# Go JSON

> Guides encoding/json correctly — only exported fields marshal (a lowercase field is silently dropped), struct tags (`json:"name,omitempty"`, `json:"-"`, `,string`), the omitempty-vs-omitzero (Go 1.24) trap where omitempty does NOT drop a zero struct or time.Time, decoding into interface{} yielding map[string]any with every number a float64 (precision loss → json.Number / UseNumber), Decoder/Encoder for streams, DisallowUnknownFields, custom MarshalJSON, json.RawMessage, and always checking the Marshal/Unmarshal error. Auto-invokes when writing or editing struct json tags, json.Marshal/Unmarshal, encoding/json, Decoder/Encoder, omitempty/omitzero, or on "why is this field missing in the JSON" / "why is my number a float64" requests. The struct tag is a contract; the surprises are all in the defaults.

- Skill: `ctoth/go-json` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-json`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-json/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-json

---


# 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](https://go.dev/blog/json)

> "The `omitzero` field tag is clearer and less error-prone than `omitempty` when the intent is to omit zero values. In particular, unlike `omitempty`, `omitzero` omits zero-valued `time.Time` values, which is a common source of friction."
> — [Go 1.24 release notes](https://go.dev/doc/go1.24)

`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](https://pkg.go.dev/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."

```go
// 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](https://pkg.go.dev/encoding/json)).

```go
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`, not `id`).
- `json:"-"` — "if the field tag is `-`, the field is always omitted" ([encoding/json](https://pkg.go.dev/encoding/json)). (To literally name a field `-`, use `json:"-,"`.)
- `,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](https://pkg.go.dev/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](https://go.dev/blog/json)) — 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](https://pkg.go.dev/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](https://go.dev/doc/go1.24)).

```go
// 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](https://go.dev/blog/json)); 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.

```go
// 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](https://pkg.go.dev/encoding/json#Number)). 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](https://pkg.go.dev/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.

```go
// 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](https://pkg.go.dev/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](https://pkg.go.dev/encoding/json)).

```go
// 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.

```go
// 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.

```go
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](https://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](https://go.dev/blog/jsonv2-exp)).

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 the `Marshal`/`Unmarshal` error (`%w`, `errors.Is`/`As`, message strings).
- `go-slices-and-maps` — the nil-vs-empty slice distinction behind §8's `null` vs `[]`.
- `go-strings-bytes-runes` — `[]byte` JSON payloads (base64) and `string`↔`[]byte` cost.
- `go-time` — the zero `time.Time` that §3's `omitzero` finally drops.
- `go-version-feature-map` — version floors: `omitzero` (1.24), `encoding/json/v2` experiment.

---

## 11. Reference Files

High-frequency encoding/json anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

