The zero-value trap
You're building a profile update endpoint. The client sends a JSON payload with only the fields the user changed. The request body looks like {"name": "Alice"}. Your Go struct has Name string, Age int, and Email string. After unmarshaling, Name is "Alice", Age is 0, and Email is "".
Your backend logic checks Age. If it's 0, you skip the database update to save a write. But what if the user actually set their age to 0? Or what if the client sent {"age": 0}? The decoder can't tell the difference. In Go, zero values are real values. A missing key leaves the field alone, which means it stays at its zero value. A present key with a zero value updates the field to that zero value. The result is identical.
This ambiguity breaks APIs that rely on partial updates. You need a way to know whether a key was present in the JSON or absent. Go provides that distinction through pointers.
Pointers distinguish absence
A pointer field can hold a value or it can be nil. nil means the pointer points to nothing. When the JSON decoder encounters a pointer field, it uses nil to signal that no value was provided.
If the JSON key is missing, the decoder skips the field. The pointer remains nil. If the JSON key is present, the decoder allocates memory, stores the value, and sets the pointer to point at it. Now you have three states:
- Pointer is
nil: key was missing or value wasnull. - Pointer points to a value: key was present with data.
- Pointer points to a zero value: key was present with a zero value.
Think of a pointer like a mailbox. An int field is a box that always contains a number, even if that number is 0. A *int field is a mailbox. If the mail carrier delivers a letter, the mailbox has content. If no letter arrives, the mailbox is empty. You can check the mailbox to see if anything was delivered, rather than guessing based on what's inside the box.
Minimal example
Here's the standard pattern: use pointers for fields that might be absent.
package main
import (
"encoding/json"
"fmt"
)
// User holds profile data where age and email might not be sent.
type User struct {
Name string `json:"name"`
Age *int `json:"age"`
Email *string `json:"email"`
}
func main() {
// JSON has name, but age is missing and email is null.
data := `{"name": "Alice", "email": null}`
var u User
err := json.Unmarshal([]byte(data), &u)
if err != nil {
panic(err)
}
// Name is a string, so it's always present in the struct.
fmt.Println("Name:", u.Name)
// Age is nil because the key was missing.
if u.Age == nil {
fmt.Println("Age not provided")
} else {
fmt.Println("Age:", *u.Age)
}
// Email is nil because the value was explicitly null.
if u.Email == nil {
fmt.Println("Email is null or missing")
} else {
fmt.Println("Email:", *u.Email)
}
}
The omitempty struct tag interacts with pointers. If you add omitempty to a pointer field, the encoder omits the key when the pointer is nil. This is useful for PATCH requests where you only want to send fields that have values. Without omitempty, a nil pointer marshals to null.
Convention aside: Go developers often say "don't use pointers for strings" because strings are cheap to pass by value. JSON structs break this rule. Here, pointers represent optionality, not performance. Use *string in structs for optional JSON fields without hesitation.
Pointers track presence. Zero values track data. Don't mix them up.
Decoder walkthrough
When json.Unmarshal runs, it walks the JSON object key by key. For each key, it looks for a matching struct field.
The decoder sees "name": "Alice". It finds the Name field. It's a string. It writes "Alice" into u.Name.
The decoder looks for "age". It doesn't find the key. It skips the field. u.Age remains nil.
The decoder finds "email". The value is null. It sees the Email field is a pointer. It sets u.Email to nil.
Both a missing key and an explicit null result in a nil pointer. The decoder treats them the same way. If your application needs to distinguish between "the client forgot to send the field" and "the client explicitly cleared the field", pointers alone aren't enough. You need to inspect the raw JSON.
A nil pointer tells you the key didn't provide a value. It doesn't tell you why.
Realistic scenario: update versus clear
APIs often use missing keys to mean "don't update" and null to mean "clear the value". For example, a configuration endpoint might accept {"timeout": 30} to set a timeout, {"timeout": null} to reset it to default, and omit timeout entirely to leave it unchanged.
To support this, you need a custom UnmarshalJSON method. The method receives the raw JSON bytes. You can decode those bytes into a map of json.RawMessage values to check key existence without decoding the values themselves.
Here's how to implement the distinction.
// Config tracks whether timeout was omitted or set to null.
type Config struct {
Timeout *int `json:"timeout"`
}
// UnmarshalJSON checks raw JSON to distinguish missing keys from null.
func (c *Config) UnmarshalJSON(data []byte) error {
// Parse into map to check key existence.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Check if key exists.
val, exists := raw["timeout"]
if !exists {
// Key absent: nil means do not update.
c.Timeout = nil
return nil
}
// Key present. Check for explicit null.
if string(val) == "null" {
// Null value: nil means clear the field.
c.Timeout = nil
return nil
}
// Key present with value. Decode integer.
var t int
if err := json.Unmarshal(val, &t); err != nil {
return err
}
c.Timeout = &t
return nil
}
The json.RawMessage type is just an alias for []byte. It tells the decoder to skip decoding and store the raw bytes. This lets you inspect the key presence and the literal value. If the key is missing, the map lookup returns exists = false. If the key is present with null, the raw bytes are the string "null".
Custom unmarshaling gives you full control. Use it when the semantics of absence matter.
Pitfalls and errors
Dereferencing a nil pointer causes a runtime panic. If you write *u.Age without checking u.Age == nil, the program crashes with invalid memory address or nil pointer dereference. Always check for nil before dereferencing.
The omitempty tag can hide nulls. If you marshal a struct with a nil pointer and the field has omitempty, the key is omitted from the output. If you need to send null explicitly, remove omitempty or use a custom marshaler. To send a zero value like 0, use a pointer to a zero value and omit omitempty. A pointer to zero with omitempty is omitted because the value is zero.
Marshaling rules:
nilpointer withomitempty: key omitted.nilpointer withoutomitempty: key withnull.- Pointer to zero with
omitempty: key omitted. - Pointer to zero without
omitempty: key with zero value.
Check for nil before dereferencing. The runtime won't forgive you.
Decision matrix
Use a pointer field when you need to distinguish a missing JSON key from a zero value. Use a value field when the field is always required or a zero value is a valid default. Use json.RawMessage when you need to defer decoding or handle dynamic types. Use a custom UnmarshalJSON method when you must differentiate between a missing key and an explicit null. Use the omitempty tag when you want to skip nil pointers and zero values during marshaling.
Pick the field type that matches your API contract. Pointers track presence; values track data.