Deep Engineering
Intermediate·Published·65 MIN

JSON in Go: tags, omitempty and what encoding/json does silently

Interview questions on encoding/json (v1) climb a ladder: which fields end up in JSON — what counts as empty for omitempty — why the key NAME filled the field name — where the extra key went — why an id from the database arrived one less — how to write your own MarshalJSON without bringing the process down. A run answers each step: an empty struct and a zero time.Time pass straight through omitempty; Unmarshal ignores case and silently skips what is extra; 9007199254740993 in an any becomes 9007199254740992; a MarshalJSON on a pointer is not called for a value without an address; and in a measurement on go1.24.7, linux/amd64, Marshal of a struct cost less than twice as much as assembling the same string by hand — the expensive side was Unmarshal.

Full technical treatment

TL;DR

encoding/json (v1 — the package imported as "encoding/json") turns a Go value into JSON text and back, and decides what goes where by rules it reads from the type. The key is the name of an exported field or a json:"…" tag; the package skips unexported fields (except embedded structs — their exported fields are promoted to the top). Marshal walks the struct's fields, Unmarshal looks for a field for each key of the input. How the package reads tags through reflect is covered in the lesson on reflection; this one is about what it does with them.

The main consequence: almost everything that goes wrong goes wrong silently. An empty struct and a zero time.Time pass straight through omitempty — {"v":"0001-01-01T00:00:00Z"}. The key "NAME" fills the field tagged name, the extra key "role" disappears without an error, and a field absent from the input keeps its old value. The package returns an error only where pretending that all is well is no longer possible: NaN, a channel, a value of the wrong type, a number that does not fit into an int64 field.

Beyond that comes what separates real understanding from having read about it. A number in an any becomes a float64, and 9007199254740993 turned into 9007199254740992 in the run — on the way in and on the way back, without an error. That is lost precision, not a range error: a number above MaxInt64 in an int64 field gives an UnmarshalTypeError. The remedies are Decoder.UseNumber, an int64 field, or the ,string tag. A nil slice is null, an empty one is []. A MarshalJSON on a pointer is not called for a value without an address: json.Marshal(inv) gave {"total":1234}, while json.Marshal(&inv) gave {"total":"12.34"}. Calling json.Marshal(e) inside your own MarshalJSON means a stack overflow and the whole process going down. The lesson measured cost in one configuration — go1.24.7, linux/amd64, two cores: there Marshal of a five-field struct cost less than twice as much as assembling the same string by hand, and Unmarshal cost several times more than Marshal. On your machine and version this must be measured again: in Go 1.27, according to the release notes, encoding/json already runs on a different implementation.

Where to start
Before this lesson it is enough to understand
  • how to declare a struct and what an exported name is — the lesson on structs;
  • that a field tag is a string in the type which libraries read through reflect — the lesson on reflection;
  • what any (the empty interface) is and what "a type implements an interface" means — in detail in the lesson on interfaces;
  • that a method with a pointer receiver is not available on every value — in detail in the lesson on method receivers; Mechanism 5 also uses embedding.
You do not need to know in advance
  • what counts as empty for omitempty and how omitzero (available since Go 1.24) differs from it;
  • key case, extra keys, DisallowUnknownFields, UseNumber, the ,string tag;
  • your own MarshalJSON, the recursion in it and the twin type; strict decoding of an HTTP request body — a complete example; encoding/json/v2 (an experiment in go1.25, a separate package in Go 1.27).

What is really being asked

A typical ladder:

  1. "How do I make a field have a different name in JSON?" — the warm-up: the json:"name" tag.
  2. "What does omitempty do?" — followed straight away by "and an empty struct? and time.Time{}?". This is where people answer from memory and get it wrong.
  3. "What happens if the JSON has an extra field? And if a key has different case?" — checks whether the candidate knows that Unmarshal stays quiet.
  4. "Why did the wrong id arrive?" — numbers in interface{}.
  5. "How does a nil slice differ from an empty one in JSON?" — the API contract with the frontend.
  6. "How do you write your own MarshalJSON?" — and where the recursion hides in it.

The ladder has one core: by default encoding/json chooses "carry on" over "report". A field went missing, a key found no field, a number was rounded when float64 was chosen — decoding goes on. That does not mean there are no errors at all: a value of the wrong type or an integer that does not fit into its field gives an error, and it must not be ignored. Each mechanism below is one of the cases where the boundary between the two shows.

What this was checked on: the runs are go1.24.7 and go1.25.1, linux/amd64; documentation quotes were checked against go doc and release notes on 14.10.2026. Everything the lesson calls the behavior of encoding/json refers to v1 — the package "encoding/json". By that date Go 1.26 and 1.27 were out; the lesson was not run on them, and what changed in Go 1.27 according to the release notes is in "Version history".

Base: a field becomes a key, a tag changes the rule

The Marshal documentation states the rule in one sentence:

Each exported struct field becomes a member of the object, using the field name as the object key, unless the field is omitted for one of the reasons given below.

— The encoding/json package — Marshal
language contractThe rules for choosing fields, omitempty, matching keys and decoding numbers are written in the encoding/json documentation; the run of bench/gojson/internals.go illustrates them rather than establishes them.

The same struct without tags and with them.

Fragment of bench/gojson/internals.go — type declarations:

GO
type Address struct {
    City string `json:"city"`
}
 
type Account struct {
    ID       int
    Name     string
    Email    string
    Tags     []string
    Address  Address
    password string
}
 
type AccountTagged struct {
    ID       int      `json:"id"`
    Name     string   `json:"name"`
    Email    string   `json:"email,omitempty"`
    Tags     []string `json:"tags,omitempty"`
    Address  Address  `json:"address,omitempty"`
    Password string   `json:"-"`
}

Output of bench/gojson/internals.go, section 1; labels translated:

1) ONE STRUCT WITHOUT TAGS AND WITH TAGS
────────────────────────────────────────
  no tags:
    {"ID":7,"Name":"Ann","Email":"","Tags":null,"Address":{"city":""}}
  with tags name, omitempty and "-":
    {"id":7,"name":"Ann","address":{"city":""}}

