skip to content

Unknown Fields and Drift

encoding/json drops keys your struct does not declare and matches names case-insensitively, so a misspelled field is accepted and lost. DisallowUnknownFields is the switch that refuses it.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why does json.Unmarshal leave a struct field at zero when a key in the JSON is misspelled, and return no error?

level: juniorimportance: must knowfreq 60%

answer

  1. the decoder is tolerant by default
  2. no field matched, so nothing happened
  3. a successful decode with an untouched field
  4. zero value, nil error, no log line
  5. strictness is opt-in on the Decoder

basics

~20 s

encoding/json skips any JSON object key that matches no field of the destination struct. A misspelled key is discarded, the field keeps its zero value, and Unmarshal returns nil. Silence is the documented default, not a bug.

solid answer

~50 s

By default `encoding/json` treats a JSON object as a bag of keys to try against the destination struct: for each key it looks for a matching field, and if there is none it skips that key's value and moves on. There is no error, no warning and no record that the key existed, so `json.Unmarshal` returns `nil` and the field it was meant to fill keeps its zero value — `0`, `""`, `false`, or a nil map or slice. This is why a daemon can load a config file in which an operator wrote `"maxConnnections"` instead of `"maxConns"` and start perfectly happily with the default connection limit. The behaviour is deliberate: it lets a reader tolerate keys it does not know about. If you want the typo reported, you have to ask for it explicitly by decoding through a `json.Decoder` with `DisallowUnknownFields` set.

code

go · 8 lines
go
type Config struct {
	MaxConns int
	Timeout  string
}

var c Config
err := json.Unmarshal([]byte(`{"maxConnnections": 50}`), &c)
// err is nil; c.MaxConns is still 0

go deeper

for a junior

Be ready to say plainly that encoding/json ignores keys it cannot match, leaves the field at its zero value, and returns no error. Know that strict behaviour exists and lives on the Decoder.

for a middle

Explain the mechanics: the decoder skips the value of an unmatched key and continues, so a valid document always decodes; contrast that with a type mismatch, which does produce an error.

for a senior

Show how you would stop this reaching production: strict decoding at startup, or a second strict pass purely to log the offending key, plus logging the effective configuration the process actually holds.

for a principal

Own the posture. Decide where in the pipeline an unknown key is fatal versus merely reported, and who is allowed to relax it when a rollout is blocked by a key an older binary does not know.

