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.
- 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.
- what counts as empty for
omitemptyand howomitzero(available since Go 1.24) differs from it; - key case, extra keys,
DisallowUnknownFields,UseNumber, the,stringtag; - 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:
- "How do I make a field have a different name in JSON?" — the warm-up:
the
json:"name"tag. - "What does
omitemptydo?" — followed straight away by "and an empty struct? andtime.Time{}?". This is where people answer from memory and get it wrong. - "What happens if the JSON has an extra field? And if a key has different
case?" — checks whether the candidate knows that
Unmarshalstays quiet. - "Why did the wrong id arrive?" — numbers in
interface{}. - "How does a
nilslice differ from an empty one in JSON?" — the API contract with the frontend. - "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 same struct without tags and with them.
Fragment of bench/gojson/internals.go — type declarations:
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.
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)".
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.
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 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:
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:
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.
Fragment of bench/gojson/internals.go — the fix:
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
"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.
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 do | what it costs |
|---|---|
json.Marshal of a struct | one allocation for the result; manual assembly is faster by less than a factor of two, not by orders of magnitude |
json.Unmarshal into a struct | several times more than Marshal; two passes over the input, an allocation per string |
json.Unmarshal into map[string]any | more expensive still, and numbers become float64 |
| manual assembly | faster, 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
How do you tell "the field did not arrive" from "the field equals zero"?
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.
What is encoding/json/v2 and what is different in it?
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.
Unmarshal or Decoder — which one for an HTTP request body?
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: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
omitempty skips any zero value
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.
A JSON key must match the tag letter for letter
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.
An extra field in the input is an Unmarshal error
Extra keys are skipped silently, err is nil. Only a Decoder with DisallowUnknownFields gives an error: json: unknown field "role".
Unmarshal fills the struct from scratch
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.
An integer from JSON stays an integer in an any
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.
If a type has a MarshalJSON, it is always called
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.
An alias works as the twin inside MarshalJSON: type event = Event
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).
encoding/json is slow because of reflection, Marshal is better written by hand
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
| Version | Change | What it means for code |
|---|---|---|
| Go 1.1 | Fields 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.24 | The 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.25 | The 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.27 | Per 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
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
Knowledge check
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?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
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 ajson:"…"tag; the package skips unexported fields (except embedded structs — their exported fields are promoted to the top).Marshalwalks the struct's fields,Unmarshallooks for a field for each key of the input. How the package reads tags throughreflectis covered in the [lesson on reflection](/en/interview/golang/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.Timepass straight throughomitempty—v":"0001-01-01T00:00:00Z. The key"NAME"fills the field taggedname, 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 anint64field. - Beyond that comes what separates real understanding from having read about it. A number in an
anybecomes afloat64, and9007199254740993turned into9007199254740992in the run — on the way in and on the way back, without an error. That is lost precision, not a range error: a number aboveMaxInt64in anint64field gives anUnmarshalTypeError. The remedies areDecoder.UseNumber, anint64field, or the,stringtag. Anilslice isnull, an empty one is[]. AMarshalJSONon a pointer is not called for a value without an address:json.Marshal(inv)gavetotal":1234}, whilejson.Marshal(&inv)gave{"total":"12.34. Callingjson.Marshal(e)inside your ownMarshalJSONmeans a stack overflow and the whole process going down. The lesson measured cost in one configuration — go1.24.7, linux/amd64, two cores: thereMarshalof a five-field struct cost less than twice as much as assembling the same string by hand, andUnmarshalcost several times more thanMarshal. On your machine and version this must be measured again: in Go 1.27, according to the release notes,encoding/jsonalready runs on a different implementation.
In fact
- Only
false,0,"", anilpointer, anilinterface 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 isomitzero— since Go 1.24. - Not in
encoding/json:Unmarshalprefers 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. - Extra keys are skipped silently,
errisnil. Only aDecoderwithDisallowUnknownFieldsgives an error:json: unknown field "role". - It merges the input into what the struct already holds: a field absent from the input keeps its old value,
nullfor astringchanges 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. - It becomes a
float64.9007199254740993turned into9007199254740992in the run — without an error, and went back into JSON that way. Exactly — viaUseNumber, anint64field or the,stringtag. - 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. - 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 ofEventitself (the methods of embedded fields stay). - In the measurement on go1.24.7
Marshalof 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 wasUnmarshal: 6.0 times more thanMarshal, 13 allocations. That is one configuration (go1.24.7, linux/amd64) and the old implementation; on your version — measure.
By version
- Go 1.1
- Fields of embedded structs come out in JSON as fields of the outer one: according to the
Marshaldocumentation, before Go 1.1 embedded structs were ignored.< - Go 1.24
- The
omitzerotag option — per the Go 1.24 release notes: the field is omitted if its value is zero, and if there is anIsZero() boolmethod, it decides. The lesson's runs (go1.24.7, go1.25.1) show its behavior.< - Go 1.25
- The
encoding/json/v2package is an experiment: only withGOEXPERIMENT=jsonv2, outside the compatibility promise; under the same flagencoding/jsonis implemented on top of v2. There is no v2 source in go1.24.7. Checked by a run on go1.25.1.< - Go 1.27
- Per the release notes:
encoding/json/v2andencoding/json/jsontextare available without a flag;encoding/jsonruns on the v2 implementation with its behavior preserved, but error texts may differ; the old implementation is restored withGOEXPERIMENT=nojsonv2. The lesson did not measure this: Go 1.27 is not in the measurement environment.<
What is covered
- What is really being asked
- Base: a field becomes a key, a tag changes the rule
- Mechanism 1: omitempty — "empty" by the package's rules, not by meaning
- Mechanism 2: Unmarshal merges and keeps quiet
- Mechanism 3: numbers in any — float64, precision and a range error
- Mechanism 4: value shapes the package picks by itself
- Mechanism 5: your own MarshalJSON — the address and the recursion
- Deeper: what encoding/json costs and where the cost is
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
8 SOURCES
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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