- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add omitempty to a struct field, run the tests, and the field
still shows up in the JSON. Or the opposite: you wanted a field to
always be present, and one day it quietly vanished from an API
response because someone downstream started sending a zero value.
Both bugs come from the same place. omitempty in Go does not mean
what most people read it to mean, and once you write a custom
MarshalJSON, it stops applying to your type at all.
There are two interfaces encoding/json cares about, one exact
rule omitempty follows, and a pointer-versus-value decision
underneath both. Everything targets Go 1.23+ and the standard
library encoding/json.
The two interfaces json actually looks for
encoding/json has two hooks. When it marshals a value, it checks
whether the type implements json.Marshaler:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
When it unmarshals into a value, it checks for json.Unmarshaler:
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
If your type implements either one, the encoder hands the whole job
to you and ignores the struct tags on that type. That last part
trips people up, so hold onto it.
Here is a Money type that marshals to a decimal string instead of
a float, because you never want money in a float64:
type Money struct {
Cents int64
}
func (m Money) MarshalJSON() ([]byte, error) {
d := fmt.Sprintf("%d.%02d", m.Cents/100, m.Cents%100)
return json.Marshal(d)
}
Note the last line. You call json.Marshal on a plain string,
which is already valid JSON encoding. Do not hand-build the byte
slice with fmt.Sprintf("\"%s\"", d). That path forgets to escape
quotes and backslashes, and it produces invalid JSON the first time
a value contains a ".
The read side mirrors it:
func (m *Money) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parts := strings.SplitN(s, ".", 2)
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return err
}
cents := whole * 100
if len(parts) == 2 {
frac, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return err
}
cents += frac
}
m.Cents = cents
return nil
}
The receiver is not a style choice
Look at the receivers above. MarshalJSON has a value receiver.
UnmarshalJSON has a pointer receiver. That split is not arbitrary,
and getting it wrong produces the most confusing class of bug in
this whole area.
UnmarshalJSON must take a pointer receiver. It has to write the
decoded data back into the value, and a value receiver gets a copy.
If you write it on a value receiver, the method still compiles, the
decode still runs, and your field stays at its zero value because
you mutated a throwaway copy.
MarshalJSON can go either way, but the receiver you pick decides
which values the encoder can reach. This is the trap:
type Event struct {
At Timestamp
}
If Timestamp implements MarshalJSON on a pointer receiver,
then json.Marshal(Event{}) does not call it. The value you pass
is copied into an interface{}, so the top-level Event is not
addressable, and neither is its At field. Pass a pointer:
json.Marshal(&Event{}) makes the struct addressable, so the
pointer method fires. Map values are worse:
json.Marshal(map[string]Timestamp{...}) hands the encoder
values it can never take the address of, so a pointer-receiver
MarshalJSON never runs for any of them. Slice elements, by
contrast, are addressable through the backing array, so the
pointer method does fire there. The general rule: put
MarshalJSON on a value receiver unless you have a concrete
reason not to. Then it fires for values, pointers, slice
elements, and map values alike.
omitempty: the rule, not the vibe
Here is the field that surprises people:
type Filter struct {
Tags []string `json:"tags,omitempty"`
Page Page `json:"page,omitempty"`
}
type Page struct {
Number int `json:"number"`
Size int `json:"size"`
}
omitempty on Tags works: a nil or zero-length slice is omitted.
omitempty on Page does nothing. Marshal Filter{} and you get:
{"page":{"number":0,"size":0}}
The page key is still there. omitempty skips a field only when
its value is the type's empty value, and the standard library
defines empty as: false, 0, a nil pointer, a nil interface, or
an array, slice, map, or string of length zero. A struct is never
empty. It does not matter that every field inside it is zero. There
is no recursion into the struct.
This is the single most common omitempty misunderstanding. People
read it as "omit when this looks blank." It means "omit when this
equals the zero value, for the small set of kinds where the standard
library bothers to check length or nil." Structs are not on that
list.
The pointer fix, and what it costs
The usual fix is to make the field a pointer. A nil pointer is
empty, so omitempty skips it:
type Filter struct {
Tags []string `json:"tags,omitempty"`
Page *Page `json:"page,omitempty"`
}
Now Filter{} marshals to {}, because Page is nil. Set it to a
real page and it appears. That works, and it is the idiomatic answer
when "absent" and "present with zero fields" need to be different
things on the wire.
But the pointer changes your read side too. The wire has three
states: absent, null, and present with a value. Go collapses
them to two, because absent and null both decode to nil.
On the API-consumer side that means you now have to nil-check every
access, and a missing field and a field explicitly set to null
are indistinguishable. If your protocol needs to tell those apart,
encoding/json alone will not, and you reach for json.RawMessage
or a wrapper type with an explicit Set bool.
So the pointer is not free. It buys you real absence in the JSON at
the cost of nil-checks in Go. Decide per field whether that trade is
worth it, rather than reaching for *T on reflex.
omitempty is ignored on custom marshalers
Back to the thing to hold onto. Once a type implements
json.Marshaler, the encoder calls your method and takes whatever
bytes you return. It does not inspect the result. It does not apply
omitempty based on what you produced.
type Coord struct {
Lat, Lng float64
}
func (c Coord) MarshalJSON() ([]byte, error) {
return json.Marshal([2]float64{c.Lat, c.Lng})
}
type Place struct {
Name string `json:"name"`
Loc Coord `json:"loc,omitempty"`
}
Marshal Place{Name: "x"} and loc is present as [0,0]. The
omitempty tag is dead weight. The encoder's emptiness check runs
against the Go value's kind before your method is called, and a
non-nil struct is never empty, so the field is always emitted and
your MarshalJSON always runs.
If you need a custom-marshaled field to disappear when empty, the
field has to be a pointer and you have to leave it nil:
type Place struct {
Name string `json:"name"`
Loc *Coord `json:"loc,omitempty"`
}
Now a nil *Coord is empty in the encoder's eyes, so the field is
skipped before your MarshalJSON is ever reached. A non-nil pointer
runs the method as normal.
omitzero: the Go 1.24 escape hatch
Go 1.24 added omitzero to encoding/json, and it fixes the exact
struct gap above. omitzero omits a field when it equals its zero
value, and unlike omitempty it does look at whole structs. If the
type implements IsZero() bool, that method decides.
type Filter struct {
Tags []string `json:"tags,omitempty"`
Page Page `json:"page,omitzero"`
}
With omitzero, Filter{} marshals to {} without making Page a
pointer, because a zero Page is recognized as zero. That removes
the main reason people reached for *Page in the first place. If
you are on Go 1.24 or newer, prefer omitzero for struct and array
fields, and keep omitempty for slices, maps, and strings where its
length check is what you want.
One caveat: omitzero compares against the zero value, so a Page
with Number: 0, Size: 0 is still "zero" and gets omitted. If zero
is a meaningful value you want on the wire, that is the wrong tag.
The choice is the same one you have been making all along, just with
better tools to express it.
A short checklist
Four things to check the next time custom JSON misbehaves.
-
UnmarshalJSONon a pointer receiver, always. A value receiver compiles and silently discards the decode. -
MarshalJSONon a value receiver by default, so it fires for slice elements and map values, not just addressable fields. -
omitemptyskips only zero-length slices, maps, strings, and nil pointers and interfaces. It never looks inside a struct. - On a type with a custom
MarshalJSON,omitemptyis inert. Make the field a*Tand leave it nil, or move toomitzeroon Go 1.24+.
Most JSON bugs in Go are not bugs in encoding/json. They are two
reasonable assumptions quietly disagreeing: the tag you read as
"blank" and the emptiness rule the encoder actually runs. Read the
rule, not the vibe, and the surprises go away.
Custom JSON is one of those Go corners that looks like a formatting
detail and turns into an API-contract decision the moment two
services disagree about what "absent" means. The Complete Guide to
Go Programming digs into encoding/json, the reflection underneath
it, and the value-versus-pointer semantics that decide which methods
fire. Hexagonal Architecture in Go is the one to reach for when you
want that serialization logic living at the edge of your system
instead of leaking into the domain.

Top comments (0)