Go の JSON 処理が変わる!encoding/json/v2 のテストコードから学ぶ 15 の新常識

こんにちは、DX ビジネス開発部の岡留です。普段は Support & Growth Portalの開発をしています。

本記事は、OPTiM TECH BLOG Advent Calendar 2025 Day 6 および Go Advent Calendar 2025 シリーズ2 Day 6 の記事です!

今回は、Go 1.25 で実験的に追加され 1.26 で本格導入されるかもしれない encoding/json/v2 についての話です。
Go の公式リポジトリには v1 と v2 の動作の違いを確認できる v2_diff_test.go というテスト が用意されており、15 個のテスト関数としてまとめられています。こちらを参考に変更点をみてみたいと思います!

1. 大文字小文字の区別(#14750

JSON キーと構造体のフィールドを対応づける JSON タグについてです。
v1 では json タグと json キーの大文字小文字を区別していませんでしたが、未知のフィールドを unmarshal する際にパフォーマンスコストが発生するため、v2 では大文字小文字を区別するのがデフォルトの動作になります。
case:ignoreによって v2 でも大文字小文字を区別しないようにできますが、「-」 や 「_」 も無視され v1 より緩くマッチされます。

type Fields struct {
    FieldA bool
    FieldB bool `json:"fooBar"`
    FieldC bool `json:"fizzBuzz,case:ignore"` // v2で大文字小文字を区別しないマッチングを有効化
}

Unmarshal の比較

入力/値 v1 v2
{"fielda": true} → FieldA true false (マッチしない)
{"FooBar": true} → FieldB true false (マッチしない)
{"FIZZBUZZ": true} → FieldC true true (case:ignore)
{"fizz_buzz": true} → FieldC true true (case:ignore)

2. omitempty の定義変更(#11939, #22480

v1 では omitempty を付与したフィールドが Go の空値(false,0,nil,"")である場合のマーシャリングが省略されていましたが、v2 では omitempty を付与したフィールドが JSON の空値(null,"",[],{})である場合のマーシャリングを省略します。

type Struct struct {
    Foo string  `json:",omitempty"`
    Bar []int   `json:",omitempty"`
    Baz *Struct `json:",omitempty"`
}
type Types struct {
    Bool     bool     `json:",omitempty"`
    Int      int      `json:",omitempty"`
    StructA  Struct   `json:",omitempty"`
    StructB  Struct   `json:",omitempty"`
    PointerB *string  `json:",omitempty"`
}

Marshal の比較

フィールド v1 v2
Bool false 省略 false
Int 0 省略 0
StructA Struct{} {} 省略
StructB Struct{Bar: []int{}, Baz: new(Struct)} {"Baz":{}} 省略
PointerB new(string) (→"") "" 省略

3. string オプションの適用範囲(#34268

フィールドの値をマーシャリング時に文字列へと変換する string オプションに関する変更です。
v1 では string オプションは、文字列、bool、数値に適用されていたが、v2 では数値のみに制限されます。Go Module Proxy に存在するコードを見た結果、実際の文字列化の用途が数値に限られていたということらしいです。
さらに v2 では、スライス、マップ、などの複合型内の数値にも再帰的に適用されるようになります。

type Types struct {
    String   string         `json:",string"`
    Bool     bool           `json:",string"`
    Int      int            `json:",string"`
    Map      map[string]int `json:",string"`
    Slice    []int          `json:",string"`
    PointerC **int          `json:",string"`
}

Marshal の比較

フィールド v1 v2
String "string" "\"string\"" "string"
Bool true "true" true
Int 1 "1" "1"
Map {"Name": 1} {"Name":1} {"Name":"1"}
Slice [1] [1] ["1"]
PointerC **1 1 "1"

Unmarshal の比較

入力 v1 v2
{"Bool": "true"} true (成功) エラー
{"Map": {"Name":"1"}} エラー {"Name": 1} (成功)

4. nil スライス、マップの出力(#27589, #37711

Go のスライスとマップが初期化されていない状態でマーシャリングされた時の動作が変わります。
v1 では nil スライスと nil マップのマーシャリングは null でしたが、v2 では空の配列と空の JSON オブジェクトとなります。
スライスやマップが nil になりうるという Go 言語特有の情報を、JSON という言語に依存しないデータ交換形式の表現に漏らすべきではないという理由のようです。
format:emitnullタグによって v2 でも v1 の動作を強制することも可能です。

type Types struct {
    Slice []int
    Map   map[string]int
}

Marshal の比較

フィールド v1 v2
Slice nil null []
Map nil null {}

5. 固定長配列の長さチェック

v1 では任意の長さの JSON 配列を Go の配列へ unmarshal できましたが、v2 では要素数が一致しない場合エラーとなります。 Go でスライス(可変長)ではなく配列(固定長)を選択して使用する場合には長さに重要な意味を持っているはずなので、この詳細な情報を無視することはバグへと繋がりやすいため修正されます。

var arr [3]int

Unmarshal の比較

入力 v1 v2
[1, 2, 3, 4, 5] [1, 2, 3] (切り詰め) エラー
[1, 2] [1, 2, 0] (ゼロ埋め) エラー
[1, 2, 3] [1, 2, 3] [1, 2, 3]

6. バイト配列([N]byte)のエンコード

v1 では符号なし整数配列となっていましたが、v2 ではバイナリ値として扱われるようになります。これにより、[N]byte[]byte の動作に一貫性がでます。
format:arrayタグによって v2 でも v1 の動作を選択することができます。

type Types struct {
    ArrayN [4]byte
}

Marshal の比較

フィールド v1 v2
ArrayN [4]byte{1,2,3,4} [1,2,3,4] "AQIDBA=="

7. ポインタレシーバのメソッド呼び出し(#27722, #33993

Go では型にメソッドを定義する際に、値レシーバとポインタレシーバで定義することができ、普通に使う分には値に対してポインタメソッドを呼んでも Go が自動的にポインタに変換して呼び出す仕組みになっています。しかし、マップの値などアドレスを取得できないケースがあります。

// 補足: ポインタレシーバとアドレス取得の関係
type MyInt int
func (m *MyInt) PointerMethod() {}

var n MyInt = 42
n.PointerMethod() // OK - Goが自動で (&n).PointerMethod() に変換

m := map[string]MyInt{"key": 42}
m["key"].PointerMethod() // コンパイルエラー! マップの値はアドレス取れない

v1 ではアドレスを取得できるところでのみメソッドが呼ばれていましたが、v2 ではアドレスが取得できない場合でも一時的にヒープにコピーしてアドレスを作ることで常にポインタメソッドを呼び出すようになりました。

type PointerReceiver int

func (p *PointerReceiver) MarshalJSON() ([]byte, error) {
    return []byte(fmt.Sprintf(`"%d"`, *p)), nil
}

type Types struct {
    PointerReceiver PointerReceiver
}

Marshal の比較

フィールド v1 v2
PointerReceiver 1 1 (メソッド呼ばれない) "1" (メソッド呼ばれる)

8. マップの出力順序(#27179

v1 ではマップは決められたキー順序でマーシャルされていましたが、v2 ではキー順序は固定されなくなります。パフォーマンスを優先し、ストリーミング方式で動作することを保証するための変更です。

type Types struct {
    Map map[string]int
}

Marshal の比較

フィールド v1 v2
Map {"a":1,"b":2,"c":3} {"a":1,"b":2,"c":3} (ソート済み) 順序不定

9. HTML エスケープ

v1 では JSON 文字列エンコーディングは HTML 関連の特殊文字をエスケープしていましたが、v2 では RFC 8785 に準拠した表現を使用します。
HTML エスケープは JSON の仕様ではないため、JSON ライブラリで HTML 固有のエスケープを行うことは適切ではなく、必要であればユーザーが自分で手動で確認するべきということのようです。

type Types struct {
    String string
}

Marshal の比較

フィールド v1 v2
String "<script> console.log("Hello, world!"); </script>" "\u003cscript\u003e console.log(\"Hello, world!\"); \u003c/script\u003e" "<script> console.log(\"Hello, world!\"); </script>"

10. 無効な UTF-8 の扱い

v1 では JSON シリアライゼーションは無効な UTF-8 を黙って無視して Unicode 置換していましたが、データ破損につながることから v2 では無効な UTF-8 を検出したらエラーとなります。

type Types struct {
    String string
}

Marshal の比較

フィールド v1 v2
String "Hello, \xff\xfe" "Hello, \ufffd\ufffd" (置換) エラー

11. 重複キーの扱い(#48298

v1 ではキーが重複する JSON オブジェクトでも許容されていましたが、RFC 8259 では重複キーの処理は未定義であり v2 ではエラーとなります。

type Types struct {
    String string `json:"string"`
}

Unmarshal の比較

入力 v1 v2
{"string":"first","string":"second"} "second" (後勝ち) エラー

12. null のマージ動作(#22177, #33835

フィールドが null の JSON オブジェクトを既に値を持っている構造体に unmarshal するケースです。
v1 ではフィールドの型で一貫性がなく説明の難しい動作でしたが、v2 では一貫して値がクリアされるようになります。

type Types struct {
    Bool   bool
    String string
    Int    int
    Map    map[string]string
    Struct struct{ Field string }
    Array  [1]string
}

Unmarshal の比較: 既存値にnullを適用

フィールド 既存値 v1 v2
Bool true true (維持) false (クリア)
String "old" "old" (維持) "" (クリア)
Int 1234 1234 (維持) 0 (クリア)
Map {"old":"old"} nil (クリア) nil (クリア)
Struct {Field:"old"} {Field:"old"} (維持) {} (クリア)
Array ["old"] ["old"] (維持) [""] (クリア)

13. 複合型のマージ動作(#21092

json.Unmarshal は、既存の値に対して JSON をマージする動作をします。

// 補足: マージ動作の例
user := Tuple{Old: true}  // 既存の値
json.Unmarshal([]byte(`{"New": true}`), &user)
// user = {Old: true, New: true}  ← Oldは維持、Newだけ更新

v1 では複合型(スライス、マップ、構造体)のマージの動作に一貫性がありませんでしたが、v2 では RFC 7396 の考え方に基づき JSON 配列は置換、JSON オブジェクトはマージという一貫した動作を提供します。

type Tuple struct{ Old, New bool }
type Composites struct {
    Slice  []Tuple
    Array  [1]Tuple
    Map    map[string]Tuple
    Struct struct{ Tuple Tuple }
}

Unmarshal の比較(既存値{Old:true}{"New":true}を適用)

フィールド v1 v2
Slice[0] {Old:true, New:true} (マージ) {Old:false, New:true} (置換)
Array[0] {Old:true, New:true} (マージ) {Old:false, New:true} (置換)
Map["Tuple"] {Old:false, New:true} (置換) {Old:true, New:true} (マージ)
Struct.Tuple {Old:true, New:true} (マージ) {Old:true, New:true} (マージ)

14. time.Duration の表現(#10275

v1 では符号付き整数として扱われましたが、v2 では time.Duration.String/time.ParseDuration を使用してフォーマット/パースされます。
time.Duration のデフォルト表現はまだ確定していないようです(#71631

type Types struct {
    Duration time.Duration
}

Marshal の比較

フィールド v1 v2
Duration time.Minute 60000000000 "1m0s"

15. 空の構造体

公開フィールドを持たない構造体のマーシャリングの動作が変わります。 v1 では {} としていましたが、v2 ではエラーとなります。
非公開フィールドがシリアライズされることを期待して混乱してしまう落とし穴を避ける目的での変更です。

err := errors.New("error")

// errors.New("error") が返す errors.errorString は非公開フィールドのみを持つ
type errorString struct {
    s string // 先頭小文字は非公開
}

Marshal の比較

v1 v2
errors.New("error") {} エラー

まとめ

encoding/json パッケージの v1 と v2 の動作の違いを見てみました! Go に長く慣れ親しんできたエンジニアにとっては混乱するケースもあるかもしれませんが、JSON として正しく、一貫性があり、パフォーマンスが向上にもつながる変化がたくさんありそうです!v2_diff_test.goはテストコードが仕様理解のためのドキュメントの役割も果たしている良い例だと思うので、うまく利用しながら新しい動作に慣れていきましょう!

おわりに

当社では、Go 言語を使って様々なサービスの開発を行っています。技術的な挑戦を楽しみながら、一緒にプロダクトを成長させていける仲間を募集していますので、ご興味のある方は、ぜひご連絡ください。

www.optim.co.jp