Last Updated: May 22, 2026
The encoding/json package converts Go values to JSON bytes and back. json.Marshal and json.Unmarshal work with structs, but the same functions also encode primitives, slices, maps, pointers, and interfaces, and the rules for each type are worth understanding before tags and streaming get in the way. This chapter focuses on the encoding rules themselves: what each Go type becomes in JSON, what each JSON value becomes back in Go, and the error types you'll see when something doesn't line up.
The two functions you'll use 90% of the time are json.Marshal and json.Unmarshal. Marshal takes any Go value and returns []byte plus an error. Unmarshal takes a []byte of JSON and a pointer to a destination, then fills the destination in place. json.MarshalIndent is the same as Marshal but adds line breaks and indentation for readability. These functions work with struct types and the json:"key" tag; the rest of this chapter pushes deeper into the encoding rules for every other type the package can handle.
The compact form is what you send over the wire. The indented form is what you read in a terminal. Both are valid JSON, and any parser will produce the same result from either.
Cost: MarshalIndent walks the bytes a second time to insert whitespace, so it's measurably slower than Marshal and produces larger output. Use it for logs and debugging, not for hot-path serialization.
The high-level shape of what encoding/json is doing in both directions is worth seeing in one picture.
Marshal reads from a value and produces bytes. Unmarshal reads bytes and writes through a pointer into a destination. Both sides use the same rules for how Go types map to JSON types, and those rules are the meat of this lesson.
Every basic Go type has a defined JSON representation. The mapping is mechanical and the same rules apply when these types appear as struct fields, slice elements, or map values.
| Go type | JSON form | Notes |
|---|---|---|
bool | true / false | Direct |
int, int8 through int64 | number | Whole number, no quotes |
uint, uint8 through uint64 | number | Unsigned, but JSON has no separate unsigned type |
float32, float64 | number | NaN and +/-Inf cause errors |
string | string (quoted) | UTF-8 expected, certain bytes are escaped |
nil (pointer, interface, slice, map) | null | Same JSON value for all four cases |
Booleans are the simplest. true and false go straight through with no quoting.
Numbers are next. Go has multiple integer widths and two floating-point widths, but JSON has only one number type. The encoder writes the value in decimal without quotes, and the decoder is responsible for fitting the result into whatever Go type the destination has. Float values get the shortest decimal form that round-trips back to the same float, which is why 1.5 comes out as 1.5 instead of 1.50000000000.
One sharp edge: NaN, +Inf, and -Inf are not valid JSON numbers, and the encoder returns an *json.UnsupportedValueError if you try to marshal them. JSON simply has no syntax for those values. Code that does math (averages, ratios) needs to either check for these cases before marshaling or use a custom encoding (a string like "NaN", or null, or omit the field).
Strings go through with quoting and escaping. The encoder escapes the characters that JSON requires (", \, control characters below 0x20) and a few that have special meaning in HTML (<, >, &) by default. The HTML escaping is on by default for safety when embedding JSON in HTML; it can be turned off with a json.Encoder.
The < and > in <script> come out as < and >, which are the same characters in Unicode escape form. The accented é in café goes through unchanged because UTF-8 is valid inside a JSON string. The escape form for < and > makes the output safe to drop into HTML without an XSS hole, at the cost of slightly larger payloads.
Cost: HTML escaping turns three characters into six bytes each. For an API that doesn't render output as HTML, the size overhead is wasted. json.Encoder.SetEscapeHTML(false) disables it.
The nil interface, a nil pointer, a nil slice, and a nil map all marshal to the literal null. This is the same JSON value for four distinct Go states, which is convenient for the writer and inconvenient for the reader who has to figure out which Go type to put back.
Slices become JSON arrays and maps become JSON objects. The rules are uniform once you know how nil and empty cases behave.
For slices, a nil slice marshals as null, while an empty slice marshals as []. Both have len(s) == 0, but they are not the same JSON value. This is the same trap as the nil vs empty slice distinction, and it surfaces here because consumers of your JSON usually care about the difference.
If the receiving system expects an array, null will break iteration code in many languages. The fix is to initialize the slice with a literal: var cart = []string{}, or cart := make([]string, 0). Both produce a non-nil empty slice that marshals to [].
A common pattern in HTTP handlers is to allocate the response slice explicitly so that an empty result still returns []:
Both runs return a valid JSON array. A consumer can for item in response.body without first checking for null. That's worth one extra [] on the declaration.
[]byte is a special case. The encoder treats it not as an array of small numbers but as a chunk of binary data, and it writes the slice as a base64-encoded JSON string. This is the behavior you want when shipping images, encrypted payloads, or anything that isn't human-readable text, and it surprises people who expected a numeric array.
SGVsbG8= is the base64 form of Hello. The decoder reverses the encoding on the way back, so a []byte field round-trips correctly without any extra work. If you want a numeric array of bytes for some reason, use []int8 or []uint8 with a named type and a custom marshaler.
Maps work similarly to slices but with one extra rule: the keys have to be strings, or types that JSON can represent as a string (integer types, types implementing encoding.TextMarshaler). A map[int]string is allowed; a map[Product]string is not.
The keys are sorted alphabetically in the output. The encoder does this on purpose so that two equal maps always produce the same JSON, which makes diffs, hashes, and golden tests stable. Go's runtime map iteration order is randomized, so without this sort the output would change between runs.
A nil map marshals as null, same as a nil slice. An empty map marshals as {}:
Integer-keyed maps work because the encoder converts each key to its decimal string form. So map[int]string{1: "Notebook"} produces {"1":"Notebook"}, not a JSON object with a numeric key (JSON doesn't have those).
Integer keys are sorted as strings, not as numbers, which is why "10" comes before "2". This is a JSON property, not a Go quirk: JSON object keys are always strings, and lexicographic order is the only ordering that makes sense for strings. If you need numeric order, sort the keys yourself before encoding.
Cost: Marshaling a large map allocates a slice for the sorted key list plus the encoded value. For a hot loop that emits the same map shape repeatedly, consider building a struct or pre-allocating a key slice with slices.Sort. The savings show up in profiles, not in casual usage.
A pointer *T is encoded as whatever its pointed-to value would encode to, or as null if the pointer is nil. This is the same recursive rule, applied through one level of indirection.
The non-nil pointer disappears in the JSON: only the pointed-to value shows up. This is exactly what you want for optional fields. A struct with a *string field can distinguish "the producer didn't set this" (nil pointer, encodes as null) from "the producer set this to the empty string" (non-nil pointer to "", encodes as ""). Plain string can't tell those cases apart because both states are "".
For Alice, Loyalty points at an integer zero, so the JSON has "loyalty_points": 0, a meaningful "we know they have zero points". For Bob, Loyalty is nil, so the JSON has "loyalty_points": null, meaning "we don't have data on points". A plain int field can't carry that distinction. This trade-off is why API designers reach for pointers when "missing" and "zero" are different states.
Decoding is the mirror. When Unmarshal sees a JSON value for a *T field and the pointer is nil, it allocates a new T, fills it, and stores the pointer. When the JSON value is null, the pointer stays nil (or is set to nil if it had a previous value).
The first run gets a real total. The second run sees JSON null and leaves the pointer nil. The dereference *o.Total is safe in the first case and would panic in the second, which is why code that uses optional pointer fields always nil-checks before dereferencing.
When you marshal a value of interface type, the encoder uses the concrete type stored inside the interface, not the interface type itself. The any type is just the empty interface interface{}, so the same rule applies: whatever value happens to be in the interface gets encoded.
Each value is encoded according to its concrete type. The interface is just a box; the encoder looks inside the box and encodes what it finds. A nil interface (no concrete type) encodes as null.
This rule extends to interface fields on a struct. A field of type any accepts any value, and the JSON output depends on what was assigned at marshal time.
The Payload field is any, but the JSON shape depends entirely on what's inside it on each call. This flexibility is useful for event streams and generic logs, with the caveat that the consumer has to inspect each payload to know what shape it received. For payloads with a fixed set of shapes, json.RawMessage is usually a cleaner tool because it preserves the bytes verbatim instead of decoding and re-encoding through a generic interface.
Only exported fields (those starting with an uppercase letter) are visible to encoding/json. Unexported fields are silently skipped in both directions, even if they have a json tag.
cost has a perfectly valid-looking tag, but the lowercase first letter means encoding/json can't see it through reflection. The field is gone from the output, no error, no warning. go vet flags this exact case with the message struct field has json tag but is not exported, which is one of the reasons go vet is part of most Go build pipelines.
This rule applies to unmarshal too. JSON that contains a "cost" key has nowhere to land if the matching field is unexported; the value is dropped on the floor. The fix is always the same: capitalize the field name. If the field really should be hidden from JSON, drop the tag or use json:"-".
When the JSON shape isn't known in advance, you can decode into a generic destination. Unmarshal accepts a pointer to any (often written as *interface{}), and the resulting value is one of six concrete types depending on the JSON.
| JSON value | Go type after Unmarshal |
|---|---|
null | nil |
true / false | bool |
| number | float64 |
| string | string |
| array | []any |
| object | map[string]any |
There's no int, no int64, no float32. Every JSON number, integer or not, becomes float64. That's a fundamental rule of generic JSON decoding in Go, and it's the source of "why did my integer become a float" bugs.
The integer 1 became float64(1), not int(1). The array ["paper", "stationery"] became []interface{}, not []string, even though every element is a string. The nested array would need another type assertion to access its elements. This is the cost of generic decoding: every access requires a type assertion, and integer values lose precision above 2^53 because that's the limit of exact integer storage in float64.
Every JSON value type lands on one of six Go types when the destination is any.
JSON has one number type, and the decoder picks the widest standard Go floating type that can represent it, which is float64. There's no way to ask for int here without a defined target type. If you need integer precision for large IDs, define a struct with an int64 field, or use json.Decoder with UseNumber() to keep the original string form.
The same rule applies recursively. A JSON array of objects becomes []any, where each element is map[string]any, where each value is one of the six types. Walking such a structure requires a chain of type assertions:
The , ok form of each assertion is essential: bare assertions like value.([]any) panic if the underlying type isn't what you claimed. For real production code, define a struct instead. Use map[string]any for genuinely dynamic input (a generic logger, a passthrough proxy, an exploratory script), not as a shortcut to avoid writing types.
Cost: Decoding into map[string]any allocates a heap object for every value (strings, maps, slices, boxed primitives). Decoding into a struct allocates only the struct. The difference shows up as 5x-10x more allocations and proportionally more garbage collector pressure in benchmarks.
Unmarshal writes into the destination, which means the destination has to be a pointer. Passing a value (not a pointer) returns an *json.InvalidUnmarshalError. The error message is clear once you know what to look for.
The fix is &p. The reason the rule exists: Go passes arguments by value, so a function receiving a Product would only see a copy. The decoder would happily fill its local copy and the original would stay zero. A pointer gives the decoder a stable address to write through, so the changes survive the function return.
A nil pointer is also an error, with a different error wording:
The pointer is the right type, but it points at nothing. The decoder has nowhere to write. The fix is to allocate before unmarshaling: p := &Product{} or p := new(Product). Either gives the decoder a valid target.
For slices and maps, the same rule applies. The destination is a pointer to the slice or map, not the slice or map itself.
&nums is *[]int. The decoder allocates a new slice with three elements and writes the pointer back through that address. If you had passed nums directly, the local copy would be filled and the caller's nums would stay nil.
Unmarshal returns three error types you'll see in practice, plus a generic catch-all. Knowing the type lets you give better error messages and decide whether to retry, log, or reject the input.
| Error type | When it fires |
|---|---|
*json.SyntaxError | The bytes aren't valid JSON at all |
*json.UnmarshalTypeError | Valid JSON, but a value's type doesn't match the destination field |
*json.InvalidUnmarshalError | The destination isn't a non-nil pointer |
| Other errors | Number overflow, custom unmarshaler failures, etc. |
A *json.SyntaxError means the parser couldn't make sense of the bytes. Missing closing brace, unquoted key, trailing comma, anything that violates the JSON grammar.
The Offset field tells you where in the bytes the parser gave up, which is useful for logging or for pointing a user at the bad character in a config file. For a malformed payload over the network, you usually just want to log the error and return a 400 to the client.
A *json.UnmarshalTypeError means the JSON is syntactically valid but a value doesn't fit the destination. A JSON string where the field expects a number, an array where the field expects an object, that kind of thing.
The Field, Type, and Value fields tell you exactly which struct field failed, what Go type the destination has, and what JSON kind the parser found. This is precise enough to surface as a user-facing validation message in a JSON API.
A *json.InvalidUnmarshalError means the destination is wrong: it's not a pointer, it's a nil pointer, or it's of a type the decoder can't write into. The two examples in the previous section both produce this error type.
InvalidUnmarshalError is always a programmer bug, not a runtime data issue. Treat it like a compile error: fix the call site, don't try to recover at runtime. Logging and panicking is reasonable if it fires in production, because it means the code has never worked.
The fourth case is everything else: overflow when a JSON number is too big for the destination integer type, errors returned from a custom UnmarshalJSON method, errors from a TextUnmarshaler. These don't share a single type but they do share a pattern: handle them with a default branch after checking the typed ones.
The overflow case is interesting. The error message looks like a type error, but the underlying error type is actually *json.UnmarshalTypeError too in modern Go, because the decoder treats "this number doesn't fit" as a type mismatch. Older Go versions reported a different error string; the behavior is stable enough that production code can rely on the typed approach.
Cost: Calling errors.As on every error path is cheap, but a hot decode loop should validate input shape before unmarshaling rather than relying on Unmarshal to reject every bad payload. Once you're past the rejection cost, the unmarshal itself is fast.
By default, Unmarshal ignores JSON keys that don't correspond to any struct field. This is forgiving behavior chosen on purpose: a producer can add new fields to a response without breaking older clients that don't know about them. The cost is that typos ("prce" instead of "price") also get dropped, which can hide bugs.
color, weight_grams, and the typo prce are all dropped silently. Price ends up as zero because nothing matched the price tag, and the absence of a key leaves the field at its zero value. The lack of any error is on purpose; DisallowUnknownFields() on json.Decoder flips the behavior to strict so unknown keys become an error. Strict mode is the right default for config files and admin APIs, where unexpected keys probably mean a typo. The lenient default is the right default for public APIs that have to stay forward-compatible.
The same drop-on-the-floor behavior applies to extra keys in nested objects, extra elements in JSON arrays (actually, JSON arrays decode into Go slices that grow to fit the input, so "extra" isn't really a concept there), and any other mismatch where the JSON has data the destination can't hold.
Field matching is also case-insensitive as a fallback. "NAME", "name", and "Name" in the JSON all populate a Go field tagged "name", with the exact-match winning if both are present. This is another piece of the package's forgiving philosophy: minor capitalization differences shouldn't break decoding. The matching algorithm tries exact tag match first, then case-insensitive tag match, then exact field name, then case-insensitive field name.