Without tags the key is the Go field name with its capital letter, and all exported fields come out, empty ones too. The unexported password is not there — the package walks the fields through reflect and skips such fields; why is covered in the lesson on reflection. There is one exception — an unexported embedded struct: it is not visible itself, but its exported fields are promoted to the top.

Output, section 1 (continued):

  unexported embedded field:
    Outer{inner{X: 1}, 2}              -> {"X":1,"Y":2}

That lesson also shows how the tag is read through StructTag.Get and what json:"" and json:"-" mean; we will not repeat it.

With tags the keys became lower-case, the empty Email and Tags went away, and Password with the "-" tag did not come out. Yet the empty Address with the same omitempty stayed — that is the first rung of the ladder where people stumble, and Mechanism 1 is about it.

That is enough for a Junior-level answer: exported fields go into JSON; the key is the field name or the name from the json:"…" tag; omitempty skips an empty value, "-" skips the field always. The rest is detail about what "empty" means and what happens on the way back.

Mechanism 1: omitempty — "empty" by the package's rules, not by meaning

The documentation lists what an "empty value" is, in full:

The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero.

— The encoding/json package — Marshal

Structs are not on the list — and that is not an oversight. A check with a field of each kind, next to the omitzero option from the same documentation. It exists since Go 1.24 — its release notes say so; on an older Go the omitzero tag will not omit the field.

Output, section 2:

2) OMITEMPTY AND OMITZERO: WHAT COUNTS AS EMPTY
───────────────────────────────────────────────
  field value              with omitempty                       with omitzero
  false                    {}                                   {}
  0                        {}                                   {}
  ""                       {}                                   {}
  (*int)(nil)              {}                                   {}
  &zero (pointer to 0)     {"v":0}                              {"v":0}
  any(nil)                 {}                                   {}
  []int(nil)               {}                                   {}
  []int{}                  {}                                   {"v":[]}
  map[string]int{}         {}                                   {"v":{}}
  [2]int{}                 {"v":[0,0]}                          {}
  struct{}{}               {"v":{}}                             {}
  Address{}                {"v":{"city":""}}                    {}
  time.Time{}              {"v":"0001-01-01T00:00:00Z"}         {}

{} in the table means the field was omitted. Four rows are worth remembering.

time.Time{} with omitempty comes out in JSON as "0001-01-01T00:00:00Z". time.Time is a struct, and a struct is never empty for omitempty. Hence the "year one date" in API responses: a CreatedAt field with omitempty that nobody filled in.

[2]int{} is not empty: the array's length is 2, and the "length zero" rule does not cover it, even though both elements are zero.

A pointer to zero is not empty: omitempty looks at the pointer itself, and it is not nil. That is exactly what people use when they need to tell "field not set" from "field equals zero" — more in "Next they ask".

omitzero works differently. It asks the value for an IsZero() method, and if there is none, compares with the zero value of the type. So it omits structs and time.Time{} (time.Time has IsZero), but keeps the empty non-nil slice []int{} — that is not the zero value of a slice. If both meanings are wanted at once, the two options are given together; the Go 1.24 release notes describe that too: "If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both)".

implementation detail · Go 1.24Line numbers are from encoding/json/encode.go in go1.24.7.

In the source the rule is one function, isEmptyValue (encoding/json/encode.go:318–330 in go1.24.7): array, map, slice and string are checked by Len() == 0, numbers, bool, pointer and interface by IsZero(), and for everything else, structs included, it returns false. Both options are checked by one condition while walking the fields (encode.go:715–716).

Mechanism 2: Unmarshal merges and keeps quiet

The way back follows the same "carry on, don't report" principle, and the documentation says so directly:

Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don't have a corresponding struct field are ignored.

— The encoding/json package — Unmarshal

A Name field with the tag json:"name".

Output, section 3:

3) UNMARSHAL: KEY CASE AND EXTRA KEYS
─────────────────────────────────────
  {"name":"ann"}                     -> Name="ann" err=<nil>
  {"Name":"ann"}                     -> Name="ann" err=<nil>
  {"NAME":"ann"}                     -> Name="ann" err=<nil>
  {"nAmE":"ann"}                     -> Name="ann" err=<nil>
  Unmarshal into Strict{Name}:
    {"name":"ann","role":"user","is_admin":true}
    -> Name="ann" err=<nil>
  Decoder with DisallowUnknownFields, same input:
    -> Name="ann" err=json: unknown field "role"

The tag name does not make the matching strict: all four spellings filled the field. In the source this is two steps in a row — a lookup by exact name, then by a "folded" name without regard to case (encoding/json/decode.go:694–697 in go1.24.7). The extra role and is_admin vanished without a trace.

A strict mode exists, but only on a Decoder, not on the Unmarshal function:

DisallowUnknownFields causes the Decoder to return an error when the destination is a struct and the input contains object keys which do not match any non-ignored, exported fields in the destination.

— The encoding/json package — Decoder.DisallowUnknownFields

The error in the run is json: unknown field "role", on the first extra key. And Name is already filled at that point: the check does not cancel the decoding, it adds an error to it.

The second thing Unmarshal does silently is merge the input into what is already in the struct.

Output, section 4:

4) UNMARSHAL INTO A FILLED STRUCT
─────────────────────────────────
  before: {ID:1 Name:Ann Email:ann@example.com Tags:[a b c] Meta:map[visits:3]}
  input:  {"name":"Bob","tags":["z"],"meta":{"likes":5},"email":null}
  after:  {ID:1 Name:Bob Email:ann@example.com Tags:[z] Meta:map[likes:5 visits:3]}
  err=<nil>

Four different outcomes in one line. There is no id in the input — ID stayed 1. email: null for a string field changed nothing: the documentation says exactly that — "unmarshaling a JSON null into any other Go type has no effect on the value and produces no error". The tags slice was cut to zero length and refilled — [z] instead of [a b c]. And the meta map was added to: the old key visits stayed next to the new likes.

