Structs and JSON
Marshal and Unmarshal convert structs to and from JSON; tags rename keys, omitempty drops empty values, unexported fields vanish.
func Marshal(v any) ([]byte, error); func Unmarshal(data []byte, v any) errorJSON is a text format for data, and nearly every web API speaks it. The standard library package encoding/json converts between JSON text and Go values. Marshal goes from Go to JSON. Unmarshal goes from JSON to Go.
The two functions
func Marshal(v any) ([]byte, error). Here any means a value of any type. The result is a []byte, the JSON text as raw bytes, so string(b) prints it. func Unmarshal(data []byte, v any) error. Unmarshal has to write into your variable, so you pass its address with &, as in json.Unmarshal(data, &o). An address is a value that says where something is stored; pointers have their own lesson next. Pass o itself and you get an error back and an untouched struct.
Which fields appear
Only exported fields, the ones starting with a capital letter. A json tag renames the key. omitempty leaves a field out when its value is false, 0, a nil pointer, a nil interface, or an array, slice, map or string of length zero. omitzero, added in Go 1.24, leaves it out when the value is the zero value for its type, using an IsZero() bool method if the type has one. That is what you want for time.Time, which omitempty never omits because a struct is never empty.
What trips people up
An unexported field is invisible: skipped when encoding and left at its zero value when decoding, with no error. Decoding is forgiving too. Keys with no matching field are ignored, fields with no matching key keep their zero value, and matching prefers an exact name but accepts a case-insensitive one, so id, ID and Id all land in the same field. A typo in an incoming payload therefore looks exactly like a missing value.
Example
package main
import (
"encoding/json"
"fmt"
)
type Order struct {
ID int `json:"id"`
Items int `json:"items,omitempty"`
Note string `json:"-"`
total int
}
func main() {
o := Order{ID: 7, Note: "internal", total: 42}
// Items is zero and tagged omitempty, Note is tagged "-",
// and total is unexported, so none of the three appear.
b, err := json.Marshal(o)
if err != nil {
fmt.Println("marshal:", err)
return
}
fmt.Println(string(b))
var got Order
data := []byte(`{"id":9,"items":3,"extra":true}`)
// &got is the address of got, so Unmarshal can write into it.
// "extra" matches no field, so it is ignored.
if err := json.Unmarshal(data, &got); err != nil {
fmt.Println("unmarshal:", err)
return
}
fmt.Printf("%d %d %q %d\n", got.ID, got.Items, got.Note, got.total)
}
// Output:
// {"id":7}
// 9 3 "" 0When to use it
- Turn a struct into the body of an HTTP response.
- Decode a request body or a webhook payload into a typed struct.
- Hide an internal field, such as a password hash, from every encoded response.
- Write a config struct out as readable indented JSON.
More examples
omitempty against omitzero
Both structs hold a time that was never set. omitempty keeps it, because a time.Time is a struct and a struct is never one of the empty values, so the API receives a date in the year 1. omitzero, added in Go 1.24, asks the type whether it is zero and leaves the key out. Use omitzero for any struct-valued optional field; before Go 1.24 the usual workaround was a pointer field, which omitempty does drop when nil.
package main
import (
"encoding/json"
"fmt"
"time"
)
type WithEmpty struct {
Name string `json:"name"`
Date time.Time `json:"date,omitempty"`
}
type WithZero struct {
Name string `json:"name"`
Date time.Time `json:"date,omitzero"`
}
func main() {
a, err := json.Marshal(WithEmpty{Name: "x"})
if err != nil {
fmt.Println(err)
return
}
b, err := json.Marshal(WithZero{Name: "x"})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(a))
fmt.Println(string(b))
}
// Output:
// {"name":"x","date":"0001-01-01T00:00:00Z"}
// {"name":"x"}Nested structs and indented output
Nesting needs no extra work: a struct field becomes a JSON object and a slice of structs becomes an array of objects. MarshalIndent is Marshal plus formatting, useful for a config file or a log line a human will read. Send the compact Marshal output over the wire.
package main
import (
"encoding/json"
"fmt"
)
type Line struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
}
type Basket struct {
ID string `json:"id"`
Lines []Line `json:"lines"`
}
func main() {
// MarshalIndent takes a prefix for every line and one level of indent.
b, err := json.MarshalIndent(Basket{ID: "A1", Lines: []Line{{SKU: "pen", Qty: 2}}}, "", " ")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(b))
}
// Output:
// {
// "id": "A1",
// "lines": [
// {
// "sku": "pen",
// "qty": 2
// }
// ]
// }What a forgiving decode hides
Three surprises in one decode, and no error for any of them. This is why a handler cannot tell a missing field from one sent as zero or an empty string without extra work, and why validating a decoded payload is a separate step rather than something Unmarshal does for you.
package main
import (
"encoding/json"
"fmt"
)
type Reply struct {
Code int `json:"code"`
Body string `json:"body"`
}
func main() {
var r Reply
// "CODE" matches code case-insensitively; "body" is simply absent;
// "extra" matches nothing and is dropped without complaint.
data := []byte(`{"CODE":404,"extra":1}`)
if err := json.Unmarshal(data, &r); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%d %q\n", r.Code, r.Body)
}
// Output:
// 404 ""
Discussion