Which changes to a Go struct marshaled by encoding/json break clients you cannot upgrade?
answer
- one direction is safe, three are not
- who notices, and how loudly
- a missing key raises nothing at all
- a changed type raises UnmarshalTypeError
- same name, new meaning, no signal anywhere
basics
~20 sAdding a field under a new tag name is safe; an old decoder has no field for the extra key and skips it. Renaming or deleting a key breaks readers silently, and changing the JSON type behind a key breaks them loudly.
solid answer
~50 sAdditive is the only broadly safe direction. A new field with a new tag name appears as one extra key that an old decoder has nowhere to put, so the payload stays readable. Renaming a tag or deleting a field removes a key, and the decoder that wanted it just leaves its field at the zero value and returns `nil` — no error, blank data, which is the worst failure mode because your error rate never moves. Changing the JSON type behind an existing key — an `int` field becoming a `string`, or a scalar becoming an object — is at least loud: `json.Unmarshal` returns a `*json.UnmarshalTypeError` naming the field. The change nobody lists is redefining what an existing key *means* while keeping its name and type. Nothing catches that anywhere, which is why it deserves a new key rather than an edit.
code
go · 9 lines// release 3, deployed to clients that cannot be upgraded
type Profile struct {
ID int `json:"id"`
Name string `json:"name"`
}
// release 4 emits {"id":"u-1042","name":"Ada"}
// the release-3 client's json.Unmarshal returns *json.UnmarshalTypeError for "id"
// and still assigns Name, so it holds a half-filled struct plus an errorgo deeper
Be able to state the safe direction — add new keys, never rename or remove one — and to say that a client sees a missing key as an empty value rather than an error.
Classify each kind of change and name what the decoder actually does: skipped extra key, unassigned field left at zero, or a *json.UnmarshalTypeError for a changed type.
Rank the changes by how they fail in production — silent blanks versus a hard parse error that discards a whole response — and describe the deprecation path you would use for a rename.
Turn the classification into a rule the team applies without you: which categories require a migration window, who signs off, and what evidence a payload change carries into review.
## The direction that is safe Adding a field is the one change you can make to a shipped payload with confidence. Give it a tag name that has never been used, and to a client built against the previous release it is simply a key with no field to receive it: the decoder passes over it and everything else lands as before. That is what "evolve additively" means in practice, and it is why almost every workable versioning strategy for a JSON payload reduces to *only ever add*. Two caveats travel with it. **Additive is safe for readers, not for writers.** If the field you add is on a *request* struct and the server now requires it, clients built last year do not send it, and it arrives as the Go zero value: empty string, `0`, `false`. The server sees a well-formed request with a meaningless value. So a new request field must either have a defensible default or the endpoint must be versioned; you cannot make an existing request shape stricter and call it additive. **Additive is lossy through an old round-tripper.** A client that decodes your payload into its own struct, edits one field, and sends the whole object back will silently drop every key it has no field for — including the one you just added. If a workflow does read-modify-write against structs, an added field can vanish on the return trip even though nothing errored. ## The silent breaks **Deleting a field** removes its key. A decoder that wanted it never assigns the field, so it keeps its zero value, and `json.Unmarshal` returns `nil`. From the client's side this is indistinguishable from the server sending an empty value. Nothing logs, nothing alerts; the display goes blank. **Renaming a tag** is a delete plus an add, and behaves exactly like the delete: old key gone, field blank, no error. **Redefining the meaning of a key** is the worst of the three and the least discussed. `status` used to mean the account's state and now means the subscription's; the type is unchanged, the values are still plausible strings, and every old client goes on interpreting them under the old rule. There is no mechanism anywhere in Go, in JSON, or in your test suite that can notice. When the meaning changes, ship a new key and leave the old one alone. ## The loud breaks Changing the JSON type carried by an existing key is caught at decode time. If `{"id":1042}` becomes `{"id":"u-1042"}`, a client whose field is `ID int` gets a `*json.UnmarshalTypeError`, which reports the offending JSON type, the Go type and the struct field. Decoding continues past it, so the client can end up holding a partly populated struct alongside a non-nil error — which is worth knowing when you read a bug report that says "half the screen filled in". The same applies to a scalar that becomes an object or an array, an object that becomes an array, and a number that no longer fits the target integer type. Loud is better than silent, but from the client's perspective a loud break is still a break: an error at the parse boundary usually means the whole response is discarded, so changing one key's type can take out the entire screen rather than one row. ## A working checklist Before shipping a payload change, classify it: 1. **Adds a key nobody has seen** — safe for readers; check for read-modify-write round-trips and for whether anything now *requires* it. 2. **Removes or renames a key** — breaking, and silently so. Needs a deprecation window, not a review comment. 3. **Changes the JSON type under a key** — breaking, and loudly so; equivalent to a remove plus an add, so do it that way instead. 4. **Changes the meaning under a key** — breaking, invisibly. Always a new key. 5. **Changes only Go-side types that map to the same JSON** — `int` to `int64`, a named string type over `string` — not a wire change at all. The reason to write the classification down is that the first and last look identical in a diff to a reviewer who is reading for logic. Widening a Go integer and swapping a number for a string are one character apart in intent and a world apart in blast radius.
- Is adding a field safe in the request direction too?Only for whoever reads it. If the server starts requiring a new request key, clients built before it simply do not send it and the field arrives as the Go zero value — an empty string or `0` that looks like a real answer. Either the new field must have a defensible default on the server, or the request shape needs a new version; you cannot tighten an existing one additively.
- What exactly does json.Unmarshal do when a key's JSON type no longer matches the Go field?It records a `*json.UnmarshalTypeError` naming the JSON value's type, the Go type and the struct field, and carries on decoding the rest of the object before returning that error. So the caller gets a non-nil error and a partially populated struct at the same time, which is why code that ignores the error from `json.Unmarshal` can look like it half-works.
- Why is redefining an existing key worse than deleting it?Deleting at least blanks the field, and a blank is visible to a user or a support ticket. A redefinition keeps a plausible value flowing to old clients that go on applying the old rule, so the system is confidently wrong with no error, no blank and no signal in any dashboard. When the meaning moves, add a new key and leave the old one carrying its old meaning until nothing reads it.
saying these in an interview costs you the question
- Calls any payload change safe because JSON is schemaless
- Expects an old client to error when a key it reads disappears
- Renames a json tag and calls it a non-breaking refactor
- Reuses an existing key with new meaning to avoid payload growth
- Only tests the newest client against the new payload
- Forgets that an old read-modify-write client drops added keys