Slices have a subtlety. The documentation describes decoding into a slice like this: "resets the slice length to zero and then appends each element to the slice". The array underneath stays the old one, and struct elements are merged with the old ones just like the struct itself.

Output, section 4 (continued):

  before: {Items:[{A:1 B:2} {A:3 B:4}] Tags:[a b c]}
  input:  {"items":[{"a":5}],"tags":["z"]}
  after:  {Items:[{A:5 B:2}] Tags:[z]}
  err=<nil>
  the old slice old after decoding: [z b c]

B:2 in the new element comes from the old {1 2}: the input had only a. And old, which sat in the Tags field before decoding, now shows [z b c] — z was written into its array.

The practical consequence: a struct that something has already been decoded into cannot be reused for the next message — the fields absent from it will keep their values from before. Each input gets a fresh variable.

And the last thing — a type error does not stop the decoding.

Output, section 4 (end):

  type mismatch:  {"id":"7","name":"Bob"}
    -> ID=0 Name="Bob"
    err=json: cannot unmarshal string into Go struct field Profile.id of type int

ID was skipped, Name was filled, the error was returned. The documentation promises less than the run showed: "it's not guaranteed that all the remaining fields following the problematic one will be unmarshaled into the target object". Code that looks at the fields without checking err gets a half-filled struct — and which half is not known in advance.

Mechanism 3: numbers in any — float64, precision and a range error

When the destination is an interface{} or a map[string]any, there is no field type, and the package chooses one itself. For numbers there is one choice — "float64, for JSON numbers". A float64 has 53 significant bits, and not every integer above 2^53 can be represented.

Output, section 5:

5) NUMBERS IN any: FLOAT64 AND LOST PRECISION
─────────────────────────────────────────────
  input                     {"id":9007199254740993}
  into map[string]any:      9.007199254740992e+15 (float64)
  int64(f)                  9007199254740992
  json.Marshal back         {"id":9007199254740992}

9007199254740993 is 2^53 + 1. It cannot be written exactly in a float64, and decoding rounded it to 2^53 — without an error: that is how strconv.ParseFloat works (convertNumber, encoding/json/decode.go:833–842 in go1.24.7). And on the way back the JSON already carries a different id. A proxy service that decodes a request into map[string]any and forwards it corrupts identifiers without saying a word.

Three ways to avoid it.

Output, section 5 (continued):

  with UseNumber:           9007199254740993 (json.Number)
  n.Int64()                 9007199254740993
  into struct{ ID int64 }:  9007199254740993
  2^53 =                    9007199254740992
  with the ",string" tag:   {"id":"9007199254740993"}
  and back:                 9007199254740993 err=<nil>

Decoder.UseNumber puts a json.Number into the interface — a string with the number's original spelling, and Int64() returns it exactly. A field of type int64 solves the problem without settings: when the type is known, the package decodes the number straight into it. The ,string tag writes the number as a string — {"id":"9007199254740993"}. That is not for Go but for the other side; the documentation names the case directly: "This extra level of encoding is sometimes used when communicating with JavaScript programs". A parser on that side that reads numbers into float64 will corrupt an id above 2^53 exactly as any does in Go.

It is important not to confuse lost precision with an error. Rounding happens when the package itself chose float64 — in an any. If the field type is given and the number does not fit into it, Unmarshal returns an error.

Output, section 11:

11) LOST PRECISION OR AN ERROR: A NUMBER OUTSIDE int64
──────────────────────────────────────────────────────
  input: {"id":9223372036854775808}  (2^63, one more than MaxInt64)
  into struct{ ID int64 }:  ID=0
    err=json: cannot unmarshal number 9223372036854775808 into Go struct field Row.id of type int64
  into map[string]any:      9.223372036854776e+18 err=<nil>

The documentation names this case directly: "if a JSON number overflows the target type, Unmarshal skips that field and completes the unmarshaling as best it can", and returns an UnmarshalTypeError. The ID field stayed zero — without checking err, that zero goes on as a real id.

,string has a flip side — it demands a string when reading, too.

Output, section 5 (end):

  ",string", but a number came: err=json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int64

Mechanism 4: value shapes the package picks by itself

Some decisions the package makes for you without asking.

Output, section 6:

6) VALUE SHAPES
───────────────
  []string(nil)                  -> null
  []string{}                     -> []
  map[string]int(nil)            -> null
  []byte("hi")                   -> "aGk="
  []uint8{1, 2, 3}               -> "AQID"
  map{zeta alpha Mid beta}       -> {"Mid":3,"alpha":2,"beta":4,"zeta":1}
  map[int]string{10:"a", 9:"b"}  -> {"10":"a","9":"b"}
  "<b>Tom & Jerry</b>"           -> "\u003cb\u003eTom \u0026 Jerry\u003c/b\u003e"
  Encoder, SetEscapeHTML(false)  -> "<b>Tom & Jerry</b>"
  math.NaN()                     -> error: json: unsupported value: NaN
  math.Inf(1)                    -> error: json: unsupported value: +Inf
  struct{ V float64 }{NaN}       -> error: json: unsupported value: NaN
  make(chan int)                 -> error: json: unsupported type: chan int

A nil slice is null, an empty one is []. For Go the two are almost the same; for a client they are not: it expected an array and got null. A slice that could have stayed nil — var items []Item without a single append — goes out as null. If the API contract promises an array, the slice is created explicitly: items := []Item{} or make([]Item, 0).

[]byte is a base64 string, not an array of numbers: []uint8{1, 2, 3} is "AQID". The documentation calls this an exception to the rule for slices: "except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value".

Map keys are sorted — and the sort is byte-wise: "Mid" comes before "alpha" because upper-case letters come before lower-case ones in ASCII, and the integer keys 10 and 9 are turned into strings and compared as strings — "10" before "9" (slices.SortFunc with strings.Compare, encode.go:762–776). That is why Marshal output for a map is deterministic and can be compared in tests.

