Parsing JSON in Go With Struct Tags and the Standard Library
Decode JSON into Go structs with struct tags: null versus missing with pointers, case-insensitive keys, int64 ids, wrong-type errors and the omitempty trap.

Go's encoding/json is the package most Go services use for JSON, and its model is simple: you describe the shape you expect as a struct, and Unmarshal fills it. The struct tags carry the mapping. What the documentation does not make obvious is how many decisions the defaults make for you: how a missing key differs from a null, whether keys match exactly, what happens to a field of the wrong type, and what a number is.
I wrote one program that decodes an order document and pokes at each of those. This article is the output and what to do about it.
The struct is the schema
type Item struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
Price float64 `json:"price"`
}
type Customer struct {
Name string `json:"name"`
Email *string `json:"email"`
}
type Order struct {
ID int64 `json:"id"`
Customer Customer `json:"customer"`
Items []Item `json:"items"`
Status string `json:"status"`
Extra json.RawMessage `json:"extra"`
CreatedAt time.Time `json:"created_at"`
Note string `json:"note,omitempty"`
}var o Order
err := json.Unmarshal(data, &o)The result: err was nil, o.ID was 9007199254740993 exactly, o.Customer.Email was nil, o.Items had two entries, o.Extra held the raw text {"gift": true, "wrap": "blue"} and o.CreatedAt formatted back as 2026-01-02T03:04:05Z. One call handled the nesting, the slice, the timestamp and a field kept as raw bytes for later. The tag names the key, and a key with no tag uses the field name.
json.Marshal(o) wrote the same document back with the members in struct order, note omitted because of omitempty and extra reproduced as it came in. Unexported fields (lowercase) are ignored in both directions, and a tag of json:"-" excludes an exported one.
Null, missing and zero
A Go int or string has a zero value, and the decoder leaves a field alone when the key is absent. So three different inputs look the same to a plain field:
var a struct {
N int `json:"n"`
P *int `json:"p"`
}
json.Unmarshal([]byte(`{}`), &a) // N=0 P=nil
json.Unmarshal([]byte(`{"n":null,"p":null}`), &a) // N=0 P=nilThe pointer was nil for the empty object and for the explicit null. A plain int gave 0 for both. As the package documentation says, a null decoded into a value that is not a pointer, map, slice or interface has no effect, and gives no error. If the difference between "the client did not mention it" and "the client said null" matters, as it does for a PATCH-style update (partial updates and patch semantics), the standard library does not give it to you. Use a wrapper type with its own UnmarshalJSON that records whether it was set, or decode into map[string]json.RawMessage first and look at the keys.
omitempty has the opposite problem on the way out. It leaves out zero values: an int of 0, an empty string, a nil pointer, an empty slice. If 0 is a real value, say qty: 0, omitempty will drop it. Use a pointer for fields where zero is meaningful.
Keys match without regard to case
var c struct{ Status string `json:"status"` }
json.Unmarshal([]byte(`{"STATUS":"shouting"}`), &c)
// c.Status == "shouting"The tag said status and the key was STATUS, and the field was filled. The package prefers an exact match and falls back to a case-insensitive one. This is convenient for sloppy clients and a problem for strict ones: a body with Status and status fills the field with whichever it reads last, and the same payload can mean something different to a service in another language (JSON security mistakes in apps and pipelines has the table of parsers and duplicate keys). If the case must be exact, validate the keys before decoding.
For duplicate keys with identical spelling, Go kept the last: {"role":"user","role":"admin"} decoded to admin.
Wrong types give errors and partial results
var b Order
err := json.Unmarshal([]byte(`{"id":"abc","status":"paid"}`), &b)
// json: cannot unmarshal string into Go struct field Order.id of type int64
// b.Status is still "paid"The error names the field and the types, which is useful. The decode did not stop: Status was filled even though ID failed. A nested mismatch had the full path in the message: Go struct field Item.items.qty of type int. Treat any non-nil error as a rejected document and discard b. Do not use the partially filled value because "most of it parsed."
Syntax errors come out of Unmarshal before it fills anything: a trailing comma gave invalid character '}' looking for beginning of object key string, and two documents in a row gave invalid character '{' after top-level value. For streams of values use a json.Decoder and call Decode repeatedly.
Numbers: the any trap
Decoding into any (or map[string]any) turns every number into float64. The order id has 16 digits, which does not fit in a double:
var n any
json.Unmarshal(data, &n)
n.(map[string]any)["id"] // 9.007199254740992e+15 (float64)The typed int64 field kept 9007199254740993. A Decoder with UseNumber() keeps the digits as a json.Number string:
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
// id=9007199254740993 (json.Number)If you decode into a map to inspect a document, call UseNumber, and convert deliberately. The same rounding affects ids sent to JavaScript clients as numbers, so send large ids as strings in the first place.
Tags worth knowing
The tag syntax is json:"name,option". The options I use:
omitemptyskips the zero value on encode.The
stringoption encodes a number or boolean as a JSON string: a fieldA inttaggeda,stringwith the value 5 produced{"a":"5"}, which suits a client that wants ids as strings.-excludes the field. In my test a field tagged with a dash and an unexporteddwere both missing from the output.inlinedoes not exist in v1. Embedded structs are flattened by default, and an embedded struct's tags apply as if its fields were in the outer one.
For unknown members and strictness see handling unknown JSON fields in Go. If you are generating structs from a sample, the JSON to Go converter writes the tags for you. The package documentation is at pkg.go.dev/encoding/json, and Go's blog post JSON and Go explains the model in more depth.
When a struct is the wrong target
The shape is not fixed. A document whose keys are data (a map of ids to values) decodes into map[string]T. A document with different shapes per type needs a two-step decode: read a type member into a small struct, then decode the same bytes into the struct for that type.
You only need one field. For a large document where you read a single value, a struct with just that field is enough, and the decoder skips everything else. For streaming access, a Decoder with Token avoids holding the document.
You need to preserve everything. A struct drops what it does not name. If you pass documents through and only edit some fields, decode into a map of raw messages, change what you need and write it back, or keep an extras map.
Performance is the limit. Reflection is the cost of this package. Faster third-party decoders exist, and I did not measure them here. Measure your own documents before replacing a standard library package.


