JSONを返すGoのAPIで、ある日からレスポンスに "active":false が混ざるようになる。原因はアプリのコードではなく、ビルド時に渡した GOEXPERIMENT=jsonv2 でした。Go 1.25で実験導入された encoding/json/v2 は、omitempty の意味を静かに書き換えます。
v1のJSONで起きていた取りこぼし
従来の encoding/json(以下v1)の omitempty はGoのゼロ値を基準にしていました。公式ドキュメントの定義はこうです。
The “omitempty” option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero.
falseや0も「空」として消える
この定義だと false も 0 も省略対象。意図的に false を返したいフィールドが、JSONから丸ごと抜け落ちます。受け取る側は「キーが無い = 未設定」と「値がfalse」を区別できません。
time.Timeにomitemptyが効かない
逆に、ゼロ値が「長さゼロ」に当てはまらない型は消えません。time.Time が典型です。
type Event struct {
Name string `json:"name"`
At time.Time `json:"at,omitempty"`
}
b, _ := json.Marshal(Event{Name: "deploy"}) // v1: encoding/json
fmt.Println(string(b))
{"name":"deploy","at":"0001-01-01T00:00:00Z"}
ゼロ値の日時 0001-01-01T00:00:00Z がそのまま出ます。time.Time は構造体で「長さゼロの値」ではないため、omitempty の網にかからない。v1のJSONを書いたことがあれば、一度は踏む挙動です。
GOEXPERIMENT=jsonv2で切り替える
v2はまだ実験段階です。Go 1.26.0時点でもデフォルトでは見えず、環境変数で明示的に有効化します。
importはencoding/json/v2
明示的に使うなら import "encoding/json/v2"。ビルド時に GOEXPERIMENT=jsonv2 を付けて実行します。
GOEXPERIMENT=jsonv2 go run .
フラグを外すとビルドが通らない
v2パッケージはビルドタグで隔離されています。フラグ無しで encoding/json/v2 をimportすると、コンパイル自体が止まります。
imports encoding/json/v2: build constraints exclude all Go files in
/usr/local/go/src/encoding/json/v2
公式ドキュメントも “Most users should use encoding/json” と明記しています。本番投入の前提ではなく、挙動を試して移行を見積もるための段階です。
omitemptyの基準がJSONの空値に変わった
v2の最大の挙動差は omitempty の定義変更です。基準がGoのゼロ値からJSONとしての空値(null / "" / [] / {})に変わりました。同じ構造体タグでも、出てくるJSONが別物になります。
同じ構造体で出力が変わる
type User struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
Active bool `json:"active,omitempty"`
Tags []string `json:"tags"`
}
b, _ := json.Marshal(User{Name: "moha"})
fmt.Println(string(b))
v1とv2で、まったく同じ入力からこう分かれます。
| パッケージ | 出力 |
|---|---|
| v1 (encoding/json) | {"name":"moha","tags":null} |
| v2 (encoding/json/v2) | {"name":"moha","age":0,"active":false,"tags":[]} |
v1は age と active を消し、tags を null に。v2は age:0 と active:false を残し、nilスライスを [] にします。フィールドの数すら違う。
falseと0が消えなくなる
v2の omitempty にとって 0 も false も「空のJSON値」ではありません。数値の 0 やbooleanの false は、れっきとした値だからです。実際にレスポンス差分を調べたとき、フロント側で active の有無を見て分岐していた箇所が、GOEXPERIMENT=jsonv2 を入れた瞬間に常時 false を受け取るようになり、表示が反転しました。タグ文字列は1文字も変えていないのに、です。
nilスライスは[]になる
v2はnilスライス・nilマップを null ではなく [] / {} として書きます。null を期待するクライアントには破壊的変更です。元の挙動に戻すなら、マーシャル時に json.FormatNilSliceAsNull(true) や json.FormatNilMapAsNull(true) をオプションで渡します。
bool・数値はomitzeroに寄せる
「falseや0を消したい」という元の意図は、別タグで表現します。omitzero です。
omitzeroはGo 1.24で入っている
勘違いしやすい点ですが、omitzero はv2専用ではありません。Go 1.24から通常の encoding/json に入っています。GOEXPERIMENT 無しで使えます。基準はGoのゼロ値、または型が持つ IsZero() bool メソッドです。
type Event struct {
Name string `json:"name"`
At time.Time `json:"at,omitzero"` // IsZero() を見る
N int `json:"n,omitzero"`
}
b, _ := json.Marshal(Event{Name: "deploy"}) // v1のまま
fmt.Println(string(b))
{"name":"deploy"}
さきほど omitempty では消えなかった time.Time のゼロ値が、omitzero なら消えます。IsZero() を見るためです。
移行の指針
v1で bool や数値、ポインタに omitempty を付けていた箇所は、意味を保つなら omitzero に置き換えます。
// Before: v2では false/0 が消えなくなる
Active bool `json:"active,omitempty"`
// After: v1・v2どちらでも false/0 を省略
Active bool `json:"active,omitzero"`
omitzero はv1とv2で同じ意味なので、先に置き換えておけばv2へ切り替えても挙動が変わりません。タグ設計の基礎はGo structタグを読み解く—omitempty・json・reflectの仕組みにまとめています。
厳格化で動かなくなる3つのデフォルト
挙動差は omitempty だけではありません。v2はパースを厳しくしています。緩いv1のJSONを投げると、これまで通らなかったエラーで弾かれます。
大文字小文字を区別する
v1はキー名を緩く照合し、大文字小文字を無視していました。v2は既定で厳密一致です。
type T struct{ Name string `json:"name"` }
var t T
json.Unmarshal([]byte(`{"NAME":"x"}`), &t)
fmt.Printf("%q\n", t.Name) // v2: "" (マッチしない)
var t2 struct{ Name string `json:"name,case:ignore"` }
json.Unmarshal([]byte(`{"NAME":"x"}`), &t2)
fmt.Printf("%q\n", t2.Name) // "x"
strict: ""
ignore: "x"
大文字小文字の揺れを許したいフィールドには、明示的に case:ignore を付けます。値は ignore か strict の2択です。
重複キーとUTF-8を拒否する
v1が許容していた重複キーを、v2は拒否します。
var t struct{ X int `json:"x"` }
err := json.Unmarshal([]byte(`{"x":1,"x":2}`), &t)
fmt.Println(err)
jsontext: duplicate object member name "x"
不正なUTF-8も同様に既定で拒否します(v1は置換文字に差し替えていた)。緩い入力をどうしても受けたい場合は、戻すためのオプションが用意されています。
| v2の既定 | v1相当に戻すオプション |
|---|---|
| 大文字小文字を区別 | タグに case:ignore |
| 重複キーを拒否 | jsontext.AllowDuplicateNames(true) |
| 不正UTF-8を拒否 | jsontext.AllowInvalidUTF8(true) |
新APIとjsontextの2層構造
v2は関数も増えました。io.Reader / io.Writer を直接受ける版が標準で生えています。
io.Reader/Writerを直接扱う
v1で json.NewEncoder(w).Encode(v) と書いていた処理は、v2では MarshalWrite 一発です。ひとつ違うのは、Marshal 系が末尾に改行を足さないこと。
json.MarshalWrite(os.Stdout, map[string]int{"a": 1})
os.Stdout.WriteString("")
{"a":1}
{"a":1} の直後に改行が無いまま <EOF> が続きます。読み込み側は UnmarshalRead(r, &v) で io.Reader から直接デコードできます。
構文と意味を分ける設計
v2は内部を2層に割っています。公式ブログの “Building on encoding/json/jsontext” セクションが説明する通り、下層の jsontext がJSON文法だけを扱い(リフレクション非依存)、上層の json/v2 がGoの値との対応付けを担います。jsontext という名前はRFC 8259の “JSON-text” 由来です。先ほどの重複キーエラーが jsontext: から始まっていたのは、構文層が弾いているからです。
速度はUnmarshalで効く
v2は性能改善も狙っています。ただし「全体的に速い」ではありません。公式ブログによると、Unmarshalは条件次第で最大10倍速くなる一方、Marshalはv1と同等(わずかに速い/遅いが入り混じる)です。
大きな効果が出るのはデコード側。Kubernetesの kube-openapi で UnmarshalJSON から UnmarshalJSONFrom へ移したところ、桁違いの改善が報告されています。マーシャル中心のワークロードなら、速度目的でv2に飛びつく理由は薄いです。
まとめ
encoding/json/v2はGo 1.26.0でも実験段階。GOEXPERIMENT=jsonv2でのみ有効になる- v2の
omitemptyはJSONの空値基準。falseや0は消えず、nilスライスは[]になる false/0を省略したい意図はomitzeroへ。Go 1.24からv1でも使え、先に寄せておけば移行が滑らかになる- 大文字小文字の区別・重複キー拒否・不正UTF-8拒否が既定。緩い入力は明示オプションで戻す
MarshalWrite/UnmarshalReadでio.Writer/io.Readerを直接扱える。Marshalは改行を足さない- 速度はUnmarshalで効き、Marshalは横ばい
v2が正式化するまでに、bool・数値の omitempty を omitzero へ寄せておく。これだけで切り替え当日に false・0 周りで壊れる箇所を先回りで潰せます。

