The zero-value trap
You build an API endpoint that returns a configuration object. You add the omitempty tag to a nested struct field, expecting it to vanish from the JSON when the data is incomplete. You run the server, send a request, and the response still contains "info": {}. The field did not disappear. You check the tag spelling. You check the struct definition. Everything looks correct. The JSON encoder is following the rules exactly. You just misunderstood what empty means to the Go runtime.
How omitempty actually decides
Go does not guess. The encoding/json package treats omitempty as a strict type check. It asks one question: does this field hold the exact zero value for its declared type? If the answer is yes, the field is skipped. If the answer is no, the field is serialized, even if it looks blank to a human.
Every Go type has a zero value. Integers start at 0. Booleans start at false. Strings start at "". Pointers start at nil. Structs start with every field set to its own zero value. The omitempty tag does not look inside a struct to see if the fields are blank. It looks at the struct itself. If the struct variable exists in memory, it is not zero. It is a populated container, even if that container holds nothing but zeros.
Think of it like a shipping label. omitempty only removes the label if the box itself does not exist. If the box exists but is empty, the label stays on. The encoder serializes the box.
The pointer shortcut
Here is the simplest way to see the difference between a value type and a pointer type.
package main
import (
"encoding/json"
"fmt"
)
type Config struct {
// String value: omitted only if exactly ""
Name string `json:"name,omitempty"`
// Pointer to string: omitted if nil
Alias *string `json:"alias,omitempty"`
// Struct value: omitted only if the entire struct is zero-valued
Meta MetaData `json:"meta,omitempty"`
// Pointer to struct: omitted if nil
Details *MetaData `json:"details,omitempty"`
}
type MetaData struct {
Version string
Build int
}
func main() {
// Create a config where Meta has one field set
cfg := Config{
Name: "production",
Meta: MetaData{Version: "1.0"},
}
// Marshal to JSON and print the result
data, _ := json.Marshal(cfg)
fmt.Println(string(data))
}
The output is {"name":"production","meta":{"version":"1.0","build":0}}. The meta field appears because cfg.Meta is not the zero value of MetaData. It contains "1.0" and 0. The struct itself exists. The pointer fields Alias and Details are nil, so they vanish completely.
The encoding/json package uses reflection to walk your struct fields at runtime. For each field, it checks the type, reads the value, and compares it against the type's zero value. If the field is a pointer, the check is simple: is the memory address nil? If yes, skip. If no, dereference and serialize.
If the field is a struct value, the check compares the entire memory block against a freshly zeroed instance of that struct. If any byte differs, the field is considered non-zero. The encoder then serializes the struct, including all its zero-valued fields, unless those inner fields also have omitempty.
This behavior is intentional. Go favors explicit memory layout. A struct value lives inline with its parent. It has a fixed size. The compiler allocates space for it whether you fill it or not. The JSON encoder respects that allocation. It does not treat a struct value like a pointer that can be unallocated.
Pointers are cheap to pass. They are eight bytes on a 64-bit system. Using *string or *Info changes the memory footprint from the size of the data to the size of an address. It also changes the zero value from a populated struct to nil. That single change is usually enough to make omitempty behave the way you expect.
When structs refuse to disappear
In production APIs, you often need fine-grained control. You want a nested object to disappear only when a specific flag is missing, not when the entire struct is zero. The pointer trick works for simple cases, but it breaks down when you need conditional logic based on multiple fields.
Here is how you take full control by implementing the json.Marshaler interface.
package main
import (
"encoding/json"
"fmt"
)
type Payload struct {
// Custom type to control marshaling behavior
Settings Settings `json:"settings,omitempty"`
}
type Settings struct {
Timeout int
Retries int
}
// MarshalJSON implements json.Marshaler for Settings
// Returns nil to signal the parent should omit this field
func (s Settings) MarshalJSON() ([]byte, error) {
// Omit if both fields are at their zero values
if s.Timeout == 0 && s.Retries == 0 {
return nil, nil
}
// Create an alias type to avoid infinite recursion
// The alias inherits the struct fields but not the method
type Alias Settings
return json.Marshal(Alias(s))
}
func main() {
// Empty settings should vanish from the JSON output
p := Payload{}
data, _ := json.Marshal(p)
fmt.Println(string(data))
}
The output is {}. The Settings field disappears because MarshalJSON returns nil, nil. The encoding/json package treats a nil byte slice from a custom marshaler as a signal to skip the field entirely. This pattern gives you exact control over what empty means.
Note the receiver naming convention. The method uses (s Settings), not (this Settings) or (self Settings). Go idioms favor one or two letter receivers that match the type name. Also notice the type Alias Settings trick. Without it, calling json.Marshal(s) inside MarshalJSON would trigger the same method again, causing a stack overflow. The alias breaks the cycle while preserving the underlying data layout.
The & tag modifier exists for edge cases. Adding & to a JSON tag like json:"field,omitempty,&" tells the encoder to pass a pointer to the field instead of the value. This is rarely needed. It exists to support types that implement MarshalJSON on a pointer receiver but are stored as values. Stick to value receivers for custom marshalers unless you have a specific reason to mutate state during encoding.
Common pitfalls and runtime behavior
The omitempty tag interacts with Go's visibility rules and type system in ways that trip up newcomers. Unexported fields starting with a lowercase letter are ignored by the JSON package entirely. They never appear in output, and omitempty does not apply to them. If you accidentally lowercase a field name, the compiler will not warn you. The JSON package simply skips it. You get silent data loss. Public names start with a capital letter. Private start lowercase. The JSON encoder respects that boundary strictly.
Another common mistake is mixing up pointer semantics. If you declare a field as *string and assign it a pointer to an empty string, the field is not nil. It points to a valid memory address containing "". The omitempty check sees a non-nil pointer and serializes it as "field":"". The tag does not dereference and check the inner value. It only checks the pointer itself.
If you implement a custom marshaler incorrectly, the runtime panics or returns an error. Returning a non-nil error from MarshalJSON stops the entire encoding process. The caller receives an error like json: error calling MarshalJSON for type Settings. If you forget to return a byte slice and return a string instead, the compiler rejects the program with cannot use s (type Settings) as type json.Marshaler in return argument. The interface contract is strict.
Memory layout also matters. Value types copy data. Pointer types copy addresses. If you embed a large struct by value in a high-throughput API response, you pay the copy cost on every marshal. Switching to a pointer changes the memory footprint but changes the omitempty behavior. Pick the layout that matches your performance needs, then adjust the JSON tags accordingly.
Trust the compiler. If you forget to import encoding/json, you get undefined: json. If you pass the wrong type to Marshal, you get cannot use cfg (type Config) as type interface in argument. These errors are verbose by design. They point directly to the type mismatch. Read them carefully. They save hours of debugging.
When to reach for pointers, structs, or custom logic
Use a value type when the struct is small, always present in the domain model, and you want the JSON to reflect the exact memory layout. Use a pointer type when the field is optional, might be unallocated, or you want omitempty to trigger on nil. Use a custom MarshalJSON method when empty depends on business logic, multiple fields, or external state. Use the default encoder when you want predictable, zero-config serialization and can accept Go's strict zero-value rules.
The JSON package does not read your mind. It reads your types. Define your types clearly, and the output will follow.