Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Published:

Tags:
  • go
  • encoding-json
  • api-design
  • error-handling
  • debugging
  • net-http

Go's DisallowUnknownFields: Catching Silent JSON Typos

A client sends a request body with "user_id". Your struct field is tagged json:"userId". Go's encoding/json decodes the request, finds nothing matching user_id, shrugs, and leaves the field at its zero value. No error. No warning. The handler runs, the wrong user gets whatever operation was intended for someone else, and you find out about it three weeks later from a support ticket.

This is the default behaviour of every json.Unmarshal call in Go, and most people writing HTTP handlers don't realise it until it bites them. The fix is one method call, but it's tucked away somewhere most tutorials never mention.

The default is silence

Here's the behaviour nobody warns you about:

type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

func main() {
	body := `{"name":"Alice","emial":"[email protected]"}`

	var req CreateUserRequest
	if err := json.Unmarshal([]byte(body), &req); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", req)
	// {Name:Alice Email:}
}

The typo emial instead of email doesn't cause an error. It's just dropped. req.Email is an empty string, the decode reports success, and unless something downstream validates that the email field isn't blank, this request sails through with silently missing data. Swap the typo for a field the client thinks exists but you removed two API versions ago, or a field name that's correct but capitalised differently than you expect, and the failure mode is the same: nothing tells you.

This is a deliberate design choice, not an oversight. encoding/json was built to be forgiving about extra fields, which is genuinely useful when you're decoding a subset of a large JSON document and don't want to define a struct for every field you don't care about. The problem is that the same leniency applies when you're validating a request body where every field matters.

Turning it on

The strict mode has existed since Go 1.10, but it's only available on json.Decoder, not on json.Unmarshal. That trips people up, because most code reaches for Unmarshal first and there's no equivalent option to bolt on:

func decodeStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	return dec.Decode(v)
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
	var req CreateUserRequest
	if err := decodeStrict(r.Body, &req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}
	// req is now guaranteed to contain only fields the struct declares
}

Now the same typo produces a hard error instead of a silently wrong struct:

json: unknown field "emial"

If you're already using json.NewDecoder(r.Body).Decode(&req) in your handlers, which is the more common pattern for HTTP bodies anyway since it avoids reading the whole body into memory first, adding DisallowUnknownFields() is a single extra line with no behavioural cost beyond the validation you actually want.

The error isn't a type you can switch on

Here's the bit that's mildly annoying in practice. Every other decoding failure in encoding/json gives you a concrete type: *json.UnmarshalTypeError for a type mismatch, *json.SyntaxError for malformed JSON. The unknown-field error doesn't. It's constructed internally with a plain fmt.Errorf("json: unknown field %q", fieldName) and returned as a bare error, so errors.As has nothing to unwrap.

If you want to report which field was wrong in a structured API response rather than just "bad request", you're stuck parsing the string:

const unknownFieldPrefix = "json: unknown field "

func unknownFieldName(err error) (string, bool) {
	msg := err.Error()
	if !strings.HasPrefix(msg, unknownFieldPrefix) {
		return "", false
	}
	return strings.Trim(msg[len(unknownFieldPrefix):], `"`), true
}

It works, but it's fragile in the way any string-matching-on-an-error-message code is fragile: it depends on stdlib internals that happen to be stable rather than on a documented contract. Worth knowing before you build error-reporting logic around it, and worth a comment explaining why the string match exists if you do.

What it does and doesn't catch

A few behaviours worth knowing before you rely on this:

  • It recurses into nested structs. If CreateUserRequest embeds an Address struct, an unknown field inside the nested JSON object triggers the same error.
  • Case-insensitive matching still happens first. Go's decoder matches JSON keys to struct fields case-insensitively when there's no exact match, and that lookup runs before the unknown-field check. So "Email" still matches a field tagged json:"email"; it isn't rejected.
  • It stops at the first unknown field it finds, not all of them. If a client sends three misspelled fields, you get one error, fix it, resubmit, and find the next one. Mildly annoying for a client debugging their own request, but rarely worth working around.
  • json:"-" fields are invisible to the decoder in both directions, so they're not relevant to this check at all.

Where it's the wrong choice

The instinct once you know about this flag is to slap it on every decode call. Resist that for public-facing APIs with external clients you don't control the release cadence of.

Postel's law (be liberal in what you accept) exists for a reason in API design: if you add an optional field to a response or request schema and a client is still sending the old shape, strict decoding on your end turns a harmless version skew into a hard failure for every request from that client. This is precisely the forward-compatibility problem that made encoding/json permissive by default in the first place. For a public API where clients update on their own schedule, unknown fields being ignored is a feature, not a bug: it's what lets you evolve the schema without breaking every SDK still on last year's version.

DisallowUnknownFields earns its keep in narrower, more controlled contexts:

  • Internal service-to-service APIs where both ends deploy from the same repository, so schema drift is a bug rather than expected version skew.
  • Configuration file parsing, where a misspelled key in a YAML-via-JSON or JSON config file should fail loudly at startup rather than silently falling back to a default.
  • Test fixtures and golden files, where you want a decode failure the moment the fixture drifts from the struct it's meant to represent.

If you're building a public API and still want to catch client typos without breaking forward compatibility, the usual compromise is to validate specific fields you care about rather than rejecting the whole payload for anything unrecognised, or to version the schema explicitly and only apply strict decoding within a given version's boundary.

The flag costs one line: dec.DisallowUnknownFields(). Deciding where that line belongs is the part actually worth thinking about.