結論
- JSON の「キー欠落」「
null」「[]」「0」を、Go の型は全部は区別できない。encoding/jsonは区別が消えてもエラーを返さない - 書く側: nil スライスは
nullになる。*Tにomitemptyを付けるとnullを出せない。同じ階層に同じタグがあると両方消える。NaN が 1 つあると Marshal そのものが失敗する - 読む側: キーは大文字小文字を無視して照合し、構造体に無いキーは捨てる。
nullはポインタ・スライス・map・interface 以外には何もしない。値型は0と欠落を、ポインタはnullと欠落を区別できない - 区別が契約なら、型に頼らず
map[string]json.RawMessageでキー単位に見る。テストも構造体ではなく生の JSON で固定する
以下は Go 1.26.4 / testify v1.12.1 / go-playground/validator v10.30.5 で実測した。
前提: 区別を持てるのは Go の型の側だけ
JSON の 1 項目は「キーが無い」「null」「空 ([] / "")」「ゼロ (0 / false)」「値あり」のどれかになる。
Go の値型が持てる状態は「ゼロ値」か「値あり」の 2 つで、ポインタ・スライス・map は nil の分だけ 1 つ増える。
JSON 側の状態を Go の型へ写すと、どこかの区別が消える。
ゼロ値の構造体へ Unmarshal した結果は次のとおり。
| 入力 | int | *int | []int |
|---|---|---|---|
{} (キー欠落) | 0 | nil | nil |
{"v":null} | 0 | nil | nil |
{"v":0} / {"v":[]} | 0 | &0 | []int{} |
encoding/json はこれを仕様として扱い、区別が消えたことをエラーにしない。
以下の罠は、どの区別が・どちらの向きで消えるかの違いでしかない。
書く側 (Marshal)
nil スライスは null になる
var s []T のままの nil スライスは null、[]T{} は [] になる。
検索結果を var result []T で受けて append するコードは、0 件のときだけ null を返す。
type Resp struct {
Items []string `json:"items"`
}
var found []string // 0 件なら append されず nil のまま
b, _ := json.Marshal(Resp{Items: found})
fmt.Println(string(b)) // {"items":null}
b, _ = json.Marshal(Resp{Items: []string{}})
fmt.Println(string(b)) // {"items":[]}
OpenAPI で required な array を返す API なら、null は契約違反になる。レスポンスを組み立てる時点で []T{} に初期化する。
- PITFALL: コンパイラは何も言わない。0 件のテストが件数しか見ていないと、
nullに戻っても通る (後述の「テストで形を固定する」)
*string は nil で null、string は空でも ""
ポインタは nil を null で出し、値型の string は空でも必ず "" を出す。
「空のときに null を返すか "" を返すか」は、ポインタにするかどうかで決まる。
| フィールド | 未設定 | 空文字 |
|---|---|---|
*string | {"n":null} | {"n":""} |
string | {"n":""} | {"n":""} |
- PITFALL: 経路ごとに
*stringとstringを詰め替えていると、同じフィールドが経路によってnullと""に分かれる。どちらを返すかは型で 1 つに決める
*T に omitempty を付けると null を出せない
omitempty はポインタに対しては nil かどうかだけを見て、指す先は見ない (
Go言語omitemptyの罠:falseが保存できない
)。
time.Time や struct に omitempty が効かず omitzero が要る話も同じ記事に追記した。
nil はキーごと消えるので、ポインタにしたのに null が出る経路が無くなる。
| 値 | *string + omitempty | string + omitempty |
|---|---|---|
未設定 (nil / "") | {} | {} |
空文字 (&"" / "") | {"n":""} | {} |
| 値あり | {"n":"x"} | {"n":"x"} |
2 つの差は「明示的な空文字を出せるか」だけ。
if v != "" { resp.N = &v } のように nil と空文字を必ず一致させて詰めるレスポンスでは、ポインタは出力に何の差も生まない。
null を出したいなら omitempty を外す。キーを省きたいだけならポインタは要らない。
*T + omitempty が意味を持つのは「未指定はキーなし / 明示的な空は ""」を送り分けたいとき (PATCH のリクエストを組み立てる側) だけ。
- PITFALL: ポインタがキーを隠しているように見えるが、隠しているのは
omitemptyの方。逆に*stringをstringへ「簡素化」すると、&""で送っていた削除指示がomitemptyに落ちて消える。呼び出し側を機械的に直せばビルドは通る
同じ階層に同じタグがあると、両方とも消える
同じ深さに json:"dup" を持つフィールドが 2 つあると、Marshal はどちらも出力しない。エラーは返らない。
type S struct {
A string `json:"dup"`
B string `json:"dup"` // A のタグをコピーして書き換え忘れた
C string `json:"c"`
}
b, err := json.Marshal(S{A: "1", B: "2", C: "3"})
fmt.Println(string(b), err) // {"c":"3"} <nil>
フィールドの選び方は「浅い方が勝つ => タグ付きが勝つ」で、それでも決まらなければ全部を無視する (
encoding/json
)。
埋め込みでの名前衝突を安全に扱うための規則が、同じ階層の重複にもそのまま効く。Unmarshal でも同じで、{"dup":"x"} を読んでも A と B は空のまま残る。
go vet の structtag チェックは同じ階層の重複を検出する。
$ go vet .
main.go:10:2: struct field B repeats json tag "dup" also at main.go:9
- PITFALL: 症状は「足したフィールドが返ってこない」なので、サーバのロジックを疑って時間を使う。vet を CI で回していないなら、まず同じタグを grep する
NaN と ±Inf は Marshal そのものを失敗させる
JSON には NaN と Infinity のリテラルが無い。json.Marshal は値の 1 つでも NaN なら全体をエラーにする。
1 行を丸ごと json.Marshal する自前のロガーは、その行を出せない。
slog.JSONHandler は、その値だけを文字列に置き換えて行を出す。
_, err := json.Marshal(map[string]any{"msg": "calc", "v": math.NaN()})
fmt.Println(err) // json: unsupported value: NaN
slog.New(slog.NewJSONHandler(os.Stdout, nil)).Error("calc", "v", math.NaN(), "ok", 1)
// {"time":"...","level":"ERROR","msg":"calc","v":"!ERROR:json: unsupported value: NaN","ok":1}
自前のハンドラなら、Marshal に失敗した値だけを文字列に落として行は出し続ける。slog.JSONHandler の振る舞いが手本になる。
- PITFALL: 計算結果を載せるのはたいてい ERROR の行なので、行ごと消えるとエラー集約サービスにも載らず、障害そのものが見えなくなる
読む側 (Unmarshal)
キーは大文字小文字を無視して照合する
json:"amount" のフィールドに {"AMOUNT":5} を読むと、エラーにならず Amount == 5 になる。
DisallowUnknownFields でも弾けない。大文字小文字違いのキーは、既知のフィールドに一致した扱いになる。
type Req struct {
Amount int `json:"amount"`
}
var r Req
err := json.Unmarshal([]byte(`{"AMOUNT":5}`), &r)
fmt.Println(r.Amount, err) // 5 <nil>
dec := json.NewDecoder(strings.NewReader(`{"AMOUNT":5}`))
dec.DisallowUnknownFields()
r = Req{}
err = dec.Decode(&r)
fmt.Println(r.Amount, err) // 5 <nil>
r = Req{}
err = json.Unmarshal([]byte(`{"amount":1,"AMOUNT":2}`), &r)
fmt.Println(r.Amount, err) // 2 <nil>
ドキュメントの「完全一致を優先する」は、1 つのキーに複数のフィールドが一致するときの話。 1 つのフィールドに複数のキーが一致するときは優先が無く、後に書かれたキーで上書きされる。
キー名の完全一致を外部契約にしているなら、map[string]json.RawMessage に一度読んでキー名を検査する。
- PITFALL: Go の公開フィールドは大文字始まり、JSON のキーは小文字が多いので、タグが無くても読めるよう照合を緩めている。外部の入力では、綴りの揺れたキーがそのまま通る
構造体に無いキーは黙って捨てる
前方互換のため、構造体に無いキーは既定で捨てる。拒否する DisallowUnknownFields は json.Decoder にしか無い。
var r struct {
Items []int `json:"items"`
}
dec := json.NewDecoder(strings.NewReader(`{"items":[],"operationStatus":"OPEN"}`))
dec.DisallowUnknownFields()
fmt.Println(dec.Decode(&r)) // json: unknown field "operationStatus"
json.Unmarshal で同じ入力を読むと err == nil。
- PITFALL: レスポンスから項目を削除したことを、構造体へ戻すテストでは確かめられない。テスト側の構造体にその項目が無ければ、誰かが項目を戻しても捨てて通る
null はポインタ・スライス・map・interface 以外には何もしない
null を数値や文字列の型に読むと、エラーにならずその場所の値がそのまま残る (
encoding/json
)。
ゼロ値から読めば 0、既に値があればその値になる。
var p []float64
_ = json.Unmarshal([]byte(`[140.9965,null]`), &p)
fmt.Println(p) // [140.9965 0]
var pp []*float64
_ = json.Unmarshal([]byte(`[140.9965,null]`), &pp)
fmt.Println(pp[1] == nil) // true
v := struct {
N int `json:"n"`
}{N: 7}
_ = json.Unmarshal([]byte(`{"n":null}`), &v)
fmt.Println(v.N) // 7
座標や金額のように 0 も正当な値になる配列を受けるなら、要素をポインタにして nil を弾く。
- PITFALL: 経度・緯度の 0 は実在する地点 (ギニア湾) なので、0 を不正値として弾く方式では見分けられない
値型は 0 と欠落を区別できない
値型は、欠落も null も 0 も同じ 0 で受け取る (前提の表)。
「0 が正当な値」で、かつ既存の値を上書きする API では、欠落が「0 で上書き」に化ける。
ポインタにすれば、nil = 届いていない、&0 = 0 を送ってきた、と分けられる。
type Req struct {
Count *int `json:"count"` // nil = 欠落、&0 = 明示的なゼロ
}検証タグも型で意味が変わる。go-playground/validator (gin の binding タグ) の required は、値型ではゼロ値を弾き、ポインタでは nil だけを弾く。
omitempty,oneof=OPEN CLOSED は、ポインタなら &"" を弾き、値型なら "" を素通しする。ポインタを値型へ寄せると、検証エラーだった空文字の送信が通るようになる。
- PITFALL: ポインタにすれば区別できるのは「0 と欠落」まで。「
nullと欠落」は次節のとおりポインタでも区別できない
ポインタでも null と欠落は区別できない
*T のフィールドは、キーが無ければ触られず、null なら nil が入る。ゼロ値の構造体から読むと、どちらも nil で終わる。
PATCH の「送っていない (触るな)」と「null で送った (クリアしろ)」を分けるなら、受け取ったキーの集合を別に持つ。
type PatchReq struct {
Note *string `json:"note"`
}
var req PatchReq
_ = json.Unmarshal(body, &req) // {} でも {"note":null} でも req.Note == nil
var keys map[string]json.RawMessage
_ = json.Unmarshal(body, &keys)
raw, sent := keys["note"]
switch {
case !sent:
// 送っていない => 触らない
case string(raw) == "null":
// null で送った => クリアする
default:
// 値で送った => *req.Note で更新する
}区別できるのは送る側の表現だけで、JavaScript の JSON.stringify は undefined のキーを落とし、null は残す。
- PITFALL: map でキーを引く照合は完全一致になる。構造体の方は大文字小文字を無視するので、
{"NOTE":"x"}は構造体では*req.Note == "x"になるのに、map では「送っていない」に分類されて更新が捨てられる
omitempty は読む側に効かない
omitempty は Marshal でゼロ値のキーを省く指示で、Unmarshal の挙動は何も変えない。
サーバが読むだけのリクエスト専用の型に付いていても意味が無い。
type WithOmit struct {
N string `json:"n,omitempty"`
}
type Plain struct {
N string `json:"n"`
}
// {} / {"n":""} / {"n":"x"} のどれを読んでも、2 つの型の結果は同じ
- PITFALL: 検証タグの
omitempty(binding:"omitempty,...") とは別物。こちらは読む側で効き、前節のとおりポインタか値型かで意味が変わる
テストで形を固定する
レスポンスを構造体へ戻して比べるテストは、ここまでの区別の多くを見逃す。 構造体の行は、本番と同じレスポンス型へ戻すテストを想定している。
| 書き方 | 削除した項目が戻った | [] が null になった | 項目の改名 |
|---|---|---|---|
構造体へ戻して assert.Empty | 通る | 通る | 通る |
構造体へ戻して assert.Equal(t, []T{}, ...) | 通る | 落ちる | 通る |
map[string]any で assert.Empty(t, m["k"]) | - | 通る | 通る |
map[string]json.RawMessage でキー単位に見る | 落ちる | 落ちる | 落ちる |
assert.Equal は reflect.DeepEqual で比べるので、nil スライスと空スライスを区別する。[] から null への退行はこれで捕まる。
assert.Empty / assert.Nil は、map に存在しないキーでもゼロ値を見て通る。キーの存在は assert.Contains で先に固定する。
形そのものが契約なら、キー単位で生の JSON を比べる。
func responseKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
var m map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body, &m))
return m
}
keys := responseKeys(t, w.Body.Bytes())
assert.NotContains(t, keys, "operationStatus") // 削除の固定
assert.JSONEq(t, "null", string(keys["businessHours"])) // null の固定
assert.JSONEq(t, "[]", string(keys["suspensions"])) // [] の固定
{"items":null} に対して [] を期待すると、JSONEq は次のように落ちる。
Error: Not equal:
expected: []interface {}([]interface {}{})
actual : <nil>(<nil>)- PITFALL: 形を見ないテストは、実装を
return nilに書き換えても緑のまま。テストを書いたら一度実装を壊し、落ちることを確かめる
encoding/json/v2 で変わるもの
Go 1.26.4 の encoding/json/v2 は GOEXPERIMENT=jsonv2 を付けないとビルドできない。付けて実測した結果は次のとおり。
| 挙動 | v1 | v2 |
|---|---|---|
| nil スライス | null | [] |
大文字小文字違いのキー (AMOUNT) | 一致として読む | 未知のキーとして捨てる |
| 同じ階層の重複タグ | 両方消える | Marshal がエラー |
time.Time + omitempty | ゼロ時刻を出す | ゼロ時刻を出す (omitzero が要る) |
[]float64 中の null | 0 | 0 |
| 構造体に無いキー | 捨てる | 捨てる |
| NaN | エラー | エラー |
v2 でも null と欠落、未知のキーの扱いは変わらない。キー単位で見る対策はそのまま要る。
まとめ
| 場面 | 挙動 | 対策 |
|---|---|---|
| nil スライスを Marshal | null | []T{} で初期化する |
*string / string | nil は null、string は常に "" | どちらを返すかを型で 1 つに決める |
*T + omitempty | nil はキーごと消え、null は出ない | null が要るなら omitempty を外す |
| 同じ階層の重複タグ | 両方消える | go vet を CI で回す |
| NaN / ±Inf | Marshal がエラー | 失敗した値だけ文字列に落とす (slog.JSONHandler) |
| 大文字小文字違いのキー | 一致として読む。複数あれば後勝ち | map[string]json.RawMessage でキー名を検査する |
| 構造体に無いキー | 捨てる | json.Decoder の DisallowUnknownFields |
非ポインタへの null | 何もしない (0 のまま) | 要素をポインタにして nil を弾く |
値型の欠落と 0 | 同じ 0 | ポインタにする |
ポインタの欠落と null | 同じ nil | map[string]json.RawMessage でキーの有無を見る |
読む側の omitempty | 効かない | リクエスト専用の型からは外す |
| 構造体へ戻すテスト | 削除・null / []・改名を見逃す | 生の JSON をキー単位で比べる |