## What actually happens during the decode When you call `json.Unmarshal(data, &c)` with a struct destination, the decoder walks the JSON object one key at a time. For each key it builds the list of candidate fields on the destination struct and looks for one whose name matches. If it finds a field, it decodes the value into it. **If it finds no field, it does not fail — it parses the value just far enough to skip past it and continues with the next key.** So three separate things are true at once after a decode of `{"maxConnnections": 50}` into a struct whose field is `MaxConns int`: - The input was valid JSON, so there is no syntax error. - No field matched the key, so nothing was assigned. - Nothing failed, so `Unmarshal` returns `nil` and the field is still `0`. The caller sees a successful decode. The only evidence that something was wrong is a value that happens to equal the zero value — which is indistinguishable from "the operator did not set it". ## Why the standard library chose silence The default is tolerant on purpose. A JSON document is often written by something you do not control and evolves faster than your reader does: a payload gains a field, a config template gains a setting for a newer build, an API adds metadata. If an unrecognised key were fatal, every reader would break the moment a writer added anything. Skipping unknown keys lets a program decode the part of a document it understands and ignore the rest. That tolerance is exactly the property that turns a typo into a silent misconfiguration, so the library is asymmetric by design: **lenient by default, strict only when you ask.** ## The two shapes this takes in practice **The typo.** Someone hand-edits a config file or a request body and writes `"reties"`, `"time_out"`, or `"maxConnnections"`. Nothing matches, the setting is dropped, and the process runs on defaults. This is the case the operator experiences as "my change had no effect". **The drift.** A field is renamed in the Go struct — or a writer starts emitting a new key name — and the two sides stop agreeing. Every document still decodes cleanly; the affected field just quietly stops being populated. Because nothing errors, this can survive a deploy, a rollback, and several weeks of production. ## What a zero value does and does not tell you A zero value after a decode has at least three causes that the decoder does not distinguish: the key was absent, the key was present but unmatched (the case here), or the key was present with an explicit zero — `0`, `""`, `false`. Decoding JSON `null` into most Go types is also a no-op that leaves the field as it was. Any code that reasons about "did the user set this?" from the value alone is guessing. ## How to make it visible The direct tool is a `json.Decoder` with `DisallowUnknownFields()` called before `Decode`, which turns the first unmatched key into an error whose text names it: `json: unknown field "maxConnnections"`. `json.Unmarshal` itself has no such option, so when you only have bytes you wrap them: `json.NewDecoder(bytes.NewReader(b))`. Even when you do not want strict decoding to be fatal in production, you can get the report cheaply: decode leniently into your struct as usual, then decode the same bytes a second time with the strict decoder purely to log what it complains about. The service still starts, but the offending key is now named in a log line instead of being invisible. ## The rules worth memorising - An unmatched JSON key is skipped; the decode still succeeds. - An unmatched key leaves the corresponding field at its Go zero value. - Matching is not exact-only: a key that differs from the field name only in letter case does match, so not every "typo" is dropped — some land on the field anyway. - `json.Unmarshal` cannot report unknown keys; only a `json.Decoder` configured with `DisallowUnknownFields` can. - The struct populated by a failed strict decode may already hold the fields that did match, so treat that error as fatal and throw the value away.

  • How would you make that misspelled key an error instead?
    Decode through a `json.Decoder` and call `DisallowUnknownFields()` on it before `Decode`. The first key that matches no field then produces an error whose text names it, such as `json: unknown field "maxConnnections"`. `json.Unmarshal` has no equivalent switch, so wrap the bytes with `json.NewDecoder(bytes.NewReader(b))` when that is all you have.
  • After a successful Unmarshal, can you tell whether a field was absent from the JSON or explicitly set to zero?
    Not from the value. Absent, unmatched, and explicitly `0`/`""`/`false` all leave the same result, and JSON `null` typically leaves the field untouched too. Distinguishing them requires the decode itself to carry that information — for example by decoding into a pointer field, which stays nil when no key filled it.
  • Does the same silence apply when the key matches but the type does not?
    No. A type mismatch is reported: decoding `{"maxConns": "fifty"}` into an `int` field returns a `*json.UnmarshalTypeError`. Unmatched keys are skipped silently; matched keys with the wrong JSON type are errors. That asymmetry is why a wrong value is usually noticed and a wrong key name usually is not.

It is like posting a form into a slot labelled with the fields the reader knows: anything written next to a label it does not recognise is not rejected, it is simply never transcribed, and the receipt still says accepted.

saying these in an interview costs you the question

  • Claims Unmarshal errors on keys the struct does not have
  • Says the decoder does fuzzy or nearest-name matching on keys
  • Thinks a nil error proves every key in the input was used
  • Assumes an unexported field can receive a JSON key
  • Believes json.Valid would have caught the misspelling
open as a page

What does json.Decoder.DisallowUnknownFields change about a Decode call that reads an unexpected key?

level: middleimportance: should knowfreq 50%

basics

~20 s

It makes a key that matches no field of the destination struct an error instead of a silent skip. Decode then returns an error naming the first such key, for example: json: unknown field "maxConnnections".

open as a page

An operator edits a Go daemon's JSON config and reloads it, but the setting has no effect and nothing errors. How do you diagnose it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Prove which bytes the process read, then re-decode those bytes with a json.Decoder using DisallowUnknownFields, which names the first key no field matched. If that passes, look for a duplicate key later in the file or a later layer overriding the value.

open as a page

How do you decide whether a shared Go config loader rejects unknown JSON keys across a fleet?

level: principalimportance: should knowfreq 28%

basics

~20 s

Decide per stage, not per fleet. Reject unknown keys where failing is cheap and early — CI validation and process startup — and report rather than reject on hot reload of a serving process, with the unknown keys always named in logs.

open as a page

How does encoding/json match a JSON key like "MAXCONNS" to a struct field named MaxConns?

level: middleimportance: nice to knowfreq 32%

basics

~10 s

encoding/json prefers an exact match on the field's name, and falls back to a match that ignores letter case. So MAXCONNS, maxconns and MaxCONNS all decode into MaxConns, while max_conns matches nothing.

open as a page