encoding/json
JSON in Go
Section titled “JSON in Go”The encoding/json package converts Go values to JSON text (marshaling) and JSON text back to Go values (unmarshaling). The two central functions are:
data, err := json.Marshal(v) // Go value → []byte (JSON)err = json.Unmarshal(data, &v) // []byte (JSON) → Go valueMarshal takes any value and returns a []byte. Unmarshal writes into the value pointed to by the second argument — always pass a pointer so the function can modify the variable.
Struct tags
Section titled “Struct tags”A struct tag is a raw-string literal on a struct field that provides metadata. The encoding/json package reads json:"name" tags to control serialization:
type User struct { Name string `json:"name"` Email string `json:"email,omitempty"` Age int `json:"age"`}The tag syntax is `json:"<name>[,options]"`. Common options:
| Tag | Effect |
|---|---|
json:"name" | Use name as the JSON key instead of the field name |
json:"name,omitempty" | Omit this field when it is false, 0, a nil pointer/interface, or an empty string/slice/map/array — not a zero struct or a zero time.Time |
json:"name,omitzero" (Go 1.24) | Omit this field when it is the zero value of its type, including structs and time.Time — honours an IsZero() bool method if the type has one |
json:"-" | Always omit this field |
omitempty only checks for “emptiness” at the language level (the field’s zero value for basic types, or length zero for slices/maps). A struct value — even one whose fields are all zero — is never “empty” by that rule, so omitempty never omits it. time.Time is a struct, so the same applies: a zero time.Time{} is still marshaled, as "0001-01-01T00:00:00Z".
type Location struct { City string}
type Event struct { Name string `json:"name"` Location Location `json:"location,omitempty"` // struct — NOT omitted when zero When time.Time `json:"when,omitempty"` // time.Time — NOT omitted when zero}
e := Event{Name: "party"} // Location and When left at their zero valuesdata, _ := json.Marshal(e)fmt.Println(string(data))// {"name":"party","location":{"City":""},"when":"0001-01-01T00:00:00Z"}omitzero (Go 1.24) fixes exactly this: it omits a field whenever the field’s value equals its type’s zero value, structs included, and it calls IsZero() on the field when the type defines one — which is how it correctly treats time.Time{} as empty.
type Event2 struct { Name string `json:"name"` Location Location `json:"location,omitzero"` // omits zero structs too When time.Time `json:"when,omitzero"` // uses time.Time.IsZero()}
e2 := Event2{Name: "party"}data2, _ := json.Marshal(e2)fmt.Println(string(data2))// {"name":"party"}Nested structs
Section titled “Nested structs”Struct fields can themselves be structs. json.Marshal recurses into them automatically:
type Address struct { City string `json:"city"` Country string `json:"country"`}
type User struct { Name string `json:"name"` Age int `json:"age"` Address Address `json:"address"`}The resulting JSON nests the address object under the "address" key.
map[string]any — flexible unmarshaling
Section titled “map[string]any — flexible unmarshaling”When you do not have a struct type ahead of time, unmarshal into map[string]any. Each JSON value becomes its natural Go counterpart: string, float64, bool, []any, map[string]any, or nil.
var m map[string]anyjson.Unmarshal([]byte(`{"lang":"Go","version":1.22}`), &m)fmt.Println(m["lang"]) // Gofmt.Println(m["version"]) // 1.22 (float64)package main
import ( "encoding/json" "fmt")
type Address struct { City string `json:"city"` Country string `json:"country"`}
type User struct { Name string `json:"name"` Age int `json:"age"` Email string `json:"email,omitempty"` Address Address `json:"address"`}
func main() { // Marshal a struct — Email is set, so it appears u := User{ Name: "Alice", Age: 30, Address: Address{ City: "Bangkok", Country: "Thailand", }, } data, err := json.Marshal(u) if err != nil { fmt.Println("marshal error:", err) return } fmt.Println(string(data))
// omitempty: Email is empty, so it is omitted u2 := User{Name: "Bob", Age: 25, Address: Address{City: "London", Country: "UK"}} data2, _ := json.Marshal(u2) fmt.Println(string(data2))
// Unmarshal back into a struct var result User err = json.Unmarshal(data, &result) if err != nil { fmt.Println("unmarshal error:", err) return } fmt.Printf("Name: %s, City: %s\n", result.Name, result.Address.City)
// map[string]any — flexible unmarshaling raw := `{"lang":"Go","version":1.22,"stable":true}` var m map[string]any _ = json.Unmarshal([]byte(raw), &m) fmt.Println(m["lang"], m["version"], m["stable"])} Unexported fields are invisible, not just omitted
Section titled “Unexported fields are invisible, not just omitted”An unexported field (lowercase first letter) is not merely skipped by omitempty semantics — encoding/json cannot see it at all, through reflection or otherwise. No tag changes that:
type Account struct { Name string `json:"name"` secret string // never marshaled, never populated by Unmarshal, no matter what}json.Marshal(Account{Name: "root", secret: "swordfish"}) produces {"name":"root"} — the secret field simply does not exist as far as the package is concerned. This is a common source of “why isn’t my field showing up” confusion when AI-generated code copies a lowercase field name from elsewhere in the codebase.
Decoding into any: float64 precision and json.Number
Section titled “Decoding into any: float64 precision and json.Number”Without a target struct, Unmarshal/Decode turn every JSON number into a Go float64 — including integers. A float64 only has 53 bits of mantissa, so any integer literal larger than 2^53 (9007199254740992) silently loses precision the moment it becomes any:
var v anyjson.Unmarshal([]byte(`{"n": 9007199254740993}`), &v)// v["n"] is 9.007199254740992e+15 — the trailing 3 became a 2json.Decoder.UseNumber() avoids this: it decodes JSON numbers into json.Number, a string type that preserves the original text, and you convert explicitly with .Int64() or .Float64() when you actually need a numeric type — a large ID field is the classic case this saves.
Decoder vs Unmarshal: DisallowUnknownFields and streaming with More()
Section titled “Decoder vs Unmarshal: DisallowUnknownFields and streaming with More()”json.Unmarshal(data, &v) needs the whole payload in memory as a []byte. json.NewDecoder(r).Decode(&v) reads directly from an io.Reader — no full buffer required, and it supports two features Unmarshal does not:
dec.DisallowUnknownFields()rejects any JSON key that has no matching struct field, instead of silently ignoring it — useful at an API boundary where a typo’d or unexpected field should be a hard error.dec.More()lets you decode a stream of values one at a time — for example the elements of a JSON array — without ever holding the whole array in memory.
dec := json.NewDecoder(r)dec.Token() // consume the opening '['for dec.More() { var item Item dec.Decode(&item) // one element at a time}[]byte, time.Time, and nil vs empty slices
Section titled “[]byte, time.Time, and nil vs empty slices”Three encoding rules that are easy to get wrong when writing an API contract by hand:
[]bytemarshals as a base64-encoded JSON string (RFC 4648), not as a JSON array of numbers.time.Timemarshals as an RFC 3339 string by default (json.Marshal’s only built-in time format).- A
nilslice marshals asnull; a non-nil empty slice ([]T{}) marshals as[]. AGET /itemsendpoint that forgets to initialize its result slice sends"items":nullto clients that expect"items":[]— a real API-compatibility bug, not just cosmetic.
Custom MarshalJSON and the alias trick
Section titled “Custom MarshalJSON and the alias trick”A type can control its own JSON encoding by implementing MarshalJSON() ([]byte, error). The trap: calling json.Marshal on the receiver inside that method calls MarshalJSON again, recursing forever. The fix is the alias trick — define a new type with the same underlying fields but none of the original’s methods, and marshal that instead:
type Node struct { Value int `json:"value"` Children []Node `json:"children"`}
func (n Node) MarshalJSON() ([]byte, error) { type alias Node // same fields, no methods — breaks the recursive call return json.Marshal(struct { Total int `json:"total"` alias }{ Total: n.Value + len(n.Children), alias: alias(n), })}type alias Node creates a distinct named type with Node’s exact field layout but a fresh, empty method set, so json.Marshal(alias(n)) goes through the default struct encoder instead of calling MarshalJSON again.
package main
import ( "bytes" "encoding/json" "fmt" "strings" "time")
// secret is unexported — encoding/json can never see it, tag or no tagtype Account struct { Name string `json:"name"` secret string}
// Node uses a MarshalJSON with the "alias trick" to avoid infinite recursion:// aliasing Node to a new type with the same fields drops the MarshalJSON method,// so encoding it with the alias type calls the default struct encoder, not itself.type Node struct { Value int `json:"value"` Children []Node `json:"children"`}
func (n Node) MarshalJSON() ([]byte, error) { type alias Node // same fields, no methods — breaks the recursive call return json.Marshal(struct { Total int `json:"total"` alias }{ Total: n.Value + len(n.Children), alias: alias(n), })}
type Blob struct { Raw []byte `json:"raw"` When time.Time `json:"when"` Tags []string `json:"tags"` Empty []string `json:"empty"`}
func main() { // 1. unexported field is invisible to encoding/json a := Account{Name: "root", secret: "swordfish"} data, _ := json.Marshal(a) fmt.Println("unexported dropped:", string(data))
// 2. decoding into any: numbers become float64, large ints lose precision var any1 any json.Unmarshal([]byte(`{"n": 9007199254740993}`), &any1) m := any1.(map[string]any) fmt.Printf("as any: %v (%T)\n", m["n"], m["n"])
// json.Number keeps the exact text instead dec := json.NewDecoder(strings.NewReader(`{"n": 9007199254740993}`)) dec.UseNumber() var any2 any dec.Decode(&any2) m2 := any2.(map[string]any) num := m2["n"].(json.Number) fmt.Printf("as json.Number: %v (%T) -> Int64: %v\n", num, num, func() string { i, err := num.Int64() return fmt.Sprintf("%d err=%v", i, err) }())
// 3. DisallowUnknownFields rejects fields the struct doesn't declare type Strict struct { Name string `json:"name"` } var s Strict dec2 := json.NewDecoder(strings.NewReader(`{"name":"Ada","extra":1}`)) dec2.DisallowUnknownFields() err := dec2.Decode(&s) fmt.Println("DisallowUnknownFields error:", err)
// 4. Decoder streaming with dec.More() over a JSON array, one element at a time dec3 := json.NewDecoder(strings.NewReader(`[1,2,3]`)) dec3.Token() // consume the opening '[' var nums []int for dec3.More() { var n int dec3.Decode(&n) nums = append(nums, n) } fmt.Println("streamed with dec.More():", nums)
// 5. []byte marshals as base64, time.Time as RFC3339 b := Blob{ Raw: []byte("hi"), When: time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC), Tags: []string{"a"}, Empty: []string{}, } var nilTags []string b.Tags = nilTags // nil slice data2, _ := json.Marshal(b) fmt.Println("[]byte + time.Time:", string(data2))
// 6. custom MarshalJSON with the alias trick against infinite recursion tree := Node{Value: 1, Children: []Node{{Value: 2}, {Value: 3}}} data3, _ := json.Marshal(tree) fmt.Println("custom MarshalJSON:", string(data3))
// 7. nil slice encodes as null, empty slice encodes as [] type Lists struct { Nil []int `json:"nil"` Empty []int `json:"empty"` } var buf bytes.Buffer json.NewEncoder(&buf).Encode(Lists{Nil: nil, Empty: []int{}}) fmt.Print("nil vs empty slice: ", buf.String())} Real output from this program on Go 1.26.5:
unexported dropped: {"name":"root"}as any: 9.007199254740992e+15 (float64)as json.Number: 9007199254740993 (json.Number) -> Int64: 9007199254740993 err=<nil>DisallowUnknownFields error: json: unknown field "extra"streamed with dec.More(): [1 2 3][]byte + time.Time: {"raw":"aGk=","when":"2026-09-13T12:00:00Z","tags":null,"empty":[]}custom MarshalJSON: {"total":3,"value":1,"children":[{"total":2,"value":2,"children":null},{"total":3,"value":3,"children":null}]}nil vs empty slice: {"nil":null,"empty":[]}Note the last line of children in the tree output: {Value: 3} has no Children set, so it is a nil slice, and it marshals as null — the same rule from item 7, showing up inside the custom MarshalJSON output too.
encoding/json/v2 (Go 1.27)
Section titled “encoding/json/v2 (Go 1.27)”Go 1.27 ships encoding/json/v2 and its low-level companion encoding/json/jsontext as stable packages. Everything above still works: the v1 encoding/json API is unchanged, but it is now implemented on top of v2, so existing programs get the faster decoder for free (opt out with GOEXPERIMENT=nojsonv2 if you hit a behavioural difference).
Import v2 when you want its API and stricter defaults:
package main
import ( "encoding/json/v2" "fmt" "os")
type User struct { Name string `json:"name"` Email string `json:"email,omitempty"` Age int `json:"age"`}
func main() { u := User{Name: "Ada", Age: 36}
data, err := json.Marshal(u) // same shape as v1 … fmt.Println(string(data), err)
var back User err = json.Unmarshal(data, &back) fmt.Printf("%+v %v\n", back, err)
data, _ = json.Marshal(map[string]int{"b": 2, "a": 1}, json.Deterministic(true)) // … plus variadic Options fmt.Println(string(data))
_ = json.MarshalWrite(os.Stdout, u) // straight to an io.Writer fmt.Println()
// stricter defaults: duplicate keys are rejected err = json.Unmarshal([]byte(`{"name":"A","name":"B"}`), &back) fmt.Println("duplicate key:", err)} json.UnmarshalRead(r, &u) is the mirror image of MarshalWrite — it decodes straight from an io.Reader such as resp.Body.
What is different from v1:
| Area | v1 encoding/json | v2 encoding/json/v2 |
|---|---|---|
| Invalid UTF-8 in strings | silently replaced | rejected |
| Duplicate object keys | last one wins | rejected |
| Field name matching | case-insensitive | exact match by default (json.MatchCaseInsensitiveNames(true) to relax) |
| Options | global Encoder setters | per-call Options values, composable |
| Embedding a struct’s fields | anonymous field | json:",embed" tag (the old inline name was renamed) |
| Unmarshal speed | baseline | significantly faster; Marshal on par |
The stricter, case-sensitive name matching is easy to trip over silently. With encoding/json/v2 imported, unmarshalling a lowercase key into an untagged field returns a nil error and leaves the field empty:
type User struct { Name string // no tag}
var u Usererr := json.Unmarshal([]byte(`{"name":"Ada"}`), &u)// v1: err == nil, u.Name == "Ada" — matched case-insensitively// v2: err == nil, u.Name == "" — no exact match, field left alonev1 matched name to Name case-insensitively; v2 requires an exact match (or a json:"name" tag) by default. Always add explicit tags when moving code to v2.
jsontext is the streaming layer underneath: jsontext.Decoder and jsontext.Encoder walk a document as Tokens and Values while validating syntax, which is what you want for very large payloads or for rewriting JSON without decoding it into Go values.
dec := jsontext.NewDecoder(r)for { tok, err := dec.ReadToken() if err == io.EOF { break } // inspect tok.Kind(): '{', '}', '"', '0', …}Rule of thumb for 2026: keep v1 for existing code and for wire formats that tolerate sloppy input; start new services on v2 so malformed JSON fails loudly at the boundary.
The marshal/unmarshal path
Section titled “The marshal/unmarshal path”flowchart LR GoValue["Go struct"] -- "json.Marshal" --> Bytes["[]byte JSON"] Bytes -- "json.Unmarshal" --> GoValue2["Go struct"] GoValue -- "MarshalJSON()" --> Custom["custom encoding"] Bytes -- "json.NewDecoder(r)" --> Dec["Decoder"] Dec -- "Decode() / More()" --> Stream["one value at a time"] Bytes -- "into map[string]any" --> Any["float64 / json.Number"]
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| json.Marshal/Unmarshal | Simple, no schema required | Reflection-based — 2-5x slower than a code-gen approach |
Struct tags (json:"name") | Control field names, omitempty | Checked only at runtime — no compile-time check that a tag is well-formed |
| json.Decoder (streaming) | Decode large or streamed JSON without loading it all into memory | A more complex API than Unmarshal |
| omitempty | Keeps zero values out of the JSON | 0, false, and "" all count as empty — this can surprise you |
Common Misconceptions
Section titled “Common Misconceptions”- json.Marshal/Unmarshal are goroutine-safe — they are; but each
json.Decoder/Encoderinstance is not — never share one across goroutines. - Unexported fields get marshaled — they do not;
encoding/jsonignores unexported fields regardless of any tag you write on them. - omitempty treats a pointer the same as its pointee —
omitemptyon a*Tchecks the pointer itself: nil is omitted, non-nil is included, even when the value it points to is zero. - An
anyfield in a struct always marshals as null — it marshals according to the concrete type stored in the interface at runtime.
💡 Real-world example
Kubernetes API objects: every Kubernetes resource (Pod, Deployment, Service) marshals and unmarshals through
encoding/json— struct tags control every JSON key.Go REST APIs:
json.NewDecoder(r.Body).Decode(&req)is the standard pattern nearly every Go API server uses to parse a request body.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated encoding/json code tends to look complete while quietly assuming a schema it never validates. Check for these before trusting it:
- Does a field meant to appear in JSON start with an uppercase letter? A lowercase field silently never serializes, with no error from
MarshalorUnmarshal. - Does anything type-assert a decoded
anyvalue straight toint,int64, or a custom numeric type? Every JSON number decodes tofloat64unless the target is a concrete struct field orUseNumber()is set. - Does an
omitemptytag sit on astructortime.Timefield and assume it will be dropped when zero? It will not — onlyomitzero(Go 1.24) does that. - Is a
[]Tresult field ever left nil on an empty result, when API consumers expect[]rather thannull? - Does a custom
MarshalJSONcalljson.Marshalonn(the receiver) instead of an alias type — an infinite recursion that only surfaces at runtime? - Does code that must reject unexpected fields use plain
Unmarshalinstead of aDecoderwithDisallowUnknownFields()?
🐛 Spot the bug
func parseCount(body []byte) (int, error) { var v map[string]any if err := json.Unmarshal(body, &v); err != nil { return 0, err } count := v["count"].(int) // ??? return count, nil}Show the answer
json.Unmarshal into map[string]any turns every JSON number into float64, never int — so v["count"].(int) panics on any valid input:
recovered: interface conversion: interface {} is float64, not intFix it with the correct type assertion and an explicit conversion, or avoid the map entirely by decoding into a typed struct with a Count int field, which lets encoding/json do the numeric conversion for you:
count, ok := v["count"].(float64)if !ok { return 0, fmt.Errorf("count: not a number")}return int(count), nil