Find bug
1. How to use this file¶
Fourteen buggy snippets of encoding/json use in Go: structs serialized over HTTP, decoders draining request bodies, marshallers crossing the JS/Go boundary, custom MarshalJSON and UnmarshalJSON implementations, time formats, byte slices, conflicting tags. Read each in 30-60 seconds, decide where the defect is, then expand <details> for the answer.
encoding/json bugs almost never crash on the happy path. They silently drop a field because reflection skipped it, they round a 64-bit integer because JavaScript's number type is float64, they leave a connection un-reusable because the decoder didn't drain trailing whitespace, they call the value method instead of the pointer method and ship default formatting. Three questions to ask every snippet:
- What does
reflect.TypeOf(v)see when the encoder/decoder walks this struct — exported fields only, with which tags? - Does the Go type carry more precision or different semantics than the JSON wire format can represent losslessly?
- What happens to bytes left on the stream after
Decodereturns — and to bytes never written because of a silent skip?
If a snippet can't answer all three, there's a bug. The references below point at the standard library at src/encoding/json/encode.go and src/encoding/json/decode.go for the canonical implementation behaviour.
Bug 1 — Lowercase struct fields silently skipped by Marshal¶
package main
import (
"encoding/json"
"fmt"
)
type User struct {
id int64 // BUG: unexported
name string // BUG: unexported
Email string
}
func main() {
u := User{id: 7, name: "ada", Email: "ada@example.com"}
b, _ := json.Marshal(u)
fmt.Println(string(b)) // {"Email":"ada@example.com"}
}
Answer
**Bug:** `id` and `name` start with lowercase letters and are therefore *unexported*. `encoding/json`'s reflection walk in `encode.go` (`typeFields`) only considers fields where `f.PkgPath == ""` — the standard reflective test for "exported". Unexported fields are skipped silently; the marshaller emits only `Email`. **Why subtle:** No error returned. No build warning. The output looks like a partial record, and the next service in the chain happily accepts the truncated payload. The producer "wrote it correctly" — Go just refused to read its own private fields. **Reference:** `src/encoding/json/encode.go`, `typeFields(t reflect.Type)` filters via `sf.PkgPath != "" && !sf.Anonymous` — exactly the unexported test. Comments in that function explicitly call out the rule. **Fix:** Export the fields and rename via tag if the wire format needs lowercase: **Why common:** "It's a private struct, lowercase is more idiomatic." True for in-process types — fatal once the struct crosses a `Marshal` boundary. The compiler can't help because there's nothing syntactically wrong; only the runtime behaviour is silently wrong.Bug 2 — omitempty on a struct field never omits¶
package main
import (
"encoding/json"
"fmt"
"time"
)
type Range struct {
Start time.Time
End time.Time
}
type Query struct {
UserID int `json:"user_id"`
Window Range `json:"window,omitempty"` // BUG: zero struct is never "empty"
}
func main() {
q := Query{UserID: 1} // Window left as zero value
b, _ := json.Marshal(q)
fmt.Println(string(b))
// {"user_id":1,"window":{"Start":"0001-01-01T00:00:00Z","End":"0001-01-01T00:00:00Z"}}
}
Answer
**Bug:** `omitempty` does not consult a struct's "zero-ness" — it only treats the false-zero of the *built-in* `isEmptyValue` predicate as empty. That predicate (in `encode.go`) returns true for `false`, `0`, `nil` interface/pointer/map/slice, and empty string. A zero struct is *not* in that list. The `Window` field is rendered in full, including the year-1 timestamps that downstream consumers misinterpret as "epoch 0001". **Why subtle:** `omitempty` *looks* applied. Tests with non-zero `Window` pass. Tests that omit `Window` produce a payload with a phantom zero range, and consumers happily accept it. **Reference:** `src/encoding/json/encode.go`, `isEmptyValue(v reflect.Value)` — the canonical list of "empty" kinds. Struct is conspicuously absent. The Go 1.24 `omitzero` tag finally fixes this. **Fix:** Use a pointer (`*Range`), or upgrade to Go 1.24+ and switch to `json:"window,omitzero"`: **Why common:** Reading `omitempty` as "omit when zero" matches every other language's intuition. Go's stdlib chose a narrower definition and didn't fix it for fifteen years. Pointer-or-`omitzero` is the only safe path.Bug 3 — int64 from a JS client silently rounded through float64¶
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Txn struct {
ID int64 `json:"id"`
Amount int64 `json:"amount"`
}
func main() {
// Comes from a JavaScript client where Number is IEEE-754 double.
raw := `{"id": 9007199254740993, "amount": 100}` // 2^53 + 1
var t Txn
_ = json.NewDecoder(strings.NewReader(raw)).Decode(&t)
fmt.Println(t.ID) // 9007199254740992 — rounded down
fmt.Println(t.Amount) // 100
}
Answer
**Bug:** The JSON spec doesn't distinguish integer from float; JavaScript's `Number` is IEEE-754 binary64 — exact only up to 2^53. The Go decoder reads the number token as a string, then `strconv.ParseInt`s into the `int64` field — but the *producer* already lost precision because it serialized through `float64`. `9007199254740993` round-trips to `9007199254740992` *on the JS side* before it ever touches Go. The Go-side bug isn't the rounding (which happens upstream) — it's accepting `int64` in the schema when the wire is JS, with no defence and no diagnostic. Real-world Twitter snowflake IDs hit this in 2009. **Why subtle:** Numerically near 2^53, off-by-one. Below 2^53, exact. The test suite — and the producer's smoke check — usually picks small numbers. Production sees the bug six months in with one customer's ID. **Reference:** `src/encoding/json/decode.go`, `(*decodeState).literalStore` for number tokens — and the package-level docs that warn `int64` values may not survive a JS round-trip and recommend `json.Number` or `string` tags. See also issue #34472 and the long-standing "JSON-RPC over the wire" lore. **Fix:** Quote the integer as a string on both sides (`json:"id,string"` on Go, `BigInt.toString()` on JS), or use `json.Number` and never let `float64` touch the value: **Why common:** JSON looks like it has integers. JavaScript doesn't. The schema is silently wrong everywhere it crosses that boundary.Bug 4 — Unmarshal into map[string]int with mixed value types¶
package main
import (
"encoding/json"
"fmt"
)
func main() {
raw := []byte(`{"a": 1, "b": "two", "c": 3}`)
var m map[string]int
if err := json.Unmarshal(raw, &m); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(m) // never reached
}
Answer
**Bug:** The decoder enforces the *value type* of the map. When it hits `"b": "two"`, it tries to fit a JSON string into an `int` slot and fails — `json: cannot unmarshal string into Go value of type int`. The map is left in whatever partial state the decoder reached before the error (impl-defined; treat as garbage). The caller often reads `m` anyway in the `err != nil` branch and gets confusing data. **Why subtle:** The error message names the field type, not the *key*. `"cannot unmarshal string into Go value of type int"` doesn't tell you which key. Production logs show the error with no context; the offending key is left as an exercise. Worse: callers debug by rerunning with a different payload and never see the same key fail twice in a row. **Reference:** `src/encoding/json/decode.go`, `(*decodeState).literalStore` — the type check that produces `*UnmarshalTypeError` carries the JSON value and Go type, but not the field path. The `UnmarshalTypeError.Field` is populated only for *struct* paths, not map keys. **Fix:** Two paths. If the schema is truly heterogeneous, decode into `map[string]any` (or `map[string]json.RawMessage`) and inspect per key. If the schema is wrong, reject upstream: **Why common:** "Map of ints" reads the schema's *intent*, not its reality. Real producers sometimes write `"two"` because somebody forgot to coerce. Defensive decoding wraps each value with the key for the error message.Bug 5 — Decoder iteration reads partial document then errors mid-stream¶
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Event struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
// A stream of JSON values (newline-delimited), one corrupt in the middle.
body := strings.NewReader(`{"id":1,"name":"a"}
{"id":2,"name":"b"}
{"id":3,"name":}
{"id":4,"name":"d"}`)
dec := json.NewDecoder(body)
for {
var e Event
if err := dec.Decode(&e); err != nil {
fmt.Println("done:", err)
return // BUG: stops at #3, never sees #4
}
fmt.Println(e)
}
}
Answer
**Bug:** `Decoder.Decode` is *not* resumable on syntax errors. Once it returns a non-`io.EOF` error, the underlying buffer is in an undefined state — its read offset has advanced past some tokens but not others. Returning from the loop means event #4 is silently lost. Continuing the loop without recovery means the next `Decode` returns the same or another nonsense error. **Why subtle:** With a well-formed stream the pattern is correct. The break-on-error reflex is right for terminal errors; wrong for per-record errors. Real streams contain bad records (truncated lines, half-flushed writes, attacker-crafted noise) and the loop must skip past them. **Reference:** `src/encoding/json/stream.go`, `(*Decoder).Decode` — once `dec.err` is set by `readValue` or `unmarshal`, no recovery path exists. The buffer offset (`d.scanp`) isn't advanced to the next value boundary. **Fix:** Frame the records yourself — use `bufio.Scanner` with a line splitter (or length-prefixed framing), then `Unmarshal` each record independently. Each bad record gives one error, and the next record decodes cleanly: For a single large JSON value (object/array), `Decoder` is fine. For a *stream of values*, treat framing as a separate concern; `Decoder.Decode` is not a parser-with-error-recovery. **Why common:** The JSON streaming API looks designed for this — same loop shape as `bufio.Scanner`. The contract is just different: NDJSON is line-framed, the `Decoder` is value-framed and unrecoverable.Bug 6 — Marshal of a cyclic struct overflows the goroutine stack¶
package main
import (
"encoding/json"
"fmt"
)
type Node struct {
Name string
Next *Node `json:"next,omitempty"`
}
func main() {
a := &Node{Name: "a"}
b := &Node{Name: "b"}
a.Next = b
b.Next = a // cycle
out, _ := json.Marshal(a) // BUG: recursive descent never terminates
fmt.Println(string(out))
}
Answer
**Bug:** `encoding/json` has no cycle detection on the encode path. The reflection-based encoder in `encode.go` walks `Next` indefinitely; the recursion blows the goroutine stack. Until Go 1.21 the failure mode was `runtime: goroutine stack exceeds 1000000000-byte limit`; since 1.21 the encoder returns `json: unsupported value: encountered a cycle via *Node`, but only if the encoder happens to hit the depth guard before stack exhaustion. **Why subtle:** Cycles aren't part of the type system. Linked lists, doubly-linked trees, parent-pointer DAGs, plugin graphs — all "trees" that can close into cycles under adversarial input or hot-reload. The encoder doesn't crash on the happy path; it crashes on the data structure that *used* to be acyclic. **Reference:** `src/encoding/json/encode.go`, `(*encodeState).reflectValue` and the per-type encoder dispatch — no `seen` set is maintained. Go 1.21's PR #57804 added the cycle check in `ptrEncoder.encode` (a depth limit, not a true visited set). **Fix:** Three strategies. (1) Break the cycle structurally — store IDs and a flat list rather than pointers. (2) Implement a custom `MarshalJSON` that maintains a `seen map[*Node]bool` and emits a reference token. (3) Define a wire-shape DTO with no cycles and copy: **Why common:** Cycles in Go structs are easy to build (two `Add` calls) and have no compile-time signal. The JSON encoder's depth-limit is a 1.21 patch over a 15-year-old design hole; the only structural fix is to not pass cyclic shapes to it.Bug 7 — json.RawMessage field declared by value loses bytes on copy¶
package main
import (
"encoding/json"
"fmt"
)
type Envelope struct {
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"` // BUG: not *json.RawMessage on the marshal side
}
func wrap(kind string, body any) (Envelope, error) {
raw, err := json.Marshal(body)
if err != nil {
return Envelope{}, err
}
e := Envelope{Kind: kind, Payload: raw}
return e, nil
}
func main() {
e, _ := wrap("ping", map[string]int{"seq": 1})
cp := e // copy
cp.Payload = append(cp.Payload[:0], cp.Payload...) // shrink-then-grow on copy
b, _ := json.Marshal(e)
fmt.Println(string(b)) // payload looks fine in this caller — but watch e's backing array
}
Answer
**Bug:** `json.RawMessage` is `type RawMessage []byte`. Declaring the field as `RawMessage` (value) rather than `*RawMessage` (pointer) means every assignment copies the *slice header* — three words — but shares the backing array. Two innocent-looking operations break it: (1) appending to `cp.Payload` may write into `e.Payload`'s array if capacity allows; (2) on the *unmarshal* side, the decoder's `(*RawMessage).UnmarshalJSON` mutates the receiver's underlying storage, which is *only* called when the field is addressable — passing a value receiver to a function that needs a pointer to update silently no-ops. **Why subtle:** The bug appears intermittently because `append` reallocates above capacity and writes-in-place below. Tests with small payloads pass (`cap` happens to equal `len`); production with larger ones corrupt the original. The unmarshal variant is even quieter — the field receives `null` because the pointer-receiver method needed addressability and didn't get it. **Reference:** `src/encoding/json/stream.go`, `(*RawMessage).UnmarshalJSON` — `*m = append((*m)[0:0], data...)`. The method explicitly mutates `*m`, which requires an addressable receiver. `src/encoding/json/encode.go`, `encodeByteSlice` covers the marshal path. **Fix:** Use `*json.RawMessage` for fields you both marshal *and* unmarshal, and document the lifetime of the byte slice: If you keep value semantics, never `append` into a `RawMessage` you also store, and always copy with `bytes.Clone` (Go 1.20+) before storing: **Why common:** `RawMessage` looks like an opaque token. It's a slice header. Slice headers share storage; "I copied the struct" is not "I copied the bytes".Bug 8 — MarshalJSON on pointer receiver but value passed¶
package main
import (
"encoding/json"
"fmt"
)
type Money struct {
Cents int64
}
func (m *Money) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%d.%02d"`, m.Cents/100, m.Cents%100)), nil
}
type Invoice struct {
ID string
Total Money // BUG: value field; *Money MarshalJSON method not invoked
}
func main() {
inv := Invoice{ID: "INV-1", Total: Money{Cents: 1299}}
b, _ := json.Marshal(inv)
fmt.Println(string(b))
// {"ID":"INV-1","Total":{"Cents":1299}} — default encoding, custom method skipped
}
Answer
**Bug:** `MarshalJSON` is declared on `*Money` (pointer receiver). When the encoder reflects on `Invoice.Total`, it sees a `Money` value, not a `*Money`. The method set of `Money` (value type) does *not* include `MarshalJSON`; only the method set of `*Money` does. The encoder falls through to the default struct encoding and emits `{"Cents":1299}` instead of `"12.99"`. If the field were addressable (a top-level variable, a slice element by index) the encoder *would* take its address and find the method — but a struct field accessed inside a parent value isn't addressable through reflection in the encode path. `Marshal(inv)` is value-passed; the struct copy has no `*Money` to dispatch on. **Why subtle:** `Marshal(&inv)` would work — the encoder dereferences the top-level pointer, the struct becomes addressable, and the field's `*Money` method is found. So the same code works for one call site and fails for another. Tests are usually written one way. **Reference:** `src/encoding/json/encode.go`, `newTypeEncoder` calls `t.Implements(marshalerType)` *and* `reflect.PointerTo(t).Implements(marshalerType)` — but the latter only matters if the encoder later sees an addressable value. The decision tree is in `condAddrEncoder`. **Fix:** Put the method on the value receiver, or always pass pointers down the chain: Value receiver is the safe default for `MarshalJSON`. Use pointer receiver only if the method genuinely needs to lazily compute or memoize. **Why common:** Go's pointer-vs-value method dispatch is the single most error-prone interface mechanism in the language. `json.Marshaler` is the most likely interface to be silently *not* satisfied.Bug 9 — Custom time format wanted, default time.Time shipped¶
package main
import (
"encoding/json"
"fmt"
"time"
)
// Spec: dates as "YYYY-MM-DD", no time, no zone.
type Booking struct {
Guest string `json:"guest"`
Date time.Time `json:"date"` // BUG: encodes as RFC3339, not "2026-05-28"
}
func main() {
t, _ := time.Parse("2006-01-02", "2026-05-28")
b, _ := json.Marshal(Booking{Guest: "ada", Date: t})
fmt.Println(string(b))
// {"guest":"ada","date":"2026-05-28T00:00:00Z"}
}
Answer
**Bug:** `time.Time` implements `MarshalJSON` to emit RFC 3339 with nanosecond precision. There is no way to configure that on the type — it's a method, not a tagged option. The wire shape ships `"2026-05-28T00:00:00Z"`; the consumer expecting `"2026-05-28"` either crashes its date parser or stores the wrong value. **Why subtle:** The encoded string *parses* back as a date in most consumers' tolerant parsers, so the round-trip "works". Schema validators flag it. Date-only consumers (banking, calendars, legal contracts) need stable lexical equality and the suffix breaks `==`. **Reference:** `src/time/time.go`, `(Time).MarshalJSON` — hard-coded to `time.RFC3339Nano`. `src/encoding/json/encode.go` dispatches through `marshalerEncoder` because `time.Time` implements `json.Marshaler`; the JSON package never sees the date as a struct to apply a format tag to. **Fix:** Define a domain type that wraps `time.Time` with the format you actually want:type Date time.Time
func (d Date) MarshalJSON() ([]byte, error) {
return []byte(time.Time(d).Format(`"2006-01-02"`)), nil
}
func (d *Date) UnmarshalJSON(b []byte) error {
t, err := time.Parse(`"2006-01-02"`, string(b))
if err != nil { return err }
*d = Date(t)
return nil
}
type Booking struct {
Guest string `json:"guest"`
Date Date `json:"date"`
}
Bug 10 — json.Decoder leaves trailing data; connection un-reusable¶
package main
import (
"encoding/json"
"net/http"
)
type Req struct {
ID int `json:"id"`
}
func handler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req Req
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// BUG: Decode read one value. Trailing whitespace/garbage stays in r.Body.
// HTTP/1.1 keep-alive needs r.Body fully drained before the next request
// on this connection. Server will close the conn instead of reusing it.
w.WriteHeader(http.StatusOK)
}
Answer
**Bug:** `Decoder.Decode` reads exactly *one* JSON value and stops at the value's end token. Trailing whitespace, newlines, or trailing junk (sometimes injected by misbehaved clients) remains in `r.Body`. The `net/http` server requires the request body to be fully consumed before it can reuse the TCP connection for the next request — when it isn't, the server closes the connection. Throughput drops from "keep-alive 1000 req/s" to "open a new TCP connection per request, ~50 req/s with TLS". **Why subtle:** Functionally everything works. The latency regression is per-request, hidden by the LB, surfaces as p99 spikes and CPU on the TLS handshake. APM tools rarely point at the JSON decoder. **Reference:** `src/encoding/json/stream.go`, `(*Decoder).Decode` — `d.scanp` advances only to the end of the decoded value. The HTTP keep-alive requirement is documented in `src/net/http/request.go` and enforced in `(*conn).serve`. **Fix:** After `Decode`, drain the body (`io.Copy(io.Discard, r.Body)`), or impose a body size limit and call `Decoder.More()`/`Decoder.Token()` to assert "no further value" and consume to EOF:defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // bound first
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil { /* 400 */ return }
// reject extra JSON values, drain trailing bytes
if dec.More() {
http.Error(w, "trailing data", http.StatusBadRequest)
return
}
io.Copy(io.Discard, r.Body)
Bug 11 — defer resp.Body.Close() forgotten after Decode¶
package main
import (
"encoding/json"
"net/http"
)
type Profile struct {
ID int `json:"id"`
Name string `json:"name"`
}
func fetch(url string) (*Profile, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
// BUG: no defer resp.Body.Close()
var p Profile
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, err
}
return &p, nil
}
Answer
**Bug:** The HTTP response body is an `io.ReadCloser`. The decoder reads from it but never closes it. Each `http.Get` keeps the underlying TCP connection (or the read side of the persistent connection) held open until the GC eventually reaches the `*http.Response` and finalizes — minutes later, or never under steady allocation pressure. The connection pool fills up; subsequent calls block on `dialerTimeout` or `MaxIdleConnsPerHost`. Production "everything went slow" with no obvious cause. Worse: even on the *error* path from `Decode`, the body must be closed. The current code returns `nil, err` and leaves the body open. **Why subtle:** Single-request tests pass. Burst tests pass because the pool starts empty. Sustained traffic exhausts the pool; `net/http.Transport` log messages mention `unused connection lost` and `idle conn`-something, not "you forgot Close". **Reference:** `src/net/http/response.go` — `Response.Body` doc explicitly: *"It is the caller's responsibility to close Body."* Failure to close affects connection reuse, as documented in `(*Transport).RoundTrip`. **Fix:** `defer` immediately after the error check, *and* drain on the success path so the connection can be reused: **Why common:** `defer Close()` is the textbook pattern; "forgot to type it" is the textbook mistake. `errcheck` and `bodyclose` linters catch this exact bug.Bug 12 — int field receives a value larger than 32 bits on a 32-bit build¶
package main
import (
"encoding/json"
"fmt"
)
type Counter struct {
Hits int `json:"hits"` // BUG: int is 32-bit on GOARCH=386, GOARCH=arm
}
func main() {
// On a 32-bit build, int is int32: max 2,147,483,647.
raw := []byte(`{"hits": 5000000000}`) // 5 billion
var c Counter
if err := json.Unmarshal(raw, &c); err != nil {
fmt.Println("error:", err)
// On 32-bit: json: cannot unmarshal number 5000000000 into Go struct field Counter.hits of type int
return
}
fmt.Println(c.Hits)
}
Answer
**Bug:** Go's `int` is platform-defined: 32 bits on `386`/`arm`/`wasm` and 64 bits on `amd64`/`arm64`. The same JSON payload that decodes cleanly on a developer's `amd64` laptop returns `cannot unmarshal number ... into ... int` on a 32-bit deploy target (embedded device, old container base image, certain WASM runtimes). Across the boundary the schema is unstable. **Why subtle:** The bug is per-platform. CI on `amd64` passes; the 32-bit IoT firmware or 32-bit ARM container fails in the field. The error message names `int` without revealing the platform width. **Reference:** `src/encoding/json/decode.go`, `(*decodeState).literalStore` for `reflect.Int` — calls `strconv.ParseInt(s, 10, 64)` then checks `v.OverflowInt(n)`. The overflow check uses the target's bit width, which is platform-dependent for `reflect.Int`. **Fix:** Use explicit-width types on wire-facing structs: For payloads that exceed `int64`, use `json.Number` and route through `big.Int`. Never use bare `int` (or `uint`) in a struct that round-trips JSON; it's a portability hazard in disguise. **Why common:** `int` is the default in Go style guides for in-process code. JSON crosses processes — and crosses architectures via cross-compilation. The defaults are right for memory but wrong for wire.Bug 13 — []byte field silently base64-encoded¶
package main
import (
"encoding/json"
"fmt"
)
type Packet struct {
Header string `json:"header"`
Data []byte `json:"data"` // BUG: not "raw bytes" — base64
}
func main() {
p := Packet{Header: "v1", Data: []byte{0x48, 0x69}} // "Hi"
b, _ := json.Marshal(p)
fmt.Println(string(b))
// {"header":"v1","data":"SGk="} — base64-encoded, not [72,105]
}
Answer
**Bug:** `encoding/json` treats `[]byte` specially: it base64-encodes the bytes (RFC 4648, standard alphabet, *with* padding) on encode and base64-decodes on decode. This is documented but rarely remembered. Consumers expecting a numeric array (`[72, 105]`) or a UTF-8 string see `"SGk="` and silently mishandle it. Worse, on the decode side, a producer that ships a JSON array of numbers into a `[]byte` field gets `cannot unmarshal array into Go struct field Packet.data of type []uint8`. **Why subtle:** The encoded form is valid JSON. The wire protocol still passes JSON Schema validation against `"type": "string"`. The mismatch with the consumer's expectation is a semantic bug, not a syntactic one. **Reference:** `src/encoding/json/encode.go`, `encodeByteSlice` — encodes via `base64.StdEncoding` directly into the output buffer. Decode side: `(*decodeState).literalStore` for `reflect.Slice` of `reflect.Uint8`, which calls `base64.StdEncoding.Decode`. **Fix:** Decide what the wire shape should be and pick the matching Go type. Three options: 1. **Base64 (the default):** keep `[]byte`, document the wire format. Use when bytes are opaque (encryption blobs, file contents). 2. **Hex string:** wrap in a domain type with `MarshalJSON`/`UnmarshalJSON` that uses `encoding/hex`. 3. **Array of integers:** use `[]int` or `[]uint8` aliased to a different element type that *doesn't* trigger the base64 shortcut: **Why common:** `[]byte` is "bytes". JSON has no byte type, so the package picked base64. The choice is reasonable and surprising; documenting it on every wire-facing struct is the cheap insurance.Bug 14 — Two struct fields produce the same JSON name; both dropped¶
package main
import (
"encoding/json"
"fmt"
)
type Mixed struct {
ID int `json:"id"`
UserID int `json:"id"` // BUG: same JSON key as ID
Name string `json:"name"`
}
func main() {
m := Mixed{ID: 1, UserID: 99, Name: "ada"}
b, _ := json.Marshal(m)
fmt.Println(string(b))
// {"name":"ada"} — both ID and UserID dropped silently
}
Answer
**Bug:** When two fields at the same struct depth tag the same JSON name, `encoding/json`'s tie-breaker (in `typeFields`) drops *both*. The output silently omits the key entirely. No error; no warning; not even a `Marshal` failure. The first time anyone notices is when the consumer reports "id field missing". Embedded structs that surface a same-named field follow a more nuanced rule: the shallowest one wins, and ties at the same depth still drop. The bug here is two fields *at the same depth* — Go's resolution is "neither, silently". **Why subtle:** Tests on either field individually look correct because the test author writes `expected` already containing the missing key. Tests that re-encode and assert byte-equal would catch it; tests that decode and compare field values catch it. Tests that just check "no error" miss it entirely. **Reference:** `src/encoding/json/encode.go`, `typeFields` and `dominantField` — the latter's docstring: *"If there are multiple top-level fields, the boolean will be false: This condition is an error in Go and we skip all the fields."*. The decode side respects the same rule via `field` lookups. **Fix:** Rename one tag, or use a wrapper type for the embedded case: For the embedded-struct variant of this bug, lift the conflicting field into the outer struct with an explicit tag, or add `json:"-"` to the embedded duplicate. **Why common:** Tag conflicts arise from refactors — someone renames `User_ID` to `UserID` and forgets the tag, leaving two fields tagged `"id"`. Static linters (`govet`'s `composites` is unrelated; `nilness`, `structtag` partially) don't catch JSON name collisions at the depth-resolution level. Code review on tag changes is the only reliable defence.Summary¶
These bugs cluster into four families.
Reflection visibility (1, 8, 14): unexported fields skipped silently, pointer-receiver MarshalJSON not invoked on a value field, two fields with the same JSON name both dropped. encoding/json is a reflective contract; it only sees what reflection exposes, and the visibility rules diverge from the language's normal scoping rules in ways that compile cleanly but ship wrong bytes.
Numeric and wire semantics (3, 9, 12, 13): int64 rounded through JS float64, time.Time forced to RFC 3339, int size dependent on architecture, []byte silently base64-encoded. JSON's value model is poorer than Go's; every place the type system says "this fits" but the wire format says "this is a string", there's a hidden conversion and a hidden bug.
Lifecycle and emptiness (2, 10, 11): omitempty not omitting a zero struct, Decoder not draining trailing bytes, response body not closed. encoding/json doesn't manage its own I/O lifecycle — the caller does — and three small omissions degrade keep-alive throughput, exhaust connection pools, or emit phantom default values that consumers misinterpret.
Error and structural integrity (4, 5, 6, 7): mixed-type map fails mid-decode, NDJSON stream un-resumable after a syntax error, cycles overflow the stack, RawMessage value semantics lose data on copy. Each one is a place where the package's design favours simplicity over robustness, and production payloads — heterogeneous, partially corrupt, cyclic, or copied — pay the cost.
Review checklist for any encoding/json use:
- Are all wire-facing struct fields exported, with explicit
json:tags, and do two fields never share the same tag name at the same depth? - Do integer fields use explicit widths (
int64,int32) — never bareint— and is thestringtag applied when the wire crosses a JavaScript boundary? - Is
omitemptyonly applied to types whose zero is in the built-in empty set (bool/number/string/nil-pointer/nil-slice/nil-map), or upgraded toomitzerofor struct/array fields on Go 1.24+? - Do
time.Timefields go through a domain wrapper type with the exact format the wire requires, rather than relying ontime.Time's default RFC 3339? - Do
[]bytefields document base64 encoding on the wire — or use a different element type to avoid the shortcut? - Are
MarshalJSON/UnmarshalJSONmethods declared on the value receiver (or is the field always addressable when encoded)? - After
json.NewDecoder(resp.Body).Decode(...), does the codeio.Copy(io.Discard, resp.Body)thenresp.Body.Close()— even on the error path — and applyhttp.MaxBytesReaderupstream? - Are NDJSON streams framed by
bufio.Scanner(per-lineUnmarshal) rather than relying onDecoder.Decodeto skip bad records? - Are recursive types validated for acyclicity before
Marshal, or marshalled through a DTO that flattens to IDs? - Are
json.RawMessagefields declared as*json.RawMessagewhen they're decoded into, and are byte slices copied withbytes.Clonebefore being stored? - Do unmarshal callers wrap each per-key decode of
map[string]json.RawMessagewith the key in the error message, rather than relying onUnmarshalTypeErrorwhich has no key context?