Goのencoding/json/v2で変わる挙動と移行手順

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

Goのencoding/json/v2で変わる挙動と移行手順

本記事はプロモーションを含みます。

はじめに:import を v2 に替えると、コンパイルが通ったまま挙動が変わる

import を encoding/json/v2 に替えると、nil のスライス・大文字違いのキー・重複キー・omitempty・map の順・time.Duration の既定が v1 から変わる

Go 1.27 で encoding/json/v2 が正式版になった。関数の名前は v1 (encoding/json) とほぼ同じなので、import を替えるだけで移せそうに見える。ところが既定の挙動がいくつか変わっていて、コンパイルは通ったまま、出力の JSON や読み込んだ値が変わる。

結論から書く。

  • v1 は今後もサポートされ、v2 へ移す義務は無い
  • v2 で変わる既定は、nil のスライスが [] になる、キーの大文字小文字を区別する、重複キーと不正な UTF-8 をエラーにする、omitempty が 0 を省かない、map のキーを並べ替えない、time.Duration をエラーにする、など
  • どれも Option か struct tag で v1 の挙動に戻せる。まず v1 の Option 一式を付けて関数だけ替え、違いの出た挙動を 1 つずつ確かめて外していく

この記事のコードは、すべて Go 1.27.3 で実行した。

何の話か:Go 公式ブログの「A new Go API for JSON」

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 つ。

  • v1 には、大文字小文字を区別しない照合や重複キーの受理など、直したい既定がある。互換性の約束 (Go 1 compatibility promise) があるので、v1 のままでは直せない。そこで別のパッケージとして v2 を作った
  • JSON の構文だけを扱う jsontext と、Go の値との対応を扱う json/v2 に分けた。v2 の関数はすべて Option を引数で受ける
  • 性能は、Unmarshal が v1 より大幅に速く (ベンチマークで最大 10 倍)、Marshal はほぼ同じ
  • v1 は、v2 に v1 用の Option 一式を渡す形で作り直されている。そのため v1 を使うパッケージと v2 を使うパッケージを混ぜて使える

v1 から v2 で変わる挙動

比べるときは、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"`
}

nil のスライスが null でなく [] になる

Tags が nil の User を Marshal すると、v1 は tags を null、v2 は [] で出す。FormatNilSliceAsNull を付けると v2 でも null になる
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) を渡す。

大文字小文字の違うキーが、エラーにならずに入らなくなる

tag が name のフィールドに大文字の NAME キーを読むと、v1 は x が入り、v2 はエラーを出さずに空のまま。case:ignore を付けると v2 でも入る
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)
  • フィールドごとに戻す: struct tag に case:ignore を足す

case:ignore は大文字小文字に加えて - と _ も無視する。json:"user_id,case:ignore" のフィールドには、user_id・UserId・userID・USER-ID のどれでも値が入った。

重複キー・不正な UTF-8・末尾の余分なデータがエラーになる

v1 が通していた重複キー、不正な UTF-8、末尾の余分なデータを、v2 はエラーにする。ただしエラーを返したときも値は途中まで埋まっている

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"
入力v1v2
{"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) を渡す。

omitempty で 0 と false が省かれなくなる

int に omitempty を付けると、v1 は 0 を省き、v2 は count に 0 を入れて出す。omitzero に替えると両方で省かれる
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) を渡す。

出力のバイト列が変わる:map の順序と HTML のエスケープ

キーが 5 つの map を 20 回 Marshal すると、v1 の出力は 1 通り、v2 は 4 通りになった。Deterministic を付けるとキーの順で出る

キーが 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) を渡す。

time.Duration が Marshal できなくなる

3 秒の time.Duration を Marshal すると、v1 は 3000000000 を出し、v2 は no default representation のエラーを返す。FormatDurationAsNano で v1 と同じになる
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) にある。

v1 の挙動を残して、1 つずつ移す

v2 の関数に v1 の Option 一式を付けて替え、入力の実物を v1 と v2 で読み比べ、違いの出た挙動だけを残して DefaultOptionsV2 に切り替える

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 の既定にすると、どの違いで壊れたのかが分からなくなる。

  1. 関数だけ v2 に替え、jsonv1.DefaultOptionsV1() を付ける。出力は v1 と変わらない
  2. 受け取る JSON の実物 (テストの fixture、ログに残した body) を v1 と v2 の両方で読み、結果が違うものを洗い出す
  3. 違いの出た挙動だけ、Option か struct tag で v1 の挙動を残す
  4. 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 に戻す Optionstruct tag
nil のスライス・mapjson.FormatNilSliceAsNull(true) / json.FormatNilMapAsNull(true)-
大文字小文字の区別json.MatchCaseInsensitiveNames(true)case:ignore
重複キーjsontext.AllowDuplicateNames(true)-
不正な UTF-8jsontext.AllowInvalidUTF8(true)-
omitempty の意味jsonv1.OmitEmptyWithLegacySemantics(true)omitzero に書き換え
map の順序json.Deterministic(true)-
HTML のエスケープjsontext.EscapeForHTML(true)-
time.Durationjsonv1.FormatDurationAsNano(true)-

この表は記事で動かしたものだけで、全部ではない。Go の配列を長さの違う JSON の配列から読む、[N]byte を Base64 で出す、pointer receiver の MarshalJSON を呼ぶ条件なども変わっている。一覧は encoding/json のパッケージ文書の「Migrating to v2」にある。

標準ライブラリの使い方を実務の書き方でまとめて押さえ直すなら、『実用 Go言語 第2版』が手元にあると引きやすい。

実用 Go言語 第2版 (渋川よしき 他著、オライリー・ジャパン) 画像: 楽天市場

実用 Go言語 第2版 (渋川よしき 他著、オライリー・ジャパン)

まとめ

  • encoding/json/v2 は Go 1.27 で正式版になった。v1 は今後もサポートされる
  • v2 は nil のスライスを [] にし、大文字小文字を区別し、重複キー・不正な UTF-8・末尾の余分なデータをエラーにする。omitempty は 0 を省かず、map は並べ替えず、time.Duration はエラーになる
  • 大文字違いのキーはエラーにならずに値が空のまま進む。v2 がエラーを返したときも値は途中まで埋まるので使わない
  • 移すときは、v2 の関数に DefaultOptionsV1() を付けて替え、入力の実物を v1 と v2 で読み比べ、違いの出た挙動だけを残して DefaultOptionsV2() に切り替える

同じ「Goの落とし穴」の記事に「Goで本番だけ出るconnection resetの直し方」がある。

参考