<, > and & are escaped as \u003c, \u003e, \u0026 — so that the JSON can be placed in HTML: the documentation explains it by the JSON having to be "safe to embed inside HTML <script> tags". The string after decoding is the same, the bytes are different — whoever compares texts or signs them will see the difference. It can be turned off only on an Encoder: SetEscapeHTML(false).

An error comes only where JSON cannot express the value. NaN and ±Inf do not exist in JSON, and Marshal returns an UnsupportedValueError (encode.go:540–543); channels and functions give an UnsupportedTypeError. One NaN deep inside a struct — and the whole response is not built.

Mechanism 5: your own MarshalJSON — the address and the recursion

A type controls its own JSON if it implements json.Marshaler — the method MarshalJSON() ([]byte, error). There are two traps here.

The first is the pointer receiver. Cents declares MarshalJSON on *Cents and writes the amount as the string "12.34". The sign and the magnitude are formatted separately — why, right after the run block.

Fragment of bench/gojson/internals.go:

GO
type Cents int64
 
func (c *Cents) MarshalJSON() ([]byte, error) {
    n := int64(*c)
    sign, u := "", uint64(n)
    if n < 0 {
        sign, u = "-", -u
    }
    return []byte(fmt.Sprintf(`"%s%d.%02d"`, sign, u/100, u%100)), nil
}
 
type Invoice struct {
    Total Cents `json:"total"`
}

Output, section 7:

7) MARSHALJSON ON A POINTER: WHEN IT IS NOT CALLED
──────────────────────────────────────────────────
  json.Marshal(c)                -> 1234
  json.Marshal(&c)               -> "12.34"
  json.Marshal(inv)              -> {"total":1234}
  json.Marshal(&inv)             -> {"total":"12.34"}
  []Invoice{inv}                 -> [{"total":"12.34"}]
  map[string]Invoice{"a": inv}   -> {"a":{"total":1234}}

One and the same Invoice gave two different JSONs — and not a single error. A method with a *Cents receiver exists only for something whose address can be taken. inv, passed by value, sits in the interface as a copy without an address — there is no method, and the field is encoded as an ordinary number. A slice element is addressable, a map value is not. In the source this is a separate encoder that asks CanAddr() on every value and picks the path (condAddrEncoder, encode.go:396 and 913–918). The rule is the same as in the lesson on method receivers; here it just produces no compile error, because Marshal takes an any.

About the formula inside the method. Shorter would be n/100 and n%100, but in Go the quotient is truncated towards zero, and the remainder carries the sign of the dividend — in the specification's table -5 % 3 equals -2. For negative amounts such a formula prints the minus twice.

Output, section 10:

10) CENTS: THE NAIVE FORMULA AND NEGATIVE AMOUNTS
─────────────────────────────────────────────────
  value                  n/100, n%100               sign and magnitude apart
  0                      "0.00"                     "0.00"
  1                      "0.01"                     "0.01"
  99                     "0.99"                     "0.99"
  100                    "1.00"                     "1.00"
  -1                     "0.-1"                     "-0.01"
  -100                   "-1.00"                    "-1.00"
  -123                   "-1.-23"                   "-1.23"
  9223372036854775807    "92233720368547758.07"     "92233720368547758.07"
  -9223372036854775808   "-92233720368547758.-8"    "-92233720368547758.08"

The JSON is syntactically valid — what is wrong is the amount inside the string. The magnitude is taken as a uint64: there -u does not overflow, and the formula is right even for math.MinInt64, where -n in int64 would overflow.

Hence the advice: declare MarshalJSON on the value if it does not need to change the receiver. UnmarshalJSON is declared on the pointer — otherwise it writes into a copy.

The second trap is recursion. A common task: "produce the usual JSON, but add one field". The first thing people write.

Fragment — deliberately wrong; the full file is bench/gojson/cases/recursion/main.go:

GO
func (e Event) MarshalJSON() ([]byte, error) {
    b, err := json.Marshal(e)
    ...
}

json.Marshal(e) finds the same MarshalJSON on e and calls it. This is not a panic but a stack overflow — a fatal runtime error. The test program has a defer with recover in main, and it did not fire.

Output of bench/gojson/recursion.sh, section 1; labels translated:

1) json.Marshal(e) CALLED INSIDE MARSHALJSON
────────────────────────────────────────────
  runtime: goroutine stack exceeds 1048576-byte limit
  fatal error: stack overflow
  recover in main did not fire: no "recover caught" line in the output
  exit code: 2
  one turn of the cycle in the stack trace of goroutine 1 (callee on top):
    encoding/json.Marshal
    main.Event.MarshalJSON
    main.(*Event).MarshalJSON
    encoding/json.marshalerEncoder
    encoding/json.condAddrEncoder.encode
    encoding/json.condAddrEncoder.encode-fm
    encoding/json.(*encodeState).reflectValue
    encoding/json.(*encodeState).marshal
    encoding/json.Marshal

The stack limit in the test program is lowered to 1 MB so that the crash comes quickly. The usual limit on a 64-bit platform is 1 GB (runtime/proc.go:157–158 in go1.24.7), but the recursion is infinite and will hit any limit. The whole process goes down, not one request.

The cure is a twin type: inside the method, a new type with the same fields is declared, and that is what gets encoded. The specification guarantees that the new type does not inherit the methods of Event itself — but only those:

It does not inherit any methods bound to the given type, but the method set of an interface type or of elements of a composite type remains unchanged.

— The Go Specification — Type definitions

Fragment of bench/gojson/internals.go — the fix:

GO
func (e Event) MarshalJSON() ([]byte, error) {
    type event Event
    return json.Marshal(struct {
        event
        At   int64  `json:"at"`
        Kind string `json:"kind"`
    }{event(e), e.At.Unix(), "event:" + e.Kind})
}

Output of bench/gojson/internals.go, section 8:

8) MARSHALJSON WITHOUT RECURSION: A TWIN TYPE
─────────────────────────────────────────────
  json.Marshal(ev)         -> {"id":5,"at":1790856000,"kind":"event:login"}
  json.Marshal(plain(ev))  -> {"id":5,"at":"2026-10-01T12:00:00Z"}

