こんにちは、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)
- 2. omitempty の定義変更(#11939, #22480)
- 3. string オプションの適用範囲(#34268)
- 4. nil スライス、マップの出力(#27589, #37711)
- 5. 固定長配列の長さチェック
- 6. バイト配列([N]byte)のエンコード
- 7. ポインタレシーバのメソッド呼び出し(#27722, #33993)
- 8. マップの出力順序(#27179)
- 9. HTML エスケープ
- 10. 無効な UTF-8 の扱い
- 11. 重複キーの扱い(#48298)
- 12. null のマージ動作(#22177, #33835)
- 13. 複合型のマージ動作(#21092)
- 14. time.Duration の表現(#10275)
- 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 言語を使って様々なサービスの開発を行っています。技術的な挑戦を楽しみながら、一緒にプロダクトを成長させていける仲間を募集していますので、ご興味のある方は、ぜひご連絡ください。