同じデータを JSON にしたのに、片方のサービスでは {"name":"テスト"}、もう片方では {"name":"\u30c6\u30b9\u30c8"} になる。壊れているように見えますが、どちらも正しい JSON です。違うのは言語の既定値で、どれがどう振る舞うかを覚えていないと、ログの差分比較やバイト数の見積もり、HTML への埋め込みで足をすくわれます。
この記事は、Python・Node.js・Go の標準ライブラリを実際に走らせて出力を並べたものです。使ったのは手元の macOS(arm64)の Python 3.9.0 / Node v22.22.3 / Go 1.26.2。実行したコードも載せているので、手元のバージョンで同じことを確かめられます。
入力は同じ 1 つのオブジェクト
4 つの値を 1 つのオブジェクトに入れて、3 言語でそのままエンコードしました。日本語・絵文字・HTML で意味を持つ記号・クエリ文字列つきの URL です。
{"name": "テスト", "note": "a<b>&c", "emoji": "🍣", "url": "https://example.com/?a=1&b=2"}
実測した出力(2026-10-09)
| 入力の種類 | Python 3.9.0 json.dumps(既定) |
Node v22.22.3 JSON.stringify |
Go 1.26.2 json.Marshal |
|---|---|---|---|
| 日本語「テスト」 | \u30c6\u30b9\u30c8 に逃がす |
そのまま | そのまま |
| 絵文字 🍣(U+1F363) | \ud83c\udf63(サロゲートペア2つ) |
そのまま | そのまま |
< > & |
そのまま | そのまま | \u003c \u003e \u0026 に逃がす |
| U+2028(行区切り) | \u2028 に逃がす |
そのまま | \u2028 に逃がす |
| 単独サロゲート U+D800 | \ud800 に逃がす |
\ud800 に逃がす |
\ufffd に置き換える(エラーにしない) |
| オブジェクトのキーの順 | 入れた順 | 入れた順 | map はキーを並べ替える |
| 区切りの空白 | ", " と ": " が入る |
空白なし | 空白なし |
| 切り替える手段 | ensure_ascii=False |
なし(常にこの形) | Encoder.SetEscapeHTML(false) |
実際の出力はこうなりました。
# Python 3.9.0(既定)
{"name": "\u30c6\u30b9\u30c8", "note": "a<b>&c", "emoji": "\ud83c\udf63", "url": "https://example.com/?a=1&b=2"}
// Node v22.22.3
{"name":"テスト","note":"a<b>&c","emoji":"🍣","url":"https://example.com/?a=1&b=2"}
// Go 1.26.2
{"emoji":"🍣","name":"テスト","note":"a\u003cb\u003e\u0026c","url":"https://example.com/?a=1\u0026b=2"}
3 つとも同じデータに戻ります。Python の逃がした出力を json.loads に通し、生のまま出した出力と比べると一致しました(True)。Go の \u003c も json.Unmarshal で a<b>&c に戻ります。つまりこれは互換性の問題ではなく、見た目とバイト数の問題です。
どれくらいバイト数が変わるか
上の 1 件を Python で両方の形にして、UTF-8 のバイト数を数えました。既定(逃がす)が 112 バイト、ensure_ascii=False が 95 バイト。同じ内容で 17 バイト、約 18% の差です。日本語の多いレコードを大量に保存したり転送したりするなら、効いてくる桁になります。
再現するコード
# Python
python3 -c 'import json; d={"name":"テスト"}; print(json.dumps(d)); print(json.dumps(d, ensure_ascii=False))'
// Node
node -e 'console.log(JSON.stringify({name:"テスト", note:"a<b>&c"}))'
// Go
package main
import (
"bytes"
"encoding/json"
"fmt"
)
func main() {
d := map[string]string{"note": "a<b>&c"}
b, _ := json.Marshal(d)
fmt.Println(string(b)) // {"note":"a\u003cb\u003e\u0026c"}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(d)
fmt.Print(buf.String()) // {"note":"a<b>&c"}
}
公式ドキュメントはどう書いているか
Python(docs.python.org — json・取得日 2026-10-09)は既定値をこう説明しています。”If ensure_ascii is true (the default), the output is guaranteed to have all incoming non-ASCII and non-printable characters escaped.”
Go(pkg.go.dev — encoding/json・取得日 2026-10-09)は、HTML の記号を逃がす理由まで書いています。SetEscapeHTML の項には “The default behavior is to escape &, <, and > to \u0026, \u003c, and \u003e to avoid certain safety problems that can arise when embedding JSON in HTML.” とあり、HTMLEscape の項には “so that the JSON will be safe to embed inside HTML <script> tags. For historical reasons, web browsers don’t honor standard HTML escaping within <script> tags” と理由が書かれています。map のキーの並べ替えも仕様です。”The map keys are sorted and used as JSON object keys”。
つまり Go の \u003c は親切心で、JSON を HTML の <script> の中に直接書く場面での事故を避けるためのものです。Node の JSON.stringify は逃がさないので、HTML に埋め込むなら自分で対処が必要になります(MDN — JSON.stringify・取得日 2026-10-09)。
実務でどう決めるか
- 人が読む・差分を取る JSON(設定ファイル、API のレスポンス例、テストの期待値)→ 日本語は生のまま出す。Python は
ensure_ascii=Falseを明示する。差分レビューで\u30c6\u30b9\u30c8が並ぶと、何が変わったのか読めません。 - HTML の
<script>に直接書き出す→ Go の既定が一番安全側。Node で作るなら<と U+2028 の扱いを自分で決める。 - バイト数が効く経路(ログ、キュー、課金対象の転送量)→ 生のまま出すほうが小さい。上の例で 18% 違いました。
- キーの順序に依存したテスト→ Go の map は並べ替わるので、出力の文字列比較で期待値を固定するとテストが壊れます。構造体にするか、デコードしてから比べる。
- どちらでも読めるのは確認済みなので、既存のデータを作り直す必要はありません。新しく書く側の既定値だけ決めれば十分です。
文字コードそのものを押さえたいとき
サロゲートペアや \ufffd の置き換えが出てくる場面は、JSON に限りません。文字コードの仕組みを一度通して読むと、こうした「言語ごとの既定値」の意味が早く掴めます。
広告: 本記事はアフィリエイトリンクを含みます。Amazon のアソシエイトとして、適格販売により収入を得ています。
出力は 2026-10-09 に macOS(arm64)の Python 3.9.0 / Node v22.22.3 / Go 1.26.2 で実行した結果です。バージョンによって既定値が変わることがあるため、手元で確かめてください。

コメント