event is embedded in an anonymous struct, and its fields came out at the top level; the outer At and Kind at depth 0 overrode the same-named ones at depth 1 — as in the lesson on embedding. The second line is the same Event without the method: the time as a string, no kind.

The second half of the quote is the caveat people forget: the methods of embedded fields stay with the twin. If Event embeds time.Time, its MarshalJSON is promoted into the anonymous struct, and a bare time string comes out instead of the object — without an error.

Output, section 9:

9) THE TWIN DOES NOT SAVE YOU FROM AN EMBEDDED FIELD'S METHODS
──────────────────────────────────────────────────────────────
  json.Marshal(st)  -> "2026-10-01T12:00:00Z" err=<nil>
  type stampTwin Stamp implements json.Marshaler: true

Stamp here is the same Event, except the time is embedded rather than held in an At field. id and kind are gone. That is why a field with its own MarshalJSON is not embedded in such a struct but declared as a named field.

The twin must be a new type, not an alias. type event = Event with the = sign is just another name for the same type, and its methods are the same. Worse: embedded in an anonymous struct, it promotes MarshalJSON into that struct itself — the cycle now goes through the anonymous type.

Output of bench/gojson/recursion.sh, section 2 — the deliberately wrong variant:

2) ALIAS TWIN: type event = Event
─────────────────────────────────
  runtime: goroutine stack exceeds 1048576-byte limit
  fatal error: stack overflow
  exit code: 2
  one turn of the cycle in the stack trace of goroutine 1 (callee on top):
    encoding/json.Marshal
    main.Event.MarshalJSON
    go:(*struct { main.event = main.Event; Kind string "json:\"kind\"" }).MarshalJSON

A single = character is the difference between a working method and a dead process.

Deeper: what encoding/json costs and where the cost is

measured observationbench/gojson/cost.go, go1.24.7 linux/amd64, 2 cores, GOMAXPROCS=2, input — one five-field struct (91 bytes of JSON), best of 15 rounds of 200 ms (a minimum, not a typical time), the rounds of the methods interleaved, the result written to a global variable. The machine is shared. This is an observation of one configuration, not a property of Go: the ratios depend on the input, the version and the machine.

"JSON in Go is slow because of reflection" is a phrase said more often than measured. Below is what was observed in one configuration; the mechanism explains why the two sides differ, and your own ratios must be measured on your side. And this measures the old implementation: in Go 1.27 encoding/json runs on v2, and the release notes promise "significantly faster" Unmarshal with Marshal at parity — the lesson did not measure that. A five-field struct, four ways.

Output of bench/gojson/cost.go; labels translated:

1) ONE STRUCT WITH FIVE FIELDS
──────────────────────────────
  {"id":90210,"customer":"Ann Smith","total":12345,"paid":true,"items":["book","pen","lamp"]}
  91 bytes; manual assembly gives the same bytes: true

  method                               ns/op allocs bytes
  json.Marshal(&o)                     340.0      1    96
  manual assembly (strconv.Append*)    184.3      1    96
  json.Unmarshal into Order           2044.3     13   432
  json.Unmarshal into map[string]any  2498.8     33   896

  Marshal vs manual assembly            1.8x
  Unmarshal into a struct vs Marshal    6.0x
  Unmarshal into a map vs into Order    1.2x

In this measurement Marshal cost 1.8 times more than assembling the same string by hand — and allocated just as much: one allocation of 96 bytes, the result itself. The package parses tags and finds fields once per type and caches the result — that cache and the cost of looking a field up by name are covered in detail in the lesson on reflection. What remains per call is walking the fields by a ready-made description plus checks, not "reflection" in the sense of searching by names.

implementation detail · Go 1.24Line numbers are from encoding/json/decode.go in go1.24.7.

The expensive side was Unmarshal: 6.0 times more than Marshal in the run record, and 13 allocations instead of one. The allocation count is an observation of the go1.24.7 implementation, not a contract of the package. Every string of the input is copied into a new Go string (encoding/json/decode.go:953–957 in go1.24.7), and the items slice grows as decoding proceeds — Grow when the capacity runs out (decode.go:545–548). And the package goes over the input twice: before filling the struct, Unmarshal checks the whole syntax — "Avoids filling out half a data structure before discovering a JSON syntax error" (checkValid, encoding/json/decode.go:102 in go1.24.7). Decoding into map[string]any is more expensive still — 1.2 times in the run record — and gives 33 allocations: an interface for each value, a map for the object.

Conclusions for design:

what you dowhat it costs
json.Marshal of a structone allocation for the result; manual assembly is faster by less than a factor of two, not by orders of magnitude
json.Unmarshal into a structseveral times more than Marshal; two passes over the input, an allocation per string
json.Unmarshal into map[string]anymore expensive still, and numbers become float64
manual assemblyfaster, but escaping, NaN and field order are your problem

Manual assembly won by less than a factor of two in the measurement, and it produced the same bytes only because the strings here are ASCII without quotes: in general strconv.AppendQuote escapes differently from JSON. Rewriting Marshal by hand for such a difference is rarely justified; in the measurement the expensive side is decoding the input.

How to answer in an interview

Short answer: exported fields go into JSON, the key is the field name or the json:"…" tag. omitempty skips false, 0, "", nil and empty slices, maps and arrays of length zero, but not structs — which is why time.Time{} comes out as "0001-01-01T00:00:00Z". Unmarshal matches fields without regard to case, skips extra keys silently and does not touch fields absent from the input.

That is enough to answer correctly. What follows is what people add if the interviewer digs.

If the interviewer digs deeper

On "and an empty struct with omitempty?" — the rule and the replacement. "A struct is never empty for omitempty: in the run Address{} came out as {"city":""}, and time.Time{} as a year-one date. omitzero omits them — on Go 1.24 and newer: it asks IsZero() or compares with the zero value."

