skip to content

Evolving a Wire Struct

A tagged struct is a published contract: renaming the Go field is free, renaming the tag is a break, and a field added by the sender decodes into nothing on an older reader.

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

questions

4

In encoding/json, what happens to the emitted JSON key when you rename a struct field?

level: juniorimportance: must knowfreq 70%

answer

  1. the Go name leaks by default
  2. one string decides what clients parse
  3. a routine refactor can reach the wire
  4. the tag is the published identifier

basics

~20 s

Without a json tag, encoding/json emits the Go field name as the key, so renaming the field renames the key and old readers see nothing. A json tag pins the wire name, freeing the Go field to be renamed.

solid answer

~50 s

`encoding/json` takes the key from the struct tag when there is one and from the exported field name otherwise, so an untagged `FullName` field is emitted as `"FullName"`, and renaming it to `Name` changes the payload for everyone reading it. Once a payload has shipped, the tag string is the published identifier and the Go field name is internal business — so tag every field you serialise, even where the tag looks redundant, and a later refactor cannot reach the wire. The reverse direction fails just as quietly: a client whose field is tagged `json:"full_name"` and now receives `"name"` finds no matching key, leaves the field at its zero value, and gets `nil` back from `json.Unmarshal`. A rename shows up as blank data, never as a decode failure, which is exactly why the tag is treated as frozen.

code

go · 14 lines
go
// shipped
type Profile struct {
	Name string `json:"name"`
}

// after an internal rename — the emitted key is still "name"
type Profile struct {
	FullName string `json:"name"`
}

// untagged, the same rename would move the key from "Name" to "FullName"
type Profile2 struct {
	Name string
}

go deeper

for a junior

Be ready to say which key encoding/json emits for a field with a tag and for one without, and to show a one-line rename that changes the payload.

for a middle

Explain both directions: marshal prefers the tag over the field name, and unmarshal matches incoming keys against the tag with a case-insensitive fallback, so a rename changes both what you emit and what you accept.

for a senior

Stress that the failure is silent — blank fields on old clients, no error anywhere — and say how you would catch it: review discipline on tag diffs plus a test against payloads captured before the change.

for a principal

Own the rule that a shipped tag string is a published identifier under change control, and say who may change one, on what evidence, and how the codebase makes such a diff visible.

## What decides the key When `encoding/json` marshals a struct it walks the struct's **exported** fields by reflection, and for each one it needs a name to write into the JSON object. It picks that name in one of two ways: - the field carries a `json:"..."` struct tag, and the name part of that tag is used verbatim; or - the field carries no tag, and the **Go field name** is used verbatim, capital letter and all. So `FullName string` with no tag is emitted as `"FullName"`, while `FullName string` tagged `json:"full_name"` is emitted as `"full_name"`. Unexported fields are not emitted at all, because the package cannot read them. That single rule is the whole hazard. An untagged field **publishes your Go identifier**. To you and to your reviewers that identifier is internal — the sort of name changed in a routine cleanup because a better one was found. To every client that has already shipped, it is the key they parse. ## The rename, from both ends On the marshal side, renaming an untagged `Name` to `FullName` moves the payload from `{"Name":"..."}` to `{"FullName":"..."}` and nothing in the toolchain objects. The compiler has no opinion about wire names. `go vet`'s struct-tag check looks for *malformed* tags, not for *changed* ones. And your own tests very likely keep passing, because the classic round-trip test — marshal a value, unmarshal it back into the same type, compare — is symmetric: both halves of it moved together, so it cannot detect a rename by construction. If you want a test that notices, it has to compare against a payload captured **before** the change, not one produced by the code under test. On the unmarshal side, v1 matches an incoming key against the tag name (or, untagged, the field name), preferring an exact match but also accepting a case-insensitive one. So a decoder is a little more forgiving about capitalisation than people expect, and not forgiving at all about a different word. When no key in the document matches a field, that field is simply never assigned: after a fresh decode it still holds its zero value, and `json.Unmarshal` returns `nil`. Blank string, zero number, no error, no log line. ## Therefore: tag every field that crosses the wire The working rule is to put a `json` tag on **every** field of **every** struct that is marshaled or unmarshaled, including the many where the tag appears to just repeat the field name. The tag is not there to rename the field. It is there to *freeze* the name, so that renaming the field cannot move it. Once a payload has shipped, treat that tag string with the same change control you give an exported function name. A diff that edits a tag is a contract change, and phrasing it that way in review is most of the battle: one line that renames the field and rewrites its tag together reads as a single refactor to anyone skimming for logic, and is in fact two entirely different events — a harmless field rename, and a breaking wire rename. ## When the key itself really is wrong Sometimes the published name genuinely has to change: it is misspelled, or it says `email` and now carries a login handle. Against clients you do not control there is no atomic rename. What works is **emit both**: add a second field carrying the same value under the new tag, ship it, let clients move to the new key, and delete the old field only once nothing reads it. Accept both keys on the receiving side for the same window, preferring the new one. Each half of that is an addition, and addition is the direction that is safe. ## The mirror-image accident The same rule bites the other way. A field added to a struct for internal bookkeeping — exported so another package can set it, untagged because nobody thought about JSON — is now a key in your public payload, published by accident and awkward to withdraw. That, plus the rename hazard, is the case for a struct that exists only to be the wire format, whose every field is there on purpose.

  • If a field has no json tag at all, what key does json.Marshal emit for it?
    The exported field name verbatim, including its leading capital: `FullName` becomes `"FullName"`. Unexported fields are skipped entirely, because `encoding/json` cannot read them through reflection. That default is convenient in a throwaway program and a liability in a shipped payload, because it publishes an identifier you otherwise treat as internal.
  • Why does the client get no error when the key it wants disappears?
    `json.Unmarshal` only assigns fields whose key is present in the document. A field with no matching key is left exactly as it was — its zero value after a fresh decode — and the call returns `nil`. Nothing distinguishes "the server stopped sending it" from "the server sent an empty value", so the break surfaces as blank data in the UI rather than as a decode failure.
  • You genuinely must rename the wire key from `name` to `full_name`. How do you ship that?
    Not as a rename. Add a second field with tag `json:"full_name"` populated from the same value, so the payload carries both keys; publish the new one, move readers over, and delete the `name` field only when telemetry says nothing still reads it. Accept both keys on input for the same window, preferring the new one when both arrive.

The tag is the label on the outside of the parcel and the field name is what you call the box in the warehouse. Relabel the warehouse box all you like; repaint the parcel and the courier stops delivering.

saying these in an interview costs you the question

  • Says the JSON key follows the Go field name and that is fine
  • Adds a json tag only when the wire name differs from the field name
  • Expects a renamed key to produce a decode error on the client
  • Treats a struct-field rename as a purely internal refactor
  • Assumes go vet or the compiler would flag a changed wire name
  • Trusts a marshal-then-unmarshal round-trip test to catch a rename
open as a page

Which changes to a Go struct marshaled by encoding/json break clients you cannot upgrade?

level: middleimportance: should knowfreq 58%

basics

~20 s

Adding 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.

open as a page

Why keep a separate JSON wire struct rather than putting json tags on the domain type?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Tagging the domain type welds the published payload to your internal model, so every refactor becomes a wire change. A small tagged struct plus a conversion function keeps the contract in one reviewable file and lets the two change at different rates.

open as a page

When may a JSON response field be removed if some clients can never be upgraded?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Only when evidence, not a code search, says nothing in the wild still reads it: traffic by client version measured against the release that stopped needing it. Until then keep emitting the key, and decide by what emitting it forces you to keep alive.

open as a page