skip to content

How does omitempty in encoding/json/v2 differ from omitempty in encoding/json?

level: middleimportance: should knowfreq 44%

answer

  1. Empty Go value versus empty JSON value
  2. The list on each side is different
  3. false and 0 are real JSON values
  4. Custom marshaler output is now visible to it
  5. omitzero is the closer match for scalars

basics

~20 s

encoding/json omits a member when the Go value is empty: false, 0, "", nil, or a zero-length map, slice or array. encoding/json/v2 omits it only when the value encodes to an empty JSON value: null, "", {} or []. So false and 0 are now emitted.

solid answer

~50 s

In `encoding/json`, `omitempty` is defined on the Go side: the member is dropped when the field holds one of Go's "empty" values -- `false`, `0`, `""`, a nil pointer, interface, map or slice, or a zero-length array. In `encoding/json/v2` it is defined on the JSON side: the member is dropped when it *encodes to* an empty JSON value, meaning `null`, `""`, `{}` or `[]`. The two agree on strings, nil pointers and empty collections, and disagree on numbers and booleans: a `bool` field tagged `omitempty` that holds `false` disappears under v1 and is emitted as `"verbose":false` under v2. The redefinition also makes the option work through custom marshalers, since it looks at the encoded output rather than the Go value. When you want the old behaviour for scalars, `omitzero` -- which omits the type's zero value -- is the closer match.

code

go · 9 lines
go
type Flags struct {
	Verbose bool `json:"verbose,omitempty"`
	Retries int `json:"retries,omitempty"`
	Name string `json:"name,omitempty"`
}

b, _ := json.Marshal(Flags{})
// encoding/json:    {}
// encoding/json/v2: {"verbose":false,"retries":0}

go deeper

for a junior

Remember the one-line contrast: the original package asks whether the Go value is empty, the v2 package asks whether the encoded JSON is empty. The boolean field holding false is the example to have ready.

for a middle

Explain both lists precisely, including that null, empty string, empty object and empty array are v2's empty JSON values, and why the new rule composes with a custom marshaler where the old one could not.

for a senior

Show how you would audit it: encode fixtures with both packages and diff the bytes, because nothing errors and a round-trip test passes. Then judge each diff against what the consumer does with an absent member.

for a principal

Own the API question underneath: if absent and zero mean different things to consumers, that belongs in the type as a pointer or presence flag, not in a tag option whose definition has already shifted once between package versions.

## Two definitions of "empty" The `omitempty` struct-tag option means "leave this member out when it is empty", and the whole difference between the packages is the word *empty*. **`encoding/json` defines it over Go values.** The member is omitted when the field holds the false boolean, any zero number, the empty string, a nil pointer or interface, or a map, slice or array of length zero. It is a fixed list, evaluated against the Go value before anything is encoded. **`encoding/json/v2` defines it over the encoded JSON.** The member is omitted when encoding it would produce an empty JSON value: `null`, `""`, `{}` or `[]`. Notice what is not on that list: `false` and `0` are perfectly ordinary JSON values, not empty ones, so v2 keeps them. ## Where they agree and where they bite They agree on the cases most code uses `omitempty` for. An empty string is dropped by both. A nil pointer encodes as `null`, so both drop it. A nil or zero-length slice encodes as `[]` (or `null`), so both drop it. A nil map likewise. They disagree on scalars: ```go type Flags struct { Verbose bool `json:"verbose,omitempty"` Retries int `json:"retries,omitempty"` Name string `json:"name,omitempty"` } f := Flags{} // encoding/json: {} // encoding/json/v2: {"verbose":false,"retries":0} ``` That is the migration hazard in this leaf. Nothing errors. A payload that was three members long grows to five, and every consumer that distinguished *absent* from *false* -- a partner treating a missing `verbose` as "use my default" -- now sees an explicit `false`. It is the mirror image of the case-sensitivity change: silent, and visible only if you diff the encoded output rather than watching for errors. ## Why v2 changed it The v1 rule has two known problems. First, it conflates "zero" with "absent" for types where those are genuinely different. A count of zero, a temperature of zero and `false` are real values, and a wire format that cannot express them forces every user of the field into a pointer or a wrapper type. Second, the v1 rule cannot see through a custom marshaler. A type whose `MarshalJSON`-equivalent produces `{}` is not one of Go's empty values, so v1 emits `"x":{}` even though the encoded result is exactly what `omitempty` was asked to suppress. Defining the rule over the *output* makes it compose: whatever produced the value, if the result is empty JSON, the member goes. ## What to use instead when you wanted the old rule The `omitzero` option -- available in the original package since Go 1.24 and in v2 -- omits a member when the field holds its type's zero value. For scalars that is exactly the v1 `omitempty` behaviour: `false` and `0` are the zero values of `bool` and `int`, so they are dropped again. It is not a universal replacement, though: a non-nil but empty slice `[]int{}` is not the zero value of its type, so `omitzero` keeps it while v1's `omitempty` dropped it. A field that needs both rules can carry both options. The cleaner answer for most APIs is neither: if absent and zero mean different things to the consumer, say so in the type with a pointer or an explicit presence flag, rather than encoding the distinction in a tag option whose meaning has now changed once. ## Auditing a migration Because the change is in the *output*, the test that catches it is an encode-and-compare, not a decode. Marshal a fixture of representative structs -- zero values included -- with both packages and diff the bytes. Every difference will be a `false`, a `0`, or a value produced by a custom marshaler, and each one is a question for whoever owns the consumer.

  • Why does defining omitempty over the encoded value make it work with custom marshalers?
    Because the decision moves after encoding. Under the v1 rule, a struct with a custom marshaler that produces `{}` is not one of Go's empty values, so the member is emitted anyway. Under v2's rule the encoder looks at what was actually produced, sees an empty JSON object, and drops the member. The option composes with whatever produced the value instead of second-guessing it from the Go side.
  • Is omitzero an exact replacement for v1's omitempty?
    For scalars, yes: `false`, `0` and `""` are the zero values of their types, so `omitzero` drops exactly what v1 dropped. For collections it differs -- a non-nil but empty slice or map is not its type's zero value, so `omitzero` emits `[]` or `{}` where v1's `omitempty` omitted the member. A field needing both rules can list both options.
  • What kind of test catches this change, and what kind misses it?
    An encode-and-diff catches it: marshal fixture structs, zero values included, with both packages and compare bytes. A round-trip test misses it entirely, because the extra `"verbose":false` decodes back to the same Go value; the damage is only visible to the consumer on the other end, which may treat absent and false differently.

saying these in an interview costs you the question

  • Says omitempty is unchanged between the two packages
  • Claims v2 omits false and zero as well
  • Thinks the change is caught by a round-trip test
  • Treats omitzero as an exact replacement for every field type
  • Says the change produces an error at encode time