On "what happens to an extra field" — strict mode. "It disappears without an error. To reject it — a Decoder with DisallowUnknownFields, error json: unknown field "role". And the key "NAME" fills the field name: case is ignored."

On "why is the id wrong" — float64. "In an any a number becomes a float64, and 9007199254740993 turns into 9007199254740992. The remedies are an int64 field, Decoder.UseNumber, or the ,string tag for JavaScript clients."

On "nil slice" — the contract. "A nil slice is null, an empty one is []. If the API promises an array, the slice is created explicitly."

On "your own MarshalJSON" — two traps. "On a pointer the method is not called for a value without an address: json.Marshal(inv) gave {"total":1234}. And json.Marshal(e) inside the method is infinite recursion and a dead process; inside, declare type event Event — a new type without the methods of Event itself, not an alias."

Next they ask

Next they ask

How do you tell "the field did not arrive" from "the field equals zero"?

Short answer

With a pointer. For an int field both cases give 0. For a *int field they differ: no key — the pointer stays nil; a 0 arrived — Unmarshal allocated a value and wrote zero into it. The documentation describes this directly: "If the pointer is nil, Unmarshal allocates a new value for it to point to", and a null sets the pointer to nil.

On the way out the same distinction is kept by omitempty: in the run a pointer to zero came out as {"v":0}, while a nil pointer was omitted. This is how PATCH requests are done: nil means "do not change", a pointer to a value means "write this value", even if it is zero.

A pointer gives no third state: null in the input and a missing key lead to the same nil. If the API must tell "clear the field" from "leave it alone", a pointer is not enough — you need your own type with an UnmarshalJSON that remembers it was called: according to the documentation the method is called "including when the input is a JSON null".

The catch is that a field decoded once will not be reset by another Unmarshal into the same struct — Mechanism 2. A fresh variable for each request.

Next they ask

What is encoding/json/v2 and what is different in it?

Short answer
measured observationbench/gojson/jsonv2.go, go1.25.1 linux/amd64, GOEXPERIMENT=jsonv2. An experiment: the package exists only with this flag and is not covered by the Go 1 compatibility promise.

It is a different package with a different import path — "encoding/json/v2" — and a different set of rules; "encoding/json" (v1) keeps its own. Its status has changed over time, and it matters not to mix the versions up.

In go1.25 it is an experiment, and the run below is exactly that. The package is in the go1.25.1 source tree, but it builds only with GOEXPERIMENT=jsonv2 and, by its documentation in that version, is "not subject to the Go 1 compatibility promise".

In Go 1.27, according to the release notes, no flag is needed. There encoding/json/v2 and encoding/json/jsontext are ordinary packages, and encoding/json itself "is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ"; the old implementation is restored with GOEXPERIMENT=nojsonv2. The lesson was not run on Go 1.27.

Almost every difference in v2 is one of this lesson's silent behaviors; the run on go1.25.1 with the flag.

Output of bench/gojson/jsonv2.go (go1.25.1, GOEXPERIMENT=jsonv2); labels translated:

1) KEY CASE ON UNMARSHAL
────────────────────────
  {"name":"ann"}   v1: Name="ann"  err=<nil>
                   v2: Name="ann"  err=<nil>
  {"NAME":"ann"}   v1: Name="ann"  err=<nil>
                   v2: Name=""     err=<nil>

2) NIL SLICE AND NIL MAP
────────────────────────
  List{}  v1: {"tags":null,"meta":null}
  List{}  v2: {"tags":[],"meta":{}}

3) OMITEMPTY ON A NUMBER
────────────────────────
  Counter{N: 0}  v1: {}
  Counter{N: 0}  v2: {"n":0}

4) MARSHALJSON ON A POINTER, VALUE WITHOUT AN ADDRESS
─────────────────────────────────────────────────────
  json.Marshal(inv)  v1: {"total":1234}
  json.Marshal(inv)  v2: {"total":"12.34"}

5) NULL INTO A FILLED FIELD
───────────────────────────
  {"email":null}  v1: {Name:Ann Email:ann@example.com}
  {"email":null}  v2: {Name:Ann Email:}

6) A REPEATED KEY
─────────────────
  {"name":"a","name":"b"}  v1: Name="b" err=<nil>
  {"name":"a","name":"b"}  v2: Name="a" err=jsontext: duplicate object member name "name"

In v2 case matters, a nil slice is [], omitempty on a number no longer skips zero (that is now omitzero's job), and a MarshalJSON on a pointer is called even for a value without an address, and null zeroes a filled field — question 5 of the knowledge check has a different answer in v2. A repeated key is accepted silently by v1 and rejected by v2 — the Go 1.27 release notes name this as one of v2's stricter defaults. Two more differences from the list in the v1 documentation under the same flag (encoding/json/v2_options.go:84–92 in go1.25.1) are not shown by the run: in v2 a map is encoded in a non-deterministic order, and HTML characters are not escaped — comparing Marshal output for maps in tests no longer works there. Under the same flag the old encoding/json is rewritten on top of v2 — its documentation in go1.25.1 says that "All the behavior of the v1 package is implemented in terms of the v2 package". The run of internals.go in this mode showed the same behavior in all eleven sections; the texts of three errors differ — exactly what the Go 1.27 release notes warn about.

Next they ask

Unmarshal or Decoder — which one for an HTTP request body?

Short answer

A Decoder — above all for the settings: only it has DisallowUnknownFields and UseNumber. json.NewDecoder(r.Body) reads from an io.Reader, and the body does not have to be gathered into a []byte beforehand — how reading from an io.Reader works is in the lesson on io.Reader.

It does not save memory: a Decoder still holds one JSON value in its buffer in full — Decode first reads the value to the end ("Read whole value into buffer", encoding/json/stream.go:62 in go1.24.7) and only then decodes it.

A caveat from the NewDecoder documentation: the decoder "introduces its own buffering and may read data from r beyond the JSON values requested". One Decode call decodes one value; it does not check what lies in the stream after it. Nor does v1 forbid repeated keys — neither Unmarshal nor DisallowUnknownFields does. A strict handler therefore does three things: it caps the body with http.MaxBytesReader (more on it in the lesson on io.Reader), requires a second Decode to return io.EOF, and tells an oversized body apart from bad JSON.

The full file bench/gojson/strict.go — it can be copied into an empty directory and run with go run strict.go:

GO
//go:build ignore
 
// Strict decoding of an HTTP request body: the complete example from the
// encoding/json lesson. Comments and output are in English on purpose: the
// same file is shown in both locales of the lesson.
//
// DisallowUnknownFields alone does not make decoding strict: one Decode call
// reads one value and ignores what follows it, and v1 silently accepts
// duplicate keys. The handler adds the missing steps — a second Decode must
// return io.EOF, and the body size is capped by http.MaxBytesReader — and is
// run on eight request bodies through httptest, without a network.
//
// RUN:
//
//	go run bench/gojson/strict.go
package main
 
import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"strings"
)
 
