Go's encoding/json decides how a struct maps to JSON from each field's name and its tag. The rules are small, but each has an edge that shows up in production: what omitempty counts as empty, how to tell a missing key from a null, and why some fields never fill. The samples ran on Go 1.27; omitzero needs Go 1.24 or newer. This guide covers encoding/json (v1). In the newer encoding/json/v2, omitempty also drops an empty struct and field names match case-sensitively by default.
A tag looks like json:"name,option". Only exported fields, those starting with an uppercase letter, are encoded or decoded; the rest are skipped without a warning. On decoding, a key matches the tag exactly or, failing that, case-insensitively. json:"-" excludes a field entirely.
The JSON to Go converter exports every field and copies the original key into the tag, so renaming the Go field is always safe. A key that starts with a digit, such as 15m, cannot start an identifier, and a name like _15m would be skipped silently (go vet reports "struct field _15m has json tag but is not exported"). The converter names that field X15m:
type Window struct {
_15m int `json:"15m"` // never filled: a leading underscore is not exported
X15m int `json:"15m"` // exported; the original key stays in the tag
}
With omitempty, encoding drops false, 0, a nil pointer or interface, and an empty array, slice, map or string. A struct value is never empty, and neither is a time.Time, so both are still written. Since Go 1.24, omitzero covers that gap: it drops any field whose value is the zero value, and it calls an IsZero() method when the type has one. The program shows each rule:
package main
import (
"encoding/json"
"fmt"
"time"
)
type Address struct {
City string `json:"city,omitempty"`
}
type Profile struct {
Name string `json:"name"`
Age int `json:"age,omitempty"` // 0 is dropped
Nickname *string `json:"nickname,omitempty"` // nil is dropped, "" is kept
Tags []string `json:"tags,omitempty"` // nil and empty are dropped
Address Address `json:"address,omitempty"` // a struct is never "empty"
Joined time.Time `json:"joined,omitzero"` // Go 1.24+: zero values of any type
Score *float64 `json:"score"` // nil encodes as null
}
func main() {
out, _ := json.Marshal(Profile{Name: "Ada"})
fmt.Println(string(out))
empty, zero := "", 0.0
out, _ = json.Marshal(Profile{Name: "Ada", Nickname: &empty, Score: &zero})
fmt.Println(string(out))
}
The first line is {"name":"Ada","address":{},"score":null}. age, nickname, tags and joined are gone, address is an empty object, and score is an explicit null. The second line is {"name":"Ada","nickname":"","address":{},"score":0}: pointers to an empty string and to zero are kept because the pointer is not nil.
The converter's "Add ,omitempty" option is all or nothing. It suits structs you only decode. On a struct you also encode, it drops legitimate zeros and false values, so make those fields pointers or leave the option off.
When decoding, a missing key leaves the field untouched. A JSON null into a plain value field changes nothing, so a value field cannot tell null from 0. A pointer can tell "a value was sent" from "no value", but not a missing key from a null: both leave it nil. The last loop in the next program prints set: false for {} and for {"count": null}, and set: true for {"count": 0}.
If you really must separate absent from null, take the field as json.RawMessage. It stays nil when the key is missing and holds the text null when the key is present with a null value, as the Note field below shows.
Some APIs send numbers as strings. Adding ,string to a numeric or boolean field decodes "42" and writes the value back as a string; a bare 42 is then an error. json.RawMessage keeps a value as unparsed bytes, which suits payloads whose shape depends on a type field:
package main
import (
"encoding/json"
"fmt"
)
type Event struct {
ID int `json:"id,string"` // accepts "42", not 42
Type string `json:"type"`
Data json.RawMessage `json:"data"` // decoded later, once Type is known
Note json.RawMessage `json:"note"`
}
type Click struct{ X, Y int }
type Patch struct {
Count *int `json:"count"`
}
func main() {
input := `{"id": "42", "type": "click", "data": {"X": 3, "Y": 4}, "note": null}`
var e Event
if err := json.Unmarshal([]byte(input), &e); err != nil {
panic(err)
}
var c Click
if err := json.Unmarshal(e.Data, &c); err != nil {
panic(err)
}
fmt.Println(e.ID, e.Type, c, string(e.Note))
for _, s := range []string{`{}`, `{"count": null}`, `{"count": 0}`} {
var p Patch
_ = json.Unmarshal([]byte(s), &p)
fmt.Println(s, "-> set:", p.Count != nil)
}
}
It prints 42 click {3 4} null, followed by the three set: lines described above.
Numbers decoded into interface{}, the type the generator uses for a null-only key and for mixed arrays, become float64. Beyond 2^53 they lose precision: 9007199254740993 comes back as 9007199254740992. Call UseNumber() on a json.Decoder to get a json.Number that keeps the digits, or give the field a real type.
A time.Time field expects RFC 3339 with a zone. The converter uses it only for timestamps ending in Z or an offset such as +02:00. Decoding "2024-05-01T10:30:00" into time.Time fails with cannot parse "" as "Z07:00", so a zone-less timestamp stays a string.
Unknown keys are ignored by default. Call DisallowUnknownFields() on the decoder when a typo in a field name should be an error. One sample cannot show which keys are optional, so decide for each field whether a zero value is meaningful before choosing a value or a pointer.