JSON に日本語を入れるとエスケープされるのはどれ? Python・Node.js・Go の既定を実測して比べた

同じデータを 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 で見る →

広告: 本記事はアフィリエイトリンクを含みます。Amazon のアソシエイトとして、適格販売により収入を得ています。

出力は 2026-10-09 に macOS(arm64)の Python 3.9.0 / Node v22.22.3 / Go 1.26.2 で実行した結果です。バージョンによって既定値が変わることがあるため、手元で確かめてください。

コメント

タイトルとURLをコピーしました