type CreateUser struct {
	Name string `json:"name"`
}
 
// decodeStrict decodes exactly one JSON value with no unknown keys.
func decodeStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	if err := dec.Decode(v); err != nil {
		return err
	}
	// Nothing but whitespace may follow the value.
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("unexpected data after the JSON value")
	}
	return nil
}
 
func createUser(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 64) // at most 64 bytes
	var u CreateUser
	if err := decodeStrict(r.Body, &u); err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "ok: name=%q", u.Name)
}
 
func main() {
	fmt.Printf("%s %s/%s | strict decoding of an HTTP request body\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)
	fmt.Println()
	fmt.Println("1) HANDLER createUser ON EIGHT REQUEST BODIES")
	fmt.Println("─────────────────────────────────────────────")
	long := `{"name":"` + strings.Repeat("a", 100) + `"}`
	cases := []struct{ label, body string }{
		{`{"name":"ann"}`, `{"name":"ann"}`},
		{`{"name":"ann"}` + "\\n", `{"name":"ann"}` + "\n"},
		{`{"name":"ann","role":"admin"}`, `{"name":"ann","role":"admin"}`},
		{`{"name":"ann"} {}`, `{"name":"ann"} {}`},
		{`{"name":"ann"}x`, `{"name":"ann"}x`},
		{`{"name":"a","name":"b"}`, `{"name":"a","name":"b"}`},
		{`(empty body)`, ``},
		{`{"name":"<100 x a>"}`, long},
	}
	for _, c := range cases {
		req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(c.body))
		rec := httptest.NewRecorder()
		createUser(rec, req)
		fmt.Printf("  %-31s -> %d %s\n", c.label, rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}

Output of go run bench/gojson/strict.go:

1) HANDLER createUser ON EIGHT REQUEST BODIES
─────────────────────────────────────────────
  {"name":"ann"}                  -> 200 ok: name="ann"
  {"name":"ann"}\n                -> 200 ok: name="ann"
  {"name":"ann","role":"admin"}   -> 400 json: unknown field "role"
  {"name":"ann"} {}               -> 400 unexpected data after the JSON value
  {"name":"ann"}x                 -> 400 unexpected data after the JSON value
  {"name":"a","name":"b"}         -> 200 ok: name="b"
  (empty body)                    -> 400 EOF
  {"name":"<100 x a>"}            -> 413 http: request body too large

{} {} and garbage after the object are rejected by the second Decode; a body over 64 bytes gets 413 through *http.MaxBytesError. But {"name":"a","name":"b"} went through with name="b": in v1 a repeated key is not an error, the last one wins. If that matters, you need v2 (which rejects duplicates) or your own check. And DisallowUnknownFields is not validation: required fields, ranges and value formats are checked separately.

Common misconceptions

Claim

omitempty skips any zero value

Actually

Only false, 0, "", a nil pointer, a nil interface and an array, slice, map or string of length zero. Structs — never: in the run time.Time{} came out as "0001-01-01T00:00:00Z", Address{} as {"city":""}. The array [2]int{} is not empty either — its length is 2. For zero structs there is omitzero — since Go 1.24.

Claim

A JSON key must match the tag letter for letter

Actually

Not in encoding/json: Unmarshal prefers an exact match but also accepts one without regard to case. "name", "Name", "NAME" and "nAmE" filled the same field. In the experimental v2 — no longer.

Claim

An extra field in the input is an Unmarshal error

Actually

Extra keys are skipped silently, err is nil. Only a Decoder with DisallowUnknownFields gives an error: json: unknown field "role".

Claim

Unmarshal fills the struct from scratch

Actually

It merges the input into what the struct already holds: a field absent from the input keeps its old value, null for a string changes nothing, a map is added to. A slice is cut to zero length and appended into the same array, and struct elements are merged with the old ones.

Claim

An integer from JSON stays an integer in an any

Actually

It becomes a float64. 9007199254740993 turned into 9007199254740992 in the run — without an error, and went back into JSON that way. Exactly — via UseNumber, an int64 field or the ,string tag.

Claim

If a type has a MarshalJSON, it is always called

Actually

A method on a pointer exists only for an addressable value. json.Marshal(inv) gave {"total":1234}, json.Marshal(&inv) gave {"total":"12.34"}; a value in a map did not get the method either.

Claim

An alias works as the twin inside MarshalJSON: type event = Event

Actually

An alias is the same type with the same methods, and the recursion stays: in the run — fatal error: stack overflow. A new type is needed: type event Event — by the specification it inherits no methods of Event itself (the methods of embedded fields stay).

Claim

encoding/json is slow because of reflection, Marshal is better written by hand

Actually

In the measurement on go1.24.7 Marshal of a five-field struct costs 1.8 times more than manual assembly and allocates the same — one allocation for the result. The expensive side there was Unmarshal: 6.0 times more than Marshal, 13 allocations. That is one configuration (go1.24.7, linux/amd64) and the old implementation; on your version — measure.

Version history

VersionChangeWhat it means for code
Go 1.1Fields of embedded structs come out in JSON as fields of the outer one: according to the Marshal documentation, before Go 1.1 embedded structs were ignored.Embedding moves fields to the top level of the object
Go 1.24The omitzero tag option — per the Go 1.24 release notes: the field is omitted if its value is zero, and if there is an IsZero() bool method, it decides. The lesson's runs (go1.24.7, go1.25.1) show its behavior.Zero structs and a zero time.Time can be omitted — on Go 1.24 and newer
Go 1.25The encoding/json/v2 package is an experiment: only with GOEXPERIMENT=jsonv2, outside the compatibility promise; under the same flag encoding/json is implemented on top of v2. There is no v2 source in go1.24.7. Checked by a run on go1.25.1.Experiment: case, nil slices, omitempty and duplicate keys behave differently
Go 1.27Per the release notes: encoding/json/v2 and encoding/json/jsontext are available without a flag; encoding/json runs on the v2 implementation with its behavior preserved, but error texts may differ; the old implementation is restored with GOEXPERIMENT=nojsonv2. The lesson did not measure this: Go 1.27 is not in the measurement environment.v2 is a separate import with its own rules; the lesson's cost numbers do not apply to 1.27

The lesson's measurements were taken on go1.24.7 and go1.25.1, linux/amd64; quotes were checked on 14.10.2026. The Go 1.24 and Go 1.27 rows rest on the release notes. Without the flag, the behavior of encoding/json did not change between go1.24.7 and go1.25.1 in the run: internals.go prints the same output apart from the version line (runs/internals-go1.25.txt).

Practice

Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from the script's run, not assigned.

Practice · predict the output

The encoding/json (v1) package, go1.24.7. Item has two fields: Price int with the tag price,omitempty and Created time.Time with the tag created,omitempty. User has one field, Name, with the tag name. input is a JSON object with the keys NAME (value ann) and role (value admin); big is an object with the key id and the value 9007199254740993. What does this code print?
b, _ := json.Marshal(Item{})
fmt.Println(string(b))
var u User
err := json.Unmarshal(input, &u)
fmt.Println(u.Name, err)
var m map[string]any
json.Unmarshal(big, &m)
fmt.Println(int64(m["id"].(float64)))

Practice · estimate

A five-field struct: two int64, a string, a bool and a slice of three strings. How many times more does json.Marshal(&order) cost than assembling the same JSON string by hand with strconv.AppendInt, AppendQuote and AppendBool into a new buffer?
times

Knowledge check

Question 1 of 5

encoding/json (v1). A CreatedAt time.Time field with the tag json:"created_at,omitempty" was never filled in. What will be in the JSON?

Sources & further reading

8 SOURCES

  1. The encoding/json package — MarshalOfficial documentation. Which fields go into JSON: «Each exported struct field becomes a member of the object, using the field name as the object key, unless the field is omitted for one of the reasons given below». What is empty for omitempty: «The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero». Slices and []byte: «Array and slice values encode as JSON arrays, except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value». Numbers: «NaN and +/-Inf values will return an UnsupportedValueError». Maps: «The map keys are sorted and used as JSON object keys».https://pkg.go.dev/encoding/json#Marshal
  2. The encoding/json package — UnmarshalOfficial documentation. Case and extra keys: «Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don't have a corresponding struct field are ignored». Numbers in an interface: «float64, for JSON numbers». And null: «unmarshaling a JSON null into any other Go type has no effect on the value and produces no error».https://pkg.go.dev/encoding/json#Unmarshal
  3. The encoding/json package — Decoder.DisallowUnknownFields and Decoder.UseNumberOfficial documentation. Strict decoding: «DisallowUnknownFields causes the Decoder to return an error when the destination is a struct and the input contains object keys which do not match any non-ignored, exported fields in the destination». Numbers without loss: «UseNumber causes the Decoder to unmarshal a number into an interface value as a Number instead of as a float64».https://pkg.go.dev/encoding/json#Decoder.DisallowUnknownFields
  4. The Go Specification — Type definitionsOfficial documentation. What a twin type inside MarshalJSON loses and what it keeps: «It does not inherit any methods bound to the given type, but the method set of an interface type or of elements of a composite type remains unchanged». An example from the same place: «The method set of *PrintableMutex contains the methods Lock and Unlock bound to its embedded field Mutex».https://go.dev/ref/spec#Type_definitions
  5. The encoding/json/v2 package (go1.25, experiment)Official documentation. The package's status: «This package (encoding/json/v2) is experimental, and not subject to the Go 1 compatibility promise. It only exists when building with the GOEXPERIMENT=jsonv2 environment variable set». And the main difference when decoding: «By default, unmarshaling uses case-sensitive matching to identify the Go struct field associated with a JSON object name».https://pkg.go.dev/encoding/json/v2
  6. Go 1.24 Release Notes — encoding/jsonOfficial documentation. Since which version omitzero exists: «When marshaling, a struct field with the new omitzero option in the struct field tag will be omitted if its value is zero. If the field type has an IsZero() bool method, that will be used to determine whether the value is zero». And: «If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both)». Checked on 14.10.2026.https://go.dev/doc/go1.24#encoding/json
  7. Go 1.27 Release Notes — encoding/json and encoding/json/v2Official documentation. The current status of v2: «The encoding/json package is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ»; «Two new packages are now available: … encoding/json/v2 … encoding/json/jsontext»; «The v2 package chooses stricter, more interoperable defaults than v1: it rejects invalid UTF-8 in JSON strings and rejects duplicate names within a JSON object»; «Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster». The old implementation is restored with GOEXPERIMENT=nojsonv2. The lesson did not measure this: Go 1.27 is not in the measurement environment. Checked on 14.10.2026.https://go.dev/doc/go1.27
  8. The Go Specification — Arithmetic operators (integer division)Official documentation. Why the naive formula for Cents prints "-1.-23": the quotient is truncated towards zero — «with x / y truncated towards zero», — and in the specification's table -5 % 3 equals -2: the remainder carries the sign of the dividend.https://go.dev/ref/spec#Arithmetic_operators