Go 1.27で正式版になったencoding/json/v2は、nilのスライスを[]にし、大文字小文字を区別し、重複キーをエラーにする。Go 1.27.3で動かしたv1との違いと、v1の挙動を残して1つずつ移す手順を解説する。

本記事はプロモーションを含みます。
Go 1.27 で encoding/json/v2 が正式版になった。関数の名前は v1 (encoding/json) とほぼ同じなので、import を替えるだけで移せそうに見える。ところが既定の挙動がいくつか変わっていて、コンパイルは通ったまま、出力の JSON や読み込んだ値が変わる。
結論から書く。
[] になる、キーの大文字小文字を区別する、重複キーと不正な UTF-8 をエラーにする、omitempty が 0 を省かない、map のキーを並べ替えない、time.Duration をエラーにする、などこの記事のコードは、すべて Go 1.27.3 で実行した。
A new Go API for JSON - The Go Programming Language
Go 1.27 introduces new encoding/json/v2 and encoding/json/jsontext packages.
go.dev

Go チームが 2026-11-04 に公開した、encoding/json/v2 と encoding/json/jsontext の正式版の紹介記事だ。要点は次の 4 つ。
jsontext と、Go の値との対応を扱う json/v2 に分けた。v2 の関数はすべて Option を引数で受ける比べるときは、v1 と v2 を別名で並べて import する。以下の例の型はこの 2 つだ。
import (
jsonv1 "encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
)
type User struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
b1, _ := jsonv1.Marshal(User{Name: "a"})
b2, _ := json.Marshal(User{Name: "a"})
// v1: {"name":"a","tags":null}
// v2: {"name":"a","tags":[]}
nil の map も同じで、v2 は {} を出す。受け取る側が配列に null を受け付けない言語やライブラリなら、v2 の方が扱いやすい。逆に、null を「まだ設定していない」、[] を「空に設定した」と読み分けている受け手がいると、v2 にした時点で意味が変わる。
v1 と同じにするなら json.FormatNilSliceAsNull(true) と json.FormatNilMapAsNull(true) を渡す。
var u1, u2 User
e1 := jsonv1.Unmarshal([]byte(`{"NAME":"x"}`), &u1)
e2 := json.Unmarshal([]byte(`{"NAME":"x"}`), &u2)
// v1: u1.Name = "x" e1 = <nil>
// v2: u2.Name = "" e2 = <nil>
v1 は JSON のキーを大文字小文字を区別せずにフィールドへ当てる。v2 は完全に一致したときだけ当てる。この違いで一番困るのは、v2 が エラーを返さない ことだ。知らないキーは読み飛ばすので、値が空のまま処理が先へ進む。
送り手がキーの大文字小文字を揃えていない API を読んでいるなら、v1 の挙動を残す。
json.MatchCaseInsensitiveNames(true)case:ignore を足すcase:ignore は大文字小文字に加えて - と _ も無視する。json:"user_id,case:ignore" のフィールドには、user_id・UserId・userID・USER-ID のどれでも値が入った。
v1 が警告なしに受け付けていた 3 つの入力を、v2 はエラーにする。
var d1, d2 User
jsonv1.Unmarshal([]byte(`{"name":"a","name":"b"}`), &d1)
err := json.Unmarshal([]byte(`{"name":"a","name":"b"}`), &d2)
// v1: d1.Name = "b" (後のキーが勝つ)
// v2: d2.Name = "a" err = jsontext: duplicate object member name "name"
| 入力 | v1 | v2 |
|---|---|---|
{"name":"a","name":"b"} | "b" が入る | duplicate object member name "name" |
{"name":"\xff"} (不正な UTF-8) | U+FFFD (�) に置き換えて入る | invalid UTF-8 within "/name" after offset 9 |
{"name":"a"} junk を Reader から読む | "a" が入り、エラーなし | invalid character 'j' after top-level value after offset 13 |
末尾の余分なデータは、v1 の json.NewDecoder(r).Decode(&v) が検出しない。公式ブログも、よく書かれるこの形が末尾のゴミを拒めないことを v1 の問題として挙げている。v2 では json.UnmarshalRead(r, &v) がエラーにする。
重複キーを拒むのは安全のためだ。2 つのプログラムが同じ JSON の重複キーを別々の値に読むと、片方で検査を通った値と違う値がもう片方で使われうる。
注意したいのは、v2 がエラーを返したときも、値は途中まで埋まっている ことだ。上の重複キーの例では d2.Name に "a" が入っていた。末尾のゴミの例でも Name は "a" だった。エラーを見たら、その値は使わずに捨てる。
v1 と同じにするなら jsontext.AllowDuplicateNames(true) と jsontext.AllowInvalidUTF8(true) を渡す。
type Opt struct {
Count int `json:"count,omitempty"`
Note string `json:"note,omitempty"`
}
jsonv1.Marshal(Opt{}) // {}
json.Marshal(Opt{}) // {"count":0}
v1 の omitempty は Go のゼロ値 (false・0・nil・空の文字列や配列) を省く。v2 の omitempty は、JSON にしたときに空 (null・""・[]・{}) になるものだけを省く。0 は空の JSON ではないので残る。
数・bool・ポインタ・interface に omitempty を付けているフィールドは、omitzero に書き換える。omitzero は v1 と v2 で同じように効き、Count int `json:"count,omitzero"` はどちらでも {} になった。文字列・スライス・map の omitempty は、v1 と v2 で同じ結果になる。
書き換えずに v1 の意味を残すなら jsonv1.OmitEmptyWithLegacySemantics(true) を渡す。
キーが 5 つの map[string]int を 20 回 Marshal して、出た文字列の種類を数えた。
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
// v1: 1 通り ({"a":1,"b":2,"c":3,"d":4,"e":5})
// v2: 4 通り
v1 は map のキーを並べ替えて出すが、v2 は並べ替えない。JSON として同じ内容でも、出力の文字列が実行ごとに変わる。期待値の JSON を文字列で比べるテストや、出力の差分を git で見る使い方は、v2 にすると揺れる。キーの順で出すなら json.Deterministic(true) を渡す。
もう 1 つ、v1 は文字列の中の < > & を < などにエスケープするが、v2 はしない。json.Marshal("<a>&") は v1 で "<a>&"、v2 で "<a>&" になった。JSON を HTML の中に埋め込んでいるなら、jsontext.EscapeForHTML(true) を渡す。
jsonv1.Marshal(3 * time.Second) // 3000000000
json.Marshal(3 * time.Second)
// json: cannot marshal from Go time.Duration: no default representation
v1 は time.Duration をナノ秒の数で出していた。v2 には既定の表し方が無く、エラーになる。Duration のフィールドを持つ型は、v2 に移すとその型ごと Marshal できなくなる。
v1 と同じナノ秒の数で出すなら jsonv1.FormatDurationAsNano(true) を渡す。この Option は v1 のパッケージ (encoding/json) にある。
v2 の関数に替えるのと、既定の挙動を v2 にするのは、別々にできる。v1 の Marshal / Unmarshal も、内部では v2 の関数に v1 用の Option 一式 (jsonv1.DefaultOptionsV1()) を渡しているだけだ。
b, _ := json.Marshal(User{Name: "a"}, jsonv1.DefaultOptionsV1())
// {"name":"a","tags":null} (v1 と同じ)
移す順は次の 4 段にするとよい。一度に全部を v2 の既定にすると、どの違いで壊れたのかが分からなくなる。
jsonv1.DefaultOptionsV1() を付ける。出力は v1 と変わらないjson.DefaultOptionsV2() に、残す Option だけを足した形へ切り替える2 の比べ方は、同じ入力を両方で読んで、エラーの有無と値を比べるだけでよい。
// 同じ入力を v1 と v2 で読み、結果が違えば内容を返す
func diff[T any](in []byte) (string, bool) {
var a, b T
e1 := jsonv1.Unmarshal(in, &a)
e2 := json.Unmarshal(in, &b)
if e1 == nil && e2 == nil && reflect.DeepEqual(a, b) {
return "", false
}
return fmt.Sprintf("v1=%+v (%v) v2=%+v (%v)", a, e1, b, e2), true
}
User で 3 つの入力を通した結果は次のとおり。大文字違いのキーは、どちらもエラーを返さないので、値を比べないと見つからない。
{"name":"a","tags":["x"]} => differ=false
{"Name":"a"} => differ=true v1={Name:a Tags:[]} (<nil>) v2={Name: Tags:[]} (<nil>)
{"name":"a","name":"b"} => differ=true v1={Name:b Tags:[]} (<nil>) v2={Name:a Tags:[]} (jsontext: duplicate object member name "name")
4 の形は、Option を 1 つの変数にまとめておくと、呼び出しごとに並べずに済む。
var jsonOpts = json.JoinOptions(
json.DefaultOptionsV2(),
jsontext.AllowDuplicateNames(true), // 送り手が重複キーを送ってくるので残す
)
err := json.Unmarshal(body, &u, jsonOpts)
ここまでで出てきた戻し方をまとめる。struct tag で戻せるものは、型を書いた側が決められるので、呼び出し側の Option より漏れにくい。
| v2 で変わる挙動 | v1 に戻す Option | struct tag |
|---|---|---|
| nil のスライス・map | json.FormatNilSliceAsNull(true) / json.FormatNilMapAsNull(true) | - |
| 大文字小文字の区別 | json.MatchCaseInsensitiveNames(true) | case:ignore |
| 重複キー | jsontext.AllowDuplicateNames(true) | - |
| 不正な UTF-8 | jsontext.AllowInvalidUTF8(true) | - |
omitempty の意味 | jsonv1.OmitEmptyWithLegacySemantics(true) | omitzero に書き換え |
| map の順序 | json.Deterministic(true) | - |
| HTML のエスケープ | jsontext.EscapeForHTML(true) | - |
time.Duration | jsonv1.FormatDurationAsNano(true) | - |
この表は記事で動かしたものだけで、全部ではない。Go の配列を長さの違う JSON の配列から読む、[N]byte を Base64 で出す、pointer receiver の MarshalJSON を呼ぶ条件なども変わっている。一覧は encoding/json のパッケージ文書の「Migrating to v2」にある。
標準ライブラリの使い方を実務の書き方でまとめて押さえ直すなら、『実用 Go言語 第2版』が手元にあると引きやすい。
実用 Go言語 第2版 (渋川よしき 他著、オライリー・ジャパン)
encoding/json/v2 は Go 1.27 で正式版になった。v1 は今後もサポートされる[] にし、大文字小文字を区別し、重複キー・不正な UTF-8・末尾の余分なデータをエラーにする。omitempty は 0 を省かず、map は並べ替えず、time.Duration はエラーになるDefaultOptionsV1() を付けて替え、入力の実物を v1 と v2 で読み比べ、違いの出た挙動だけを残して DefaultOptionsV2() に切り替える同じ「Goの落とし穴」の記事に「Goで本番だけ出るconnection resetの直し方」がある。