Skip to content

encoding/json

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 value

Marshal 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.

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:

TagEffect
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 values
data, _ := 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"}

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.

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]any
json.Unmarshal([]byte(`{"lang":"Go","version":1.22}`), &m)
fmt.Println(m["lang"]) // Go
fmt.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 any
json.Unmarshal([]byte(`{"n": 9007199254740993}`), &v)
// v["n"] is 9.007199254740992e+15 — the trailing 3 became a 2

json.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:

  • []byte marshals as a base64-encoded JSON string (RFC 4648), not as a JSON array of numbers.
  • time.Time marshals as an RFC 3339 string by default (json.Marshal’s only built-in time format).
  • A nil slice marshals as null; a non-nil empty slice ([]T{}) marshals as []. A GET /items endpoint that forgets to initialize its result slice sends "items":null to clients that expect "items":[] — a real API-compatibility bug, not just cosmetic.

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 tag
type 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.

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:

Areav1 encoding/jsonv2 encoding/json/v2
Invalid UTF-8 in stringssilently replacedrejected
Duplicate object keyslast one winsrejected
Field name matchingcase-insensitiveexact match by default (json.MatchCaseInsensitiveNames(true) to relax)
Optionsglobal Encoder settersper-call Options values, composable
Embedding a struct’s fieldsanonymous fieldjson:",embed" tag (the old inline name was renamed)
Unmarshal speedbaselinesignificantly 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 User
err := 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 alone

v1 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.

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"]
Struct tags and MarshalJSON control one direction; Decoder streaming avoids loading the whole payload
What you getBenefitCost
json.Marshal/UnmarshalSimple, no schema requiredReflection-based — 2-5x slower than a code-gen approach
Struct tags (json:"name")Control field names, omitemptyChecked 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 memoryA more complex API than Unmarshal
omitemptyKeeps zero values out of the JSON0, false, and "" all count as empty — this can surprise you
  • json.Marshal/Unmarshal are goroutine-safe — they are; but each json.Decoder/Encoder instance is not — never share one across goroutines.
  • Unexported fields get marshaled — they do not; encoding/json ignores unexported fields regardless of any tag you write on them.
  • omitempty treats a pointer the same as its pointee — omitempty on a *T checks the pointer itself: nil is omitted, non-nil is included, even when the value it points to is zero.
  • An any field 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.

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 Marshal or Unmarshal.
  • Does anything type-assert a decoded any value straight to int, int64, or a custom numeric type? Every JSON number decodes to float64 unless the target is a concrete struct field or UseNumber() is set.
  • Does an omitempty tag sit on a struct or time.Time field and assume it will be dropped when zero? It will not — only omitzero (Go 1.24) does that.
  • Is a []T result field ever left nil on an empty result, when API consumers expect [] rather than null?
  • Does a custom MarshalJSON call json.Marshal on n (the receiver) instead of an alias type — an infinite recursion that only surfaces at runtime?
  • Does code that must reject unexpected fields use plain Unmarshal instead of a Decoder with DisallowUnknownFields()?

🐛 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 int

Fix 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
What does json.Unmarshal require as its second argument?
A struct field tagged json:"email,omitempty" with an empty string value will:
What does this print?
var v any
json.Unmarshal([]byte(`{"n": 9007199254740993}`), &v)
m := v.(map[string]any)
fmt.Println(m["n"])
A struct field named userID (lowercase u) with a json:"user_id" tag will:
What does this print?
type Result struct {
    Items []string `json:"items"`
}
r := Result{}
data, _ := json.Marshal(r)
fmt.Println(string(data))
Why does a custom MarshalJSON method use type alias T before calling json.Marshal on the receiver's fields?