Which Go types does json.Marshal encode as something other than their obvious JSON counterpart?
answer
- one slice type gets special treatment
- empty is not always the same as empty
- object keys have only one type available
- a character type is really a number
- base64, null, and quoted numeric keys
basics
~10 sA []byte becomes a base64 string, not an array of numbers. A nil slice or map becomes null while an empty non-nil one becomes [] or {}. Integer map keys become quoted object keys.
solid answer
~50 sMost of the mapping is unsurprising — structs become objects, slices become arrays, numbers become numbers — but four cases catch people out. First, `[]byte` is special-cased and encodes as a base64 string; a fixed-size `[3]byte` array is not special-cased and encodes as `[1,2,3]`. Second, nilness is visible on the wire: a nil slice or nil map encodes as `null`, while an empty non-nil one encodes as `[]` or `{}`, which is why a handler that returns a freshly declared `var items []Item` sends `null` to clients expecting an empty list. Third, a map key that is an integer is allowed and is written as a quoted string, because JSON object keys are always strings. Fourth, `rune` is an alias for `int32`, so a rune field encodes as a number rather than a one-character string, and `time.Time` encodes as an RFC 3339 string rather than an object.
code
go · 14 linestype Blob struct {
Data []byte
Fixed [3]byte
Tags []string
Meta map[int]string
}
b, _ := json.Marshal(Blob{
Data: []byte("hi"),
Fixed: [3]byte{1, 2, 3},
Meta: map[int]string{7: "x"},
})
fmt.Println(string(b))
// {"Data":"aGk=","Fixed":[1,2,3],"Tags":null,"Meta":{"7":"x"}}go deeper
Remember that most types map the obvious way, and memorise the two that do not: a byte slice becomes a base64 string, and a nil slice becomes null rather than an empty array.
Explain why each special case exists — opaque binary for base64, JSON's string-only object keys for quoted integer keys — and show the nil-versus-empty slice difference in code.
Talk about the wire shape as a contract that outlives the Go type behind it, and about the test you would write so a field's encoded form cannot change unnoticed.
Own the question of where the encoded shape is defined at all: whether teams marshal domain types directly or keep a separate wire type, and what that costs when a field's Go type has to change.
## The default mapping When you hand `json.Marshal` a value, it walks it with reflection and applies a mapping that is mostly what you would guess: - struct → object, fields in declaration order, using the Go field name unless a tag says otherwise - map → object - slice, array → array - string → string - all integer and floating-point kinds → number - bool → `true` / `false` - pointer, interface → whatever it points at, or `null` when nil The interesting part is the handful of places where the guess is wrong. ## Byte slices become base64 `[]byte` is deliberately special-cased: it encodes as a base64 string using the standard alphabet, not as an array of small numbers. The reasoning is that a byte slice usually holds opaque binary — a hash, a blob, a ciphertext — and an array of a few thousand numbers would be both huge and useless to a consumer. Decoding is symmetric: a base64 string decodes back into a `[]byte`. The special case is on the slice, not on the element type. A fixed-size array of bytes has a different Go type and takes the generic array path: ```go type Blob struct { Data []byte Fixed [3]byte } // {"Data":"aGk=","Fixed":[1,2,3]} ``` That asymmetry surprises people who assume the rule is about bytes rather than about the `[]byte` type. ## Nil is visible on the wire Go has two empty slices: a nil one (`var s []T`) and an empty non-nil one (`s := []T{}`). They behave identically in almost all Go code — both have length zero, both can be appended to, both can be ranged over — but they encode differently. A nil slice writes `null`; an empty non-nil slice writes `[]`. The same holds for maps: nil writes `null`, empty writes `{}`. This is the single most common real-world bite in this area. A list endpoint that builds its result with `var items []Item` and never appends anything sends `{"items":null}`, and a client that iterates the field without a null check breaks. The fix is to initialise the slice — `items := []Item{}` — so that "no results" is an empty array rather than an absent value. ## Map keys are always strings JSON object keys are strings by definition, so a Go map key has to be turned into one. Marshal accepts a string-kind key directly, and it accepts any integer-kind key by formatting it and quoting it: `map[int]string{7: "x"}` becomes `{"7":"x"}`. Note that this is lossy in one direction only in the sense that the key comes back as a string on any consumer that is not Go; decoding back into `map[int]string` works because the decoder parses the quoted key. A map key type that is neither of those and carries no textual representation is rejected outright rather than guessed at. Map keys are also sorted when marshaled, which makes output deterministic — handy for golden-file tests, and a reason a decode-then-encode round trip through a map does not reproduce the original key order. ## Aliases hide their identity `rune` is an alias for `int32` and `byte` is an alias for `uint8`. The encoder sees only the underlying kind, so a `rune` field carrying `'A'` encodes as `65`, not as `"A"`. If you want a character on the wire, use a `string`. Similarly, a named type whose underlying type is a string still encodes as a string, and a named integer type used as an enum encodes as its number — the identifier name never reaches the document. Anything that appears on the wire in a different shape from its underlying kind, such as `time.Time` encoding as an RFC 3339 string, does so because that type supplies its own encoding rather than because the default mapping knows about it. ## Why this matters more than it looks Every item above is a place where the Go type and the wire type disagree, and the wire type is what other teams and other languages actually consume. A field's Go type is an internal choice you can change; the shape it produces on the wire is a contract. Reading the mapping once, and writing a test that asserts the exact bytes for one representative struct, is far cheaper than discovering the base64 or the `null` from a consumer's bug report.
- A list endpoint returns `{"items":null}` when there are no results. What in the Go code causes that, and how do you fix it?The slice was declared with `var items []Item` and never appended to, so it is nil, and a nil slice encodes as `null`. Initialise it instead — `items := []Item{}` — which is an empty non-nil slice and encodes as `[]`. Both behave the same inside Go, so the difference only shows up on the wire, which is exactly why it survives code review.
- Why does a time.Time field encode as an RFC 3339 string rather than as an object of its fields?The default mapping would produce an object from its fields, but the type supplies its own JSON encoding, and the encoder prefers that over the reflection default. The result is a quoted RFC 3339 timestamp such as `"2026-09-01T10:30:00Z"`. The general rule to take away is that a type can override the default mapping entirely, so you cannot infer a field's wire shape from its Go kind alone.
- In what order does json.Marshal write struct fields and map keys?Struct fields are written in declaration order, top to bottom, including promoted fields from embedded structs. Map keys are sorted, so map output is deterministic across runs even though Go map iteration is randomised. That determinism is what makes golden-file tests over marshaled maps stable.
saying these in an interview costs you the question
- Expects a []byte to encode as an array of numbers
- Treats a nil slice and an empty slice as identical on the wire
- Thinks an integer-keyed map cannot be marshaled
- Assumes a rune field encodes as a one-character string
- Believes map keys are emitted in insertion order