フューチャー技術ブログ

Go1.24 リリース連載 encoding/json

はじめに

こんにちは。ペンギンになりたいエンジニアの島ノ江です。普段は CSIG で「FutureVuls」という脆弱性管理サービスの開発・営業を担当しています。

Go1.24リリース連載の3日目。Go 1.24 で encoding/json に追加される omitzero を扱います(リリースノート

omitzero とは

omitzero は、Go の構造体を JSON に変換する際、フィールドの値がゼロ値である場合にそのフィールドを省略してくれるタグオプションです。json 変換の歯がゆい部分を修正してくれるアップデートです。

これまで Go にはフィールドが空の値のときに json 出力から省略する omitempty というタグがありました。omitempty では、数値型の 0、文字列型の ""(空文字列)、ポインタ・スライス・マップのnil が json フィールドに含まれる場合、その値が省略されます。
しかしこの omitempty、struct(特に time.Time{} 型)については省略されず、APIレスポンスに不要なフィールドが含まれてしまうなどの問題がありました。

実際のコードで見てみる

playground を使って omitempty, omitzero の挙動を見ていきます。

struct の json タグに omitempty, omitzero の両方を指定して、 Go1.23 と Go dev branch の両方で実行してみます。なお、omitemptyomitzero を同時に指定すると、値が空かゼロの場合に省略されます。

If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both).

https://go.dev/play/p/s_Z_tsX5s7k

type Pet struct {
Name string
Age int
}
type Person struct {
Age int
Name string
Hobby string `json:",omitempty,omitzero"`
Pet Pet `json:",omitempty,omitzero"`
CreatedAt time.Time `json:",omitempty,omitzero"`
}

func printMarshal(person Person) {
marshal, _ := json.Marshal(person)
fmt.Printf("%s\n", string(marshal))
}

func main() {
var alice, bob Person
alice = Person{
Age: 25,
Name: "Alice",
Hobby: "Game",
Pet: Pet{
Name: "Ace",
Age: 5,
},
CreatedAt: time.Date(2025, 1, 23, 4, 56, 7, 0, time.UTC),
}
printMarshal(alice)

bob = Person{
Age: 25,
Name: "Bob",
}
printMarshal(bob)
}

Go 1.23 までの挙動

Go 1.23 のブランチで実行すると、以下のような出力結果になります。 Bob の結果を見ると

  • struct の Pet が省略されず、各フィールドがゼロ値の struct になる
  • time.Time 型のフィールドがゼロ値の "0001-01-01T00:00:00Z" になる
{"Age":25,"Name":"Alice","Hobby":"Game","Pet":{"Name":"Ace","Age":5},"CreatedAt":"2025-01-23T04:56:07Z"}
{"Age":25,"Name":"Bob","Pet":{"Name":"","Age":0},"CreatedAt":"0001-01-01T00:00:00Z"}

Bob では Hobby, Pet, CreatedAt の3つのフィールドを指定していないので、気持ちとしてはこれらも出力からは省略されてほしいですが、不要な値が含まれています。

omitempty では time.Time{} のゼロ値が省略されないことを回避するために、あえてポインタ型を使う等の策がとられていました。

Go 1.24 での挙動

omitzero が追加された Go dev branch で実行すると、以下のような出力結果になります。
期待通り、宣言時に入力しなかった Pet, CreatedAt のフィールドも出力から省略されています。不要な出力が含まれておらずこれはうれしい。

{"Age":25,"Name":"Alice","Hobby":"Game","Pet":{"Name":"Ace","Age":5},"CreatedAt":"2025-01-23T04:56:07Z"}
{"Age":25,"Name":"Bob"}

その他

struct や time.Time{} の扱い以外に omitzero の特徴を見ていきます。

IsZero() のカスタマイズ

json の field には IsZero() bool のインターフェースが定義されており、このメソッドを実装することでゼロ値の定義を柔軟に変更できるようになります(実装

  • IsZero() bool メソッドが存在する場合は、このメソッドの戻り値の true/false
  • そうでない場合は、reflect.Value.IsZero に従ってそのフィールドがゼロ値であるか

により、フィールドを省略するかどうかを判定するようになります。

例えば、以下のように「Pet の年齢が0以下であればゼロ値とみなす」という定義をすると、ゼロ値の定義が変わり、IsZero() bool が条件を満たすときに Pet のフィールドも省略されています。

https://go.dev/play/p/kuhfd4mDw2I?v=gotip

func (p Pet) IsZero() bool {
return p.Age <= 0
}
func main() {
var carol Person
carol = Person{
Age: 25,
Name: "Bob",
Pet: Pet{
Name: "charlie",
Age: -10,
},
}
printMarshal(carol) //{"Age":25,"Name":"Bob"}
}

空とnilの区別

スライスにおいて、omitempty では空スライスとnilスライスはどちらも省略されてしまいます。一方で、omitzero ではこれらを区別するようになり、「リストが存在しないのか」「リストの要素がないのか」を分けることができるようになります。これはマップについても同様です。

https://go.dev/play/p/xwOXmnAEfVe?v=gotip

func printResponse(res Response) {
marshal, _ := json.Marshal(res)
fmt.Printf("%s\n", string(marshal))
}

type Response struct {
Items []string `json:",omitzero"`
}

func main() {
emptyRes := Response{
Items: []string{},
}
printResponse(emptyRes) // {"Items":[]}

nilRes := Response{}
printResponse(nilRes) // {}
}

本機能の歴史

このタグが追加された Issue を見てみると、起源は 2021年4月21日まで遡ります。

https://github.com/golang/go/issues/45669

omitempty が指定された際に省略されるケースのうち、空の struct に関する記述がドキュメントにないというのが発端のようです。空の struct についても、数値型や文字列型と同様に省略されるのが自然だという問題提議です。

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 empty array, slice, map, or string.

これについてはコミュニティでも認識されていましたが、具体的な解決策にいたらずという形で課題が残っていました(コメント)。
encoding/json にはバグともとれるような問題があるが広く使われているものであり、後方互換性や現在並行して進めている encoding/json/v2 も考慮しなければならず、慎重に議論されてきたようです。それが遂に Go1.24 で終着しました。

さいごに

今後は、 time.Time{} 型なども含めてフィールドがゼロ値である場合に省略してくれる omitzero を基本的に利用していくのが、明快さ・柔軟性ともに良いと思われます。後方互換性も考慮して omitempty は引き続き使えますが、それ以外にあえて omitempty を選択する理由はなさそうです。

実際私も初めて Go の API 周りに触れた際、time.Time{} を marshal した際に omit されず「??」となりました。 Go は時間のゼロ値も(一般的な時間のゼロ値である)UNIX時間と異なるなど、初学者が徒に混乱してしまう仕様があります。アップデート毎に、少しずつこのような歯がゆい部分が改善されていくのは良いですね。

以上で本ブログを終えます。Go連載では初めての執筆でしたが、Issue やリリースノートを読んでいくと言語の理解も深まり、学びが多くて面白かったです。

それでは、またの機会で。