8.4 encoding/json — Junior¶
Audience. You've called
json.Marshalandjson.Unmarshalalready, but the rules around struct tags, the float64-for-numbers trap, and what counts as "empty" have bitten you at least once. By the end of this file you'll know the four functions you actually use, the tag grammar that controls naming and omission, and the type mapping in both directions — well enough to debug 90% of "why is this field empty" tickets without re-reading the docs.
1. The four functions you'll use every week¶
import "encoding/json"
// Marshal: Go value -> JSON bytes
b, err := json.Marshal(v)
// Unmarshal: JSON bytes -> Go value (pass a pointer)
err = json.Unmarshal(b, &v)
// MarshalIndent: pretty-printed Marshal
b, err = json.MarshalIndent(v, "", " ")
// Streaming: Encoder/Decoder around io.Writer/io.Reader
enc := json.NewEncoder(w)
dec := json.NewDecoder(r)
Marshal and Unmarshal are the bread and butter. Use them on small payloads — config files, single API responses, fixtures. Use the Encoder/Decoder pair when bytes flow over a Reader/Writer (HTTP, file, pipe). The streaming pair stays flat in memory; the batch pair allocates the whole document.
A first round-trip:
package main
import (
"encoding/json"
"fmt"
)
type User struct {
ID int `json:"id"`
Email string `json:"email"`
Admin bool `json:"admin"`
}
func main() {
u := User{ID: 7, Email: "a@b.com", Admin: true}
b, err := json.Marshal(u)
if err != nil { panic(err) }
fmt.Println(string(b))
// {"id":7,"email":"a@b.com","admin":true}
var back User
if err := json.Unmarshal(b, &back); err != nil { panic(err) }
fmt.Printf("%+v\n", back)
// {ID:7 Email:a@b.com Admin:true}
}
Two things to internalize from this example:
Unmarshalrequires a pointer. It modifies the value at that pointer in place. A non-pointer is rejected withjson.InvalidUnmarshalError: json: Unmarshal(non-pointer User).- Field names need struct tags or capitalization.
encoding/jsononly sees exported fields (uppercase first letter). Without a tag, the JSON name defaults to the Go name as-is —IDbecomes"ID"in the JSON. Almost always you want the tag.
2. Struct tags: the small grammar that runs everything¶
The json tag is a short DSL inside a backtick-quoted string literal:
type Item struct {
Name string `json:"name"`
Count int `json:"count,omitempty"`
Internal string `json:"-"`
Raw string `json:",string"`
}
The grammar:
| Form | Effect |
|---|---|
json:"name" | Use "name" as the JSON key |
json:",omitempty" | Default the name (Go field name), but omit if zero |
json:"-" | Never marshal or unmarshal this field |
json:"-," | The literal name "-" (note the trailing comma) |
json:"name,omitempty" | Both: use "name" and omit if zero |
json:"name,string" | Encode the value as a JSON string (only for primitives) |
json:",omitempty,string" | Combine multiple options after the name |
A blank name (json:",omitempty") keeps the Go field name. A - name skips the field entirely.
omitempty — what counts as "empty"¶
omitempty omits the field if its value is the zero value of its Go type:
| Type | Zero value | Omitted? |
|---|---|---|
int, int64, float64 | 0 | yes |
string | "" | yes |
bool | false | yes |
*T (any pointer) | nil | yes |
[]T (slice) | nil | yes |
[]T{} (empty slice) | not nil | no |
map[K]V | nil | yes |
map[K]V{} (empty map) | not nil | no |
time.Time | the zero time 0001-01-01T00:00:00Z | no — see below |
struct{...} | every field zero | no |
The two big traps:
type Bag struct {
Tags []string `json:"tags,omitempty"`
}
a := Bag{Tags: nil} // {} — omitted
b := Bag{Tags: []string{}} // {"tags":[]} — present!
type Event struct {
At time.Time `json:"at,omitempty"`
}
fmt.Println(json.Marshal(Event{})) // {"at":"0001-01-01T00:00:00Z"}
time.Time{} is not empty to omitempty because it's a struct, and omitempty's notion of "empty" only applies to scalars, slices, maps, pointers, and interfaces. Use *time.Time if you want truly optional timestamps:
Now nil becomes "omitted" and any non-nil pointer is encoded.
,string — encode primitives as JSON strings¶
Some clients (notably JavaScript, where Number is a 64-bit float) can't safely round-trip int64 values larger than 2^53. The ,string option asks Marshal to wrap the field in a JSON string:
type Account struct {
Balance int64 `json:"balance,string"`
}
b, _ := json.Marshal(Account{Balance: 9007199254740993})
// {"balance":"9007199254740993"}
var back Account
json.Unmarshal(b, &back)
fmt.Println(back.Balance) // 9007199254740993
,string works on numeric types, bool, and string (where it double-quotes — rarely useful). On unmarshal, the JSON value must be a string containing the encoded literal; a JSON number triggers UnmarshalTypeError.
- vs -,¶
type Secret struct {
Token string `json:"-"` // never serialized
Literal string `json:"-,"` // serialized as the key "-"
}
The trailing comma flips it from "skip" to "use the literal name". You'll almost never write json:"-," — but if you ever wonder why a field named "-" shows up in your JSON, this is why.
3. The type mapping, in both directions¶
Marshal walks the value via reflection and maps Go types to JSON types like this:
| Go type | JSON form |
|---|---|
bool | true/false |
int*, uint*, float* | number |
string | string (UTF-8) |
[]byte | base64-encoded string (RFC 4648, std encoding) |
nil, nil pointer, nil interface, nil slice, nil map | null |
[N]T, []T | array |
map[K]V | object (keys must be strings or implement TextMarshaler) |
struct{...} | object (per-field rules apply) |
interface{} holding any of the above | the dynamic value's mapping |
Type implementing json.Marshaler | whatever its MarshalJSON returns |
Type implementing encoding.TextMarshaler (and not Marshaler) | string |
Unmarshal is the inverse, but with a critical asymmetry when the target is interface{}. Decoding into a typed target works as you'd expect:
| JSON form | Go target | Result |
|---|---|---|
| number | int, int64, float64 | parsed |
| string | string | copied |
true/false | bool | parsed |
null | pointer/slice/map/interface | becomes nil |
| array | []T, [N]T | element-by-element |
| object | struct{...}, map[string]T | field-by-field or key-by-key |
Decoding into interface{} (or any) is where the trap lives:
| JSON form | Default Go type when target is interface{} |
|---|---|
null | nil |
true/false | bool |
| number | float64 |
| string | string |
| array | []interface{} |
| object | map[string]interface{} |
That float64 is the trap. Decode the JSON 9007199254740993 into interface{} and you get a float64 whose nearest representable value is 9007199254740992. The integer is silently corrupted.
var v any
json.Unmarshal([]byte(`9007199254740993`), &v)
fmt.Printf("%T %v\n", v, v)
// float64 9.007199254740992e+15 -- off by one!
Three fixes, in order of preference:
- Decode into a typed target (
int64, a struct field ofint64). The decoder parses the literal directly into the target. - Use
Decoder.UseNumber()to keep numbers asjson.Number(a string) until you decide how to convert them. - Wire the producer to send strings (
,stringtag on the other side, or a custom marshaler).
4. Decoding into a struct vs into a map¶
When you control the schema, decode into a struct. The decoder ignores unknown JSON fields by default (we'll see how to flip that in middle.md), and missing JSON fields leave the Go field at its zero value:
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
src := []byte(`{"host":"db","port":5432,"extra":"ignored"}`)
var c Config
json.Unmarshal(src, &c)
// c == {Host:"db", Port:5432}, "extra" silently dropped
When you don't know the schema (a logging endpoint that accepts any JSON, a proxy that forwards arbitrary payloads), decode into map[string]any:
var m map[string]any
json.Unmarshal(src, &m)
// m == map[string]interface{}{"host":"db","port":float64(5432),"extra":"ignored"}
Note the float64 again. If precision matters, see Decoder.UseNumber in middle.md, or decode the field you care about into a typed intermediate.
5. Pretty printing: MarshalIndent¶
b, _ := json.MarshalIndent(map[string]int{"a": 1, "b": 2}, "", " ")
fmt.Println(string(b))
// {
// "a": 1,
// "b": 2
// }
Two arguments: the prefix prepended to every line (almost always ""), and the per-level indent string (almost always " " or "\t"). There is no separate "compact" call — Marshal already produces the compact form. To re-indent existing JSON without parsing into a struct, use json.Indent (writes into a bytes.Buffer) or json.Compact (the inverse).
json.Indent and json.Compact work on raw bytes and don't allocate intermediate Go values. They're the right tool for "log this JSON nicely" without paying the cost of unmarshal+marshal.
6. []byte is base64 (and only base64)¶
A trap to know early:
type Blob struct {
Data []byte `json:"data"`
}
b, _ := json.Marshal(Blob{Data: []byte("hello")})
fmt.Println(string(b))
// {"data":"aGVsbG8="}
encoding/json treats []byte as binary data and base64-encodes it. If you wanted the literal string "hello", use string instead of []byte. If you wanted a JSON array of byte values, use []int.
Decoding goes the other way — a JSON string is base64-decoded into the []byte. If the producer writes raw bytes as a string and expects them through, you're going to disagree.
// Wrong: gives {"raw":"aGVsbG8="}
type X struct { Raw []byte `json:"raw"` }
// Right: gives {"raw":"hello"}
type X struct { Raw string `json:"raw"` }
This is the only place encoding/json does a non-obvious type conversion. Everywhere else, what you see is what you get.
7. Pointers: nil vs zero value¶
type Maybe struct {
Name *string `json:"name"`
Age *int `json:"age"`
}
s := "alex"
n := 30
a, _ := json.Marshal(Maybe{Name: &s, Age: &n})
// {"name":"alex","age":30}
b, _ := json.Marshal(Maybe{})
// {"name":null,"age":null}
c, _ := json.Marshal(Maybe{Name: &s})
// {"name":"alex","age":null}
Pointers carry the "is this field set?" bit that scalars cannot carry by themselves. A *int that's nil becomes null (or absent with omitempty); a *int pointing at 0 becomes 0. With a plain int, you can't tell those two cases apart.
This pattern matters most for partial updates (PATCH-style APIs):
type UpdateUser struct {
Email *string `json:"email,omitempty"`
Admin *bool `json:"admin,omitempty"`
}
// PATCH body: {"admin":false}
// Distinguishing "set Admin to false" from "don't change Admin"
// requires *bool, not bool.
A request that omits "admin" decodes to Admin: nil — leave it alone. A request that sends "admin":false decodes to a non-nil pointer to false — apply the change.
8. Slices, maps, arrays¶
Slices and arrays both round-trip through JSON arrays:
b, _ := json.Marshal([]int{1, 2, 3}) // [1,2,3]
b, _ := json.Marshal([3]int{1, 2, 3}) // [1,2,3]
var s []int
json.Unmarshal([]byte(`[1,2,3]`), &s) // s = [1 2 3]
var a [3]int
json.Unmarshal([]byte(`[1,2,3]`), &a) // a = [1 2 3]
json.Unmarshal([]byte(`[1,2,3,4]`), &a) // a = [1 2 3], extra dropped silently
json.Unmarshal([]byte(`[1,2]`), &a) // a = [1 2 0], short padded with zeros
Maps round-trip through JSON objects, but the key type matters:
b, _ := json.Marshal(map[string]int{"a": 1}) // {"a":1}
b, _ := json.Marshal(map[int]int{1: 2}) // {"1":2} — keys converted to strings
// This fails to marshal:
type Pair struct{ X, Y int }
b, err := json.Marshal(map[Pair]int{{1, 2}: 3})
// json: unsupported type: map[main.Pair]int
JSON object keys are always strings. encoding/json accepts maps keyed by:
string- Any integer type
- Any type implementing
encoding.TextMarshaler(and the same for unmarshal viaTextUnmarshaler)
We'll come back to TextMarshaler in middle.md. For now: if your map key is a custom type, give it MarshalText/UnmarshalText methods or convert to/from string at the boundary.
9. Nested structs and embedding¶
Nested structs become nested JSON objects:
type Address struct {
City string `json:"city"`
Zip string `json:"zip"`
}
type Person struct {
Name string `json:"name"`
Address Address `json:"address"`
}
b, _ := json.Marshal(Person{Name: "x", Address: Address{City: "NYC", Zip: "10001"}})
// {"name":"x","address":{"city":"NYC","zip":"10001"}}
Embedded (anonymous) struct fields promote their fields into the parent's JSON object — there's no nesting:
type Audit struct {
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type Doc struct {
ID string `json:"id"`
Audit // embedded, no tag
}
b, _ := json.Marshal(Doc{ID: "doc-1"})
// {"id":"doc-1","created":"0001-...","updated":"0001-..."}
Audit's fields appear at the top level of Doc's JSON. This is the JSON equivalent of Go's struct embedding — useful for sharing common fields like timestamps, version numbers, or trace IDs across many types.
If two embedded structs have a field with the same name, the rule is covered in senior.md. For now, avoid name collisions in embeds.
10. Errors you'll meet on day one¶
// Bad JSON
err := json.Unmarshal([]byte(`{not json}`), &v)
// *json.SyntaxError: invalid character 'n' looking for beginning of object key string
// Wrong target type for the JSON value
err := json.Unmarshal([]byte(`"hello"`), &intVal)
// *json.UnmarshalTypeError: json: cannot unmarshal string into Go value of type int
// Forgot to pass a pointer
err := json.Unmarshal([]byte(`{}`), v) // v is User, not *User
// *json.InvalidUnmarshalError: json: Unmarshal(non-pointer main.User)
// Cycle through pointers (will panic, not error)
type Node struct{ Next *Node }
n := &Node{}
n.Next = n
json.Marshal(n) // panics with "json: unsupported value: encountered a cycle..."
The three error types you'll see most:
| Error type | Meaning |
|---|---|
*json.SyntaxError | The bytes aren't valid JSON. Offset points to where parsing failed. |
*json.UnmarshalTypeError | JSON is valid but doesn't fit the Go target. Field says where. |
*json.InvalidUnmarshalError | You passed a non-pointer or a nil pointer to Unmarshal. |
Type-switch on them when you want to give the user a precise diagnostic:
if err := json.Unmarshal(data, &cfg); err != nil {
var se *json.SyntaxError
var ue *json.UnmarshalTypeError
switch {
case errors.As(err, &se):
return fmt.Errorf("invalid JSON at byte %d: %w", se.Offset, err)
case errors.As(err, &ue):
return fmt.Errorf("field %q: expected %v, got %s", ue.Field, ue.Type, ue.Value)
default:
return err
}
}
UnmarshalTypeError.Field is empty when the mismatch is at the root (e.g., the whole JSON is a number but you passed a struct). When non-empty, it's a dotted path like "users.0.email".
11. A first custom marshaler¶
Sometimes the default mapping isn't what you want. You can implement json.Marshaler and json.Unmarshaler to override the behavior for a specific type. The interfaces:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
A common case: a custom enum that you want as a string in JSON but as an int in memory:
type Status int
const (
StatusUnknown Status = iota
StatusPending
StatusActive
StatusClosed
)
var statusNames = [...]string{"unknown", "pending", "active", "closed"}
func (s Status) MarshalJSON() ([]byte, error) {
if int(s) < 0 || int(s) >= len(statusNames) {
return nil, fmt.Errorf("invalid status %d", s)
}
return json.Marshal(statusNames[s])
}
func (s *Status) UnmarshalJSON(b []byte) error {
var name string
if err := json.Unmarshal(b, &name); err != nil {
return err
}
for i, n := range statusNames {
if n == name {
*s = Status(i)
return nil
}
}
return fmt.Errorf("unknown status %q", name)
}
Three details:
MarshalJSONis on the value receiver,UnmarshalJSONis on the pointer receiver —Unmarshalneeds to mutate the target.MarshalJSONreturns the complete JSON value, not a string to embed. Returning[]byte("active")produces invalid JSON. Returning[]byte("\"active\"")(or, easier,json.Marshal("active")) produces a JSON string.UnmarshalJSONreceives the raw bytes for the JSON value. If the JSON is"active", you get[]byte("active")(with quotes). Don't trim them by hand — calljson.Unmarshal(b, &name)and let the package handle escaping.
We'll cover the more advanced patterns (composing your custom marshaler with the default for nested fields, validating during unmarshal, the RawMessage type) in middle.md.
12. A first custom unmarshaler: lenient input¶
Real-world JSON is full of fields that are sometimes strings and sometimes numbers, sometimes scalars and sometimes arrays. The classic example is "accept either string or number":
type FlexInt int
func (f *FlexInt) UnmarshalJSON(b []byte) error {
// Strip surrounding quotes if present.
if len(b) >= 2 && b[0] == '"' && b[len(b)-1] == '"' {
b = b[1 : len(b)-1]
}
n, err := strconv.Atoi(string(b))
if err != nil {
return fmt.Errorf("FlexInt: %w", err)
}
*f = FlexInt(n)
return nil
}
Now both 42 and "42" decode into the same value. Real implementations should also handle null, leading/trailing whitespace, and the empty-string case — find-bug.md drills into the details.
13. HTTP request and response: the day-one shape¶
Every HTTP-handling Go program ends up with this loop:
func handler(w http.ResponseWriter, r *http.Request) {
var req UpdateUser
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp, err := updateUser(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Two subtleties already at play:
- Always set
Content-Type— the standard library doesn't infer it for you. Without it, browsers and clients may misinterpret the payload. Encoder.Encodewrites a trailing newline to the underlying writer. That's almost always fine for HTTP responses; if you're piping into a strict consumer, strip it.
Production handlers add request-body size limits (http.MaxBytesReader), unknown-field rejection (Decoder.DisallowUnknownFields), and structured error responses. We cover them in middle.md and professional.md.
14. Reading a config file¶
type Config struct {
ListenAddr string `json:"listen_addr"`
Timeout time.Duration `json:"timeout"` // surprise: see below
DB struct {
URL string `json:"url"`
MaxConn int `json:"max_conn"`
} `json:"db"`
}
func loadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var c Config
if err := json.NewDecoder(f).Decode(&c); err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return &c, nil
}
time.Duration is an int64 underneath, so JSON has to send it as a number of nanoseconds (5000000000 for 5 seconds) — almost certainly not what you want for a config file. The fix is a custom marshaler on a wrapper type, or a string field plus a time.ParseDuration call after decode. We'll write it in middle.md.
15. Writing JSON to disk¶
func saveConfig(path string, c *Config) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(c); err != nil {
return err
}
return f.Close()
}
Two things to copy from this:
Encoder.SetIndentturns on pretty-printing for the streaming case. Same arguments asMarshalIndent.- Check the error from
Closewhen you're writing data. If the OS reports a delayed write error (out of disk, network filesystem hiccup), it surfaces inClose, not inEncode.
For atomic, crash-safe writes — write-to-temp, fsync, rename — see the pattern in 01-io-and-file-handling/middle.md.
16. The two JSON-string-or-bytes-buffer flows¶
When you build a payload in pieces and want to send it, you have two shapes:
// Build into a buffer, then write once.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.Encode(v)
http.Post(url, "application/json", &buf)
// Pipe straight into the HTTP body, no buffer.
pr, pw := io.Pipe()
go func() {
defer pw.Close()
json.NewEncoder(pw).Encode(v)
}()
http.Post(url, "application/json", pr)
For payloads that fit comfortably in memory, the buffer form is simpler. For huge payloads (a million-record export), the pipe form keeps memory flat. We cover the pipe pattern in middle.md when we get to streaming NDJSON.
17. The "always include this field" pattern¶
Sometimes you want the JSON to always include a field, even when it's zero. The default behavior (no omitempty) gives you that:
type Result struct {
Items []Item `json:"items"`
Total int `json:"total"` // always present, even if 0
}
b, _ := json.Marshal(Result{})
// {"items":null,"total":0}
But notice "items":null — a nil slice becomes null, not []. Many JS clients prefer [] to null (it lets them call .map() on the result without checking). The fix is to initialize the slice:
Or use a custom marshaler that emits [] for nil, but the explicit init is simpler and obvious.
18. Booleans and the missing-vs-false case¶
A bool field has the same problem as an int: zero (false) and "missing" can't be distinguished. If your API needs to know whether the client sent false or didn't send the field at all, use *bool:
Now:
{}→Settings{DarkMode: nil}(don't change){"dark_mode":false}→Settings{DarkMode: &false}(turn off){"dark_mode":true}→Settings{DarkMode: &true}(turn on)
Take the literal pointer in tests with a tiny helper:
19. Numbers: use float64 only when you mean it¶
Go's int/int64 map to JSON numbers, and JSON has only one "number" type — there's no integer/float distinction in the syntax. If you decode 42 into a float64, you get 42.0. If you decode 42.5 into an int, you get *json.UnmarshalTypeError.
var i int
json.Unmarshal([]byte(`42.0`), &i) // ok, 42
json.Unmarshal([]byte(`42.5`), &i) // UnmarshalTypeError
var f float64
json.Unmarshal([]byte(`42`), &f) // ok, 42.0
So:
- Decode into
int/int64only when you know the source sends integers. If42.0would be acceptable but42.5should error, this is what you want. - Decode into
float64for anything that might have a fractional part. Be aware of precision loss past 2^53. - Decode into
json.Number(covered in middle.md) when you need to defer the type decision until you've seen the value.
20. A quick table of "what tag do I want"¶
| You want… | Tag |
|---|---|
Plain field with the JSON name created_at | `json:"created_at"` |
| Optional — omit when zero | `json:"created_at,omitempty"` |
| Never serialize | `json:"-"` |
| Big integer that JS can read | `json:"id,string"` |
| Server-only field, never accept from the wire | use json:"-" and a separate request DTO |
Field name is literally "-" | `json:"-,"` |
| Embed parent fields into this object's JSON | embed the type, no tag (or json:",inline" is not supported in v1) |
A note on the last row: Go's encoding/json does not support a ,inline option. Embedding gives you flat JSON for free; if you want flat output without embedding, you have to write a custom marshaler.
21. What to read next¶
- middle.md —
RawMessage, custom marshalers in depth, streaming, NDJSON, polymorphic types,Decoder.DisallowUnknownFields. - senior.md — exact tag semantics, embedded-struct field resolution,
json.Number, escaping, cycles. - find-bug.md — drills on the traps in this file.
- The official package docs:
encoding/json.