How does encoding/json's omitzero option differ from omitempty, and when do the two disagree?
answer
- one checks a list, one checks the zero
- IsZero gets a vote
- the zero time only one drops
- an allocated empty slice is not nil
- they disagree in both directions
basics
~20 sIn encoding/json, omitzero (Go 1.24) omits a field holding its type's zero value, using an IsZero() bool method when there is one. omitempty omits a fixed list of empty values instead, so the two disagree on zero structs and on non-nil empty slices.
solid answer
~50 s`omitempty` checks the value against a fixed list the encoder calls empty: `false`, numeric zero, `""`, nil pointer or interface, and any length-zero array, slice, map or string. `omitzero`, added to `encoding/json` in Go 1.24, asks a different question: is this the zero value for the field's type, and if the type has an `IsZero() bool` method, what does that method say. They agree on `0`, `false`, `""` and nil pointers. They disagree in both directions: a `time.Time` holding the zero time is omitted by `omitzero` because `IsZero` returns true, but kept by `omitempty` because a struct is never empty; a non-nil empty slice like `[]string{}` is omitted by `omitempty` but kept by `omitzero`, since the zero value of a slice is nil, not an empty one. If a tag carries both options the field is omitted when either rule fires.
code
go · 9 linestype Job struct {
Started time.Time `json:"started,omitzero"`
Finished time.Time `json:"finished,omitempty"`
Tags []string `json:"tags,omitzero"`
Labels []string `json:"labels,omitempty"`
}
// json.Marshal(Job{Tags: []string{}, Labels: []string{}}) produces:
// {"finished":"0001-01-01T00:00:00Z","tags":[]}go deeper
Know that a second option exists beside omitempty and that it is about the zero value of the type rather than a fixed list. Being able to name the zero time.Time case is enough at this level.
Give both predicates precisely and both directions of disagreement, and mention that a type's IsZero method is consulted. Say which Go version introduced the newer option.
Talk about what changes on the wire when you swap the option across an existing struct. An allocated empty slice appearing where it used to vanish is a contract change for every consumer, even though the Go code looks identical.
Own the toolchain question: the newer option is silently ignored on older toolchains, so adopting it across shared wire structs means setting a floor in go.mod and knowing which consumers build below it before the tags change meaning.
## Two different questions Both options live in the same place — after the comma in a `json:"name,..."` struct tag — and both are consulted only when encoding. What differs is the predicate. `omitempty` asks: **is this value on the encoder's empty list?** That list is fixed and has been stable for years: `false`, any numeric zero, the empty string, a nil pointer, a nil interface value, and any array, slice, map or string of length zero. `omitzero`, added in Go 1.24, asks: **is this the zero value of the field's type?** If the field's type (or its pointer type) has a method `IsZero() bool`, the encoder calls it and omits the field when it returns true. Otherwise it compares against the type's zero value directly. ## Where they agree For the everyday scalar cases the two behave identically. An `int` holding `0`, a `bool` holding `false`, a `string` holding `""`, a nil pointer, a nil map and a nil slice are all dropped by either option. If your struct is nothing but scalars and pointers, swapping one for the other changes nothing. ## Where they disagree, in both directions **A zero struct.** `time.Time{}` is the canonical case. It is a struct, so it is not on `omitempty`'s list and the field is written as `"0001-01-01T00:00:00Z"`. It *is* the zero value of `time.Time`, and `time.Time` has an `IsZero() bool` method that returns true for it, so `omitzero` drops the field. The same applies to any struct type you defined: `omitempty` never omits it, `omitzero` omits it when every field is zero (or when your own `IsZero` says so). **A non-nil empty collection.** `[]string{}` and `map[string]int{}` have length zero, so `omitempty` drops them. But the zero value of a slice or map type is `nil`, and an allocated empty one is not nil, so `omitzero` **keeps** them and writes `[]` or `{}`. This is the direction people forget, and it is occasionally what you want: an API where `[]` means "the list is now empty" and an absent key means "leave the list alone" is expressible with `omitzero` and not with `omitempty`. **A pointer to a zero value.** Both options keep it. `omitempty` keeps it because a non-nil pointer is not on the list; `omitzero` keeps it because a non-nil pointer is not the zero value of `*T`. This is why the `*T` idiom for optional fields works under either tag. ## Using both together A tag may carry both, as in `json:"tags,omitempty,omitzero"`. The field is then omitted if **either** rule fires — an empty-by-the-list value or a zero value. That covers a type where you want both a nil and an allocated-but-empty collection gone, or a struct that is both zero and, by some other measure, empty. ## The IsZero hook The `IsZero() bool` hook is what makes `omitzero` composable. Any type you own can declare what "zero" means for itself — a wrapper around a decimal that is zero when the amount is zero, an ID type that is zero when unset — and `encoding/json` will honour it without a custom marshaler. That is a much smaller commitment than implementing `json.Marshaler`, which takes over the entire encoding of the value rather than just the decision to include it. ## Version discipline `omitzero` requires a Go 1.24 or newer toolchain. On an older toolchain the tag is not an error: `encoding/json` ignores tag options it does not recognise, so the field is simply always written. That is a quiet failure mode, and it is worth knowing when a library is built and tested on a recent toolchain but consumed by a module still on an older one, or when a build pipeline pins something older than the developer machines. ## What to say in an interview Define both predicates in one sentence each, then give the two disagreements in opposite directions — the zero `time.Time` that only `omitzero` drops, and the allocated empty slice that only `omitempty` drops. Mention `IsZero` and the Go 1.24 requirement. That answer distinguishes someone who has read the current release notes from someone working from a mental model that stopped a few versions ago.
- Why does omitzero keep a []string{} that omitempty drops?The zero value of a slice type is `nil`, not an allocated slice of length zero. `[]string{}` is non-nil, so it is not the zero value and `omitzero` writes `[]`. `omitempty` uses length instead, sees zero, and drops the key. If you want an explicit empty list to reach the client, `omitzero` is the option that lets it through.
- How does a type of your own get omitted by omitzero when it is not literally all-zero?Give it an `IsZero() bool` method. `encoding/json` calls that method when the option is present and omits the field when it returns true, so the type decides what unset means for itself — an amount type that is zero at zero, an ID type that is zero when blank. It is much less invasive than implementing `json.Marshaler`, which would take over encoding of the whole value.
- What happens if a tag carries both omitempty and omitzero?The field is omitted when either predicate is satisfied — empty by the encoder's list, or the zero value for the type. That combination is useful for a slice field where you want both a nil slice and an allocated empty one to disappear, since neither option alone covers both cases.
saying these in an interview costs you the question
- Says omitzero is just a renamed omitempty
- Thinks omitzero drops a non-nil empty slice
- Believes omitempty drops a zero time.Time
- Assumes an unknown tag option fails the build on an older toolchain
- Does not know IsZero is consulted by omitzero