Go JSON
"The json package only accesses the exported fields of struct types (those that begin with an uppercase letter). Therefore only the exported fields of a struct will be present in the JSON output." — JSON and Go
"The
omitzerofield tag is clearer and less error-prone thanomitemptywhen the intent is to omit zero values. In particular, unlikeomitempty,omitzeroomits zero-valuedtime.Timevalues, which is a common source of friction." — Go 1.24 release notes
encoding/json is reflection-driven and its behavior is governed almost entirely by defaults you did not write down — which fields are visible, what counts as "empty," what a JSON number becomes in Go. Every recurring bug below is one of those defaults surprising the author. The policy root (go-idiomatic-discipline) bans the swallowed error that hides a bad payload; this skill owns the encoding/json surface that produces it.
1. Only Exported Fields Marshal — the #1 Surprise
"Each exported struct field becomes a member of the object, using the field name as the object key" (encoding/json). The inverse is the trap: a lowercase (unexported) field is silently dropped — no error, no warning, just an absent key. This is the first thing to check on "why is this field missing in the JSON."
// WRONG — token is unexported; it never appears in the output, no error raised
type Config struct {
Name string
token string // marshals to nothing; round-trips as ""
}
// json.Marshal(Config{Name: "prod", token: "secret"}) => {"Name":"prod"}
// RIGHT — export the field; use a tag to control the wire name
type Config struct {
Name string `json:"name"`
Token string `json:"token"`
}
If a field is exported but you want it kept out of JSON, that is the json:"-" tag (§2) — an explicit decision, not an accident of casing.
2. Struct Tags Are the Wire Contract
"The encoding of each struct field can be customized by the format string stored under the json key in the struct field's tag ... the name of the field, possibly followed by a comma-separated list of options" (encoding/json).
type Event struct {
ID string `json:"id"` // rename to "id"
Tags []string `json:"tags,omitempty"` // omit when empty (see §3)
Internal string `json:"-"` // never marshalled or unmarshalled
Count int `json:",string"` // encoded as a JSON string: "42"
}
json:"id"— sets the JSON key; without it the Go field name is used verbatim (ID, notid).json:"-"— "if the field tag is-, the field is always omitted" (encoding/json). (To literally name a field-, usejson:"-,".),string— "signals that a field is stored as JSON inside a JSON-encoded string. It applies only to fields of string, floating point, integer, or boolean types" (encoding/json) — used for APIs that send numbers as quoted strings.
On the way in, matching is case-insensitive and prefers a tagged field, then an exact name, then any case-insensitive match (JSON and Go) — so a typo'd tag (json:"naem") can still appear to "work" via the case-insensitive fallback to the field name, then silently stop working when you rename the field. Match the tag to the wire name exactly.
3. omitempty vs omitzero — the Classic Leak
omitempty omits a field only for a specific, narrow notion of "empty": "false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero" (encoding/json). A struct is never "empty" by that definition — so a zero time.Time (a struct) is not dropped, and you leak "createdAt":"0001-01-01T00:00:00Z" into every payload.
omitzero (Go 1.24) fixes exactly this. "If the field type has an IsZero() bool method, that will be used ... Otherwise, the value is zero if it is the zero value for its type" — and crucially, "unlike omitempty, omitzero omits zero-valued time.Time values" (Go 1.24).
// WRONG — omitempty does NOT drop a zero time.Time; the field leaks
type Post struct {
CreatedAt time.Time `json:"createdAt,omitempty"`
}
// json.Marshal(Post{}) => {"createdAt":"0001-01-01T00:00:00Z"}
// RIGHT (Go 1.24+) — omitzero drops the zero value, time.Time included
type Post struct {
CreatedAt time.Time `json:"createdAt,omitzero"`
}
// json.Marshal(Post{}) => {}
This is verified against Go 1.26 in the reference test (references/common-mistakes.md §2): omitempty emits {"createdAt":"0001-01-01T00:00:00Z"}, omitzero emits {}. The same trap applies to any nested struct; omitzero honors a custom IsZero(), so omitempty is correct only for the scalar/slice/map/pointer kinds it was defined for. The zero-time.Time story is owned by go-time; omitzero's version floor (1.24) by go-version-feature-map.
4. Decoding Into interface{} Makes Every Number a float64
When you unmarshal into an any/interface{} (or a map[string]any), you get the generic shape, not your types. "The default concrete Go types are: bool for JSON booleans, float64 for JSON numbers, string for JSON strings, and nil for JSON null" (JSON and Go); objects become map[string]any, arrays []any. The footgun is float64 for every number: a 64-bit ID like 12345678901234567 cannot be represented exactly in a float64 and decodes to 12345678901234568 — a silent off-by-one.
// WRONG — interface{} decode: id is a float64, large integers lose precision
var m map[string]any
json.Unmarshal([]byte(`{"id":12345678901234567}`), &m)
id := m["id"].(float64) // => 1.2345678901234568e16, WRONG digits
// RIGHT (a) — decode into a typed struct; int64 keeps the exact value
type T struct{ ID int64 `json:"id"` }
var t T
json.Unmarshal(data, &t) // t.ID == 12345678901234567
// RIGHT (b) — when the shape is dynamic, UseNumber keeps the literal text
dec := json.NewDecoder(r)
dec.UseNumber() // "unmarshal a number into an interface value as a Number instead of as a float64"
var m2 map[string]any
dec.Decode(&m2)
n := m2["id"].(json.Number) // exact digits preserved
i, err := n.Int64() // 12345678901234567, no precision loss
json.Number "represents a JSON number literal" with .String(), .Int64(), .Float64() (encoding/json). Prefer a typed struct whenever the shape is known; reach for UseNumber() only for genuinely dynamic JSON. Both behaviors are verified in the reference test (§3).
5. Decoder/Encoder for Streams; Unmarshal for Bytes You Hold
"A Decoder reads and decodes JSON values from an input stream"; "An Encoder writes JSON values to an output stream" (encoding/json). Use them for an io.Reader/io.Writer — an HTTP body, a file, a socket — and use json.Unmarshal/json.Marshal only for a []byte you already have fully in memory.
// WRONG — slurp the whole body into memory just to unmarshal it
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &v)
// RIGHT — decode straight from the stream; one allocation-light pass
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
return fmt.Errorf("decode request: %w", err)
}
// RIGHT — strict decode: reject any key the struct doesn't declare
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return fmt.Errorf("decode request: %w", err)
}
DisallowUnknownFields "causes the Decoder to return an error when ... the input contains object keys which do not match any non-ignored, exported fields in the destination" (encoding/json). Default decoding silently ignores unknown keys; turn this on when an unexpected field should be a hard error (config files, strict APIs). Verified in the reference test (§5). Note Decoder reads one JSON value per Decode call — for newline-delimited JSON, call Decode in a loop.
6. Custom Marshaling and json.RawMessage
When a type's wire form differs from its Go form, implement the Marshaler/Unmarshaler interfaces (MarshalJSON() ([]byte, error) / UnmarshalJSON([]byte) error), or encoding.TextMarshaler for simple scalar types (also used for map keys). When you want to defer or pass through a chunk of JSON without decoding it, use json.RawMessage — "a raw encoded JSON value. It implements Marshaler and Unmarshaler and can be used to delay JSON decoding or precompute a JSON encoding" (encoding/json).
// Defer decoding a polymorphic field until the discriminator is known.
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"` // kept as bytes, decoded after switch on Type
}
A custom UnmarshalJSON is also the idiomatic place to validate or to fail closed on a missing required field — but it must return an error, never panic.
7. Always Check the Marshal/Unmarshal Error
Marshal and Unmarshal return an error, and it is never decorative: Unmarshal fails on malformed JSON, type mismatches, and (with DisallowUnknownFields) unexpected keys; Marshal fails on unsupported types (channels, functions, cyclic data) and on a custom MarshalJSON that errors. Discarding it turns a bad payload into a silently zero-valued struct.
// WRONG — a malformed body becomes a silent empty struct (policy-root violation)
var cfg Config
_ = json.Unmarshal(data, &cfg)
return cfg
// RIGHT — check, wrap with context, return
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing config: %w", err)
}
return cfg, nil
This is the policy root's headline floor applied to JSON. The %w wrapping, errors.Is/As, and message conventions are owned by go-error-handling.
8. nil Slice → null, Empty Slice → []
A nil slice and a non-nil empty slice marshal differently: nil becomes JSON null, while []T{} becomes []. (json/v2 changes this default — see §9.) Many clients reject or mishandle null where they expect an array, so initialize to []T{} when the contract demands an empty array, or use omitempty to drop the field entirely.
type Resp struct {
Items []string `json:"items"`
}
// Resp{Items: nil} => {"items":null}
// Resp{Items: []string{}} => {"items":[]}
Verified in the reference test (§5). The nil-vs-empty distinction itself is owned by go-slices-and-maps; []byte payloads (base64-encoded by default) are owned by go-strings-bytes-runes.
9. encoding/json/v2 Is Experimental — Don't Default to It
Go ships an experimental v2 (encoding/json/v2 + encoding/json/jsontext). It fixes long-standing v1 issues — rejecting invalid UTF-8 and duplicate object names, marshaling nil slices/maps as []/{} instead of null, and case-sensitive field matching. But it is gated and unstable: "This package (encoding/json/v2) is experimental, and not subject to the Go 1 compatibility promise. It only exists when building with the GOEXPERIMENT=jsonv2 environment variable set" (pkg.go.dev/encoding/json/v2); "The nature of an experiment is that the API is unstable and may change in the future" (jsonv2 blog).
Know it exists and what it fixes, but default to encoding/json (v1) for production code until v2 is no longer behind GOEXPERIMENT. Its version status is owned by go-version-feature-map.
10. Routing to Related Skills
go-idiomatic-discipline— the policy root; §7 is its "errors are values, never silently discarded" floor applied to JSON.go-error-handling— checking and wrapping theMarshal/Unmarshalerror (%w,errors.Is/As, message strings).go-slices-and-maps— the nil-vs-empty slice distinction behind §8'snullvs[].go-strings-bytes-runes—[]byteJSON payloads (base64) andstring↔[]bytecost.go-time— the zerotime.Timethat §3'somitzerofinally drops.go-version-feature-map— version floors:omitzero(1.24),encoding/json/v2experiment.
11. Reference Files
High-frequency encoding/json anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml