skip to content

When json.Unmarshal meets a JSON null, which Go field types does it set to nil and which does it leave untouched?

level: middleimportance: should knowfreq 52%

answer

  1. null means not present, not a value
  2. four kinds have a nil to take
  3. everything else is untouched
  4. no error either way
  5. a pre-filled struct reveals it

basics

~20 s

In encoding/json, a JSON null sets a pointer, interface, map or slice field to nil. For any other Go type, including numbers, strings and structs, it is a documented no-op: the field keeps its value and no error is returned.

solid answer

~40 s

`encoding/json` treats `null` as "not present" rather than as a value. It sets a pointer, interface, map or slice field to nil, because those types have a nil to be set to. For every other type — `int`, `string`, `bool`, a struct, an array — unmarshaling `null` has **no effect on the value and produces no error**. That last part surprises people twice: it does not zero the field, so decoding into a struct that already holds data leaves the old value in place, and it does not fail, so a `null` where you expected a number passes silently. The one exception is a type implementing `json.Unmarshaler`: `UnmarshalJSON` is called even when the input is `null`, so the type decides for itself. That is the hook the three-state optional-field patterns are built on.

code

go · 10 lines
go
type Config struct {
	Retries int      `json:"retries"`
	Hosts   []string `json:"hosts"`
}

c := Config{Retries: 3, Hosts: []string{"a"}}
err := json.Unmarshal([]byte(`{"retries":null,"hosts":null}`), &c)
// err == nil
// c.Retries is still 3
// c.Hosts is now nil

go deeper

for a junior

Remember that null is treated as not present rather than as a value, and that pointers, maps, slices and interfaces are the ones that end up nil. Do not expect an error from it.

for a middle

Explain the no-op clause and prove you understand it by describing what happens when decoding into a struct that already holds values, then note that a type's UnmarshalJSON still runs for null.

for a senior

Show how this becomes a production defect: a document with nulls silently leaves stale or default values in place, no error is logged, and the wrong value propagates. Say what you would change in the decode path to catch it.

for a principal

Decide, once, what null means in your API — clear the field, leave it alone, or be rejected — and make sure the decoding types enforce that answer rather than leaving it to the standard library's do-nothing default.

## The rule, precisely `encoding/json` documents two behaviours for the JSON literal `null`: 1. It unmarshals into an **interface, map, pointer or slice** by setting that Go value to `nil`. 2. "Because null is often used in JSON to mean not present, unmarshaling a JSON null into any other Go type has no effect on the value and produces no error." So `null` is not decoded as a value at all. It is decoded as an instruction that, for types with a nil, means "be nil", and for everything else means "do nothing". ## Why the no-op surprises people Two assumptions fail here. **"null will zero the field."** It will not. If you decode into a struct that is already populated — a config loaded from defaults and then overlaid with a file, a value reused across a loop, a struct pulled from a cache — a `null` in the document leaves the previous value sitting there. `Retries: 3` overlaid with `{"retries":null}` is still `3`. If you had decoded into a fresh zero struct you would have seen `0` and concluded that `null` zeroed it; both observations are consistent with the field never being touched, and only the pre-populated case tells you which is really happening. **"null on an int will error."** It will not. `json.Unmarshal` returns `*json.UnmarshalTypeError` for a *type mismatch* — a string where a number was expected, for instance — but `null` is deliberately excluded from that. A server that decodes `{"count":null}` into a `Count int` gets a nil error and whatever `Count` already held, which is exactly the shape of a bug that only appears in production data. ## Slices and maps go nil, and that is a third state Because a slice field is set to `nil` by `null`, decoding gives you three distinguishable outcomes for a slice: the key was absent (the field is untouched), the key was `null` (the field is nil), or the key was `[]` (the field is non-nil with length zero). Only the first two are hard to tell apart, and only if the field started nil. This is the reason nil versus empty slices matter on the decode side and not just the encode side. ## The Unmarshaler exception If the field's type implements `json.Unmarshaler`, the decoder calls `UnmarshalJSON` **including when the input is `null`** — the method receives the four bytes `null`. That is the documented behaviour and it is the hook that makes optional-field wrappers possible: a type can record that it was called at all (the key was present) and separately that the value it was handed was `null`. There is a subtlety in how that interacts with pointers. If the field is a *settable pointer* — `*MyType` where `MyType` implements `json.Unmarshaler` — a `null` sets the pointer to nil rather than allocating a value and calling the method. The method runs on `null` when the field is a value of the type, not a pointer to it. That difference is exactly why the three-state wrapper pattern uses a value field rather than a pointer field. ## `json.RawMessage` as the cheap version `json.RawMessage` is a `[]byte` with `UnmarshalJSON` defined, so a `json.RawMessage` field captures the raw bytes of whatever was there. Absent leaves it nil; `null` leaves it holding the literal bytes `null`; a value leaves it holding those bytes. That is a three-state field with no code of your own, at the cost of a second decode pass for the value. ## Encoding is not symmetric On the way out, a nil pointer, nil slice, nil map and nil interface all encode as `null`, while a non-nil empty slice encodes as `[]` and a non-nil empty map as `{}`. So `null` decodes into nil and nil encodes back to `null`, but a `null` that landed on an `int` never round-trips: it comes back as whatever number the field held. ## What to say in an interview State the four nil-able kinds and then the no-op clause verbatim in your own words, with the emphasis on **no error**. Follow it with the pre-populated struct example, because that is the version of the fact that has operational consequences. Mention that `UnmarshalJSON` is still called for `null` if you want to signal that you know where the optional-field patterns hook in.

  • How would you make a null on an int field an error rather than a silent no-op?
    Give the field a type that implements `json.Unmarshaler` — the decoder calls `UnmarshalJSON` even for `null`, so the method can return an error for it — or make the field `*int` and check for nil after decoding. A separate validation pass over the decoded struct works too, but only if the struct started zeroed, since a no-op leaves any pre-existing value in place.
  • Does null decoded into a []string field leave it empty or nil?
    Nil. A slice is one of the four kinds — pointer, interface, map, slice — that `null` sets to nil, so the field is nil rather than an allocated slice of length zero. The distinction matters on the way back out, since a nil slice re-encodes as `null` while a non-nil empty one encodes as `[]`.
  • Why is decoding null over a reused struct riskier than decoding into a fresh one?
    Because for non-nilable types the decoder does nothing, the field keeps the value from the previous decode. A struct reused across a loop or overlaid on defaults silently carries stale data forward, and no error is returned to signal it. Allocating a fresh zero value per document removes the whole class of problem.

saying these in an interview costs you the question

  • Says a JSON null zeroes a non-pointer int or string field
  • Expects json.Unmarshal to error on null for a numeric field
  • Thinks null on a slice yields an empty non-nil slice
  • Believes UnmarshalJSON is skipped when the value is null
  • Assumes decoding into a reused struct is equivalent to a fresh one