JSON in Go: Tags, the Export Rule, and the Quiet float64
encoding/json end to end — Marshal and Unmarshal, struct tags and omitempty, why only exported fields survive, streaming with Encoder and Decoder, and the three silent gotchas: ignored unknown fields, DisallowUnknownFields, and numbers that decode to float64. Compiled and run against Go 1.26.5.
JSON is the lingua franca of the web, and Go’s encoding/json package is how you speak it. The whole package comes down to two functions and their streaming cousins: Marshal turns a Go value into JSON bytes, Unmarshal turns JSON bytes back into a Go value. What makes it feel effortless is reflection — the package inspects your struct at runtime and maps fields to keys for you, so you almost never write serialization code by hand. What makes it occasionally bite is that same magic, because a few of its rules are silent, and silence is exactly where wrong data hides. This chapter runs the happy path and then spends most of its time on the three gotchas worth internalizing. Everything below was compiled and run against Go 1.26.5.
Marshal, Unmarshal, and the export rule
Start with a struct and a round trip:
type Book struct {
Title string `json:"title"`
Author string `json:"author"`
Pages int `json:"pages,omitempty"`
internal string // unexported: never (de)serialized
}
func main() {
b := Book{Title: "Go in Practice", Author: "K. Ada", internal: "secret"}
out, _ := json.Marshal(b)
fmt.Println("marshal:", string(out))
var got Book
input := `{"title":"Decoded","author":"R. Pike","pages":320,"internal":"ignored"}`
json.Unmarshal([]byte(input), &got)
fmt.Printf("unmarshal: %+v\n", got)
fmt.Printf("internal after unmarshal: %q\n", got.internal)
}
marshal: {"title":"Go in Practice","author":"K. Ada"}
unmarshal: {Title:Decoded Author:R. Pike Pages:320 internal:}
internal field after unmarshal: ""
Three things are load-bearing in that output. First, the struct tags (json:"title") rename fields to the exact keys the wire format wants; without a tag, the JSON key defaults to the Go field name, capital and all. Second, pages,omitempty: the marshaled JSON has no "pages" key at all, because the field was the zero value 0 and omitempty drops empty fields. Third, and this is the rule people trip on: the internal field is gone in both directions. It didn’t marshal out, and the "internal":"ignored" in the input didn’t unmarshal in, leaving the zero value "".
The reason is the same capitalization rule that governs everything in Go: encoding/json can only see exported (capitalized) fields, because reflection from another package cannot read unexported ones. This is a genuine footgun. Lowercase a field you meant to serialize and it vanishes without a compile error and without a runtime error. The struct just quietly loses data. If a field belongs on the wire, it starts with a capital letter, full stop.
Note also the &got in the Unmarshal call. Unmarshal writes into your value, so it needs a pointer. Pass a plain struct and it has nothing to fill.
Streaming: Encoder and Decoder
Marshal and Unmarshal work on byte slices, which is fine when you already hold the whole document. But most JSON in a Go program is flowing through an io.Reader or io.Writer — a request body, a response, a file. For those, reach for json.NewEncoder(w) and json.NewDecoder(r), which read and write the stream directly without you materializing the bytes yourself:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in Order
json.NewDecoder(r.Body).Decode(&in) // decode straight from the body
in.Total += 5 // shipping
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(in) // encode straight to the writer
}))
Hitting that server with {"id":"A17","total":30} and decoding the reply:
decoded response: {ID:A17 Total:35}
Decode from the body, encode to the writer, no intermediate []byte. In an HTTP handler this is the idiomatic move, and it’s what the routing chapter’s handlers were quietly reaching for. Encoder.Encode also appends a trailing newline, a small convenience for line-delimited streams.
Nesting and real errors
Nothing about nesting needs special handling. Structs inside structs, slices of structs, slices of strings — reflection walks the whole tree, and the tags apply at every level:
type Catalog struct {
Store string `json:"store"`
Books []Book `json:"books"` // Book has a nested Author struct
Tags []string `json:"tags"`
}
Marshaling a populated Catalog with MarshalIndent produces exactly the tree you’d draw by hand — a "books" array of objects, each with a nested "author" object, and a flat "tags" array of strings. So the pattern for any API payload is simply to model it as Go structs and let the package do the walking.
And unlike the silent gotchas below, a genuinely malformed document is a loud error. Unmarshal something that isn’t valid JSON and you get a real error back, not a half-filled struct:
err := json.Unmarshal([]byte(`{"store":"x","books":[}`), &back)
// -> malformed err: invalid character '}' looking for beginning of value
Which is exactly why the err return from Unmarshal is never one to ignore, even though the examples above elide it for brevity.
Gotcha one: unknown fields are silently ignored
By default, Unmarshal skips any JSON key that has no matching struct field. No error, no warning. Usually that’s what you want — forward compatibility means a server can add fields without breaking old clients. But it also means a typo in the caller’s payload sails through undetected:
input := `{"host":"localhost","port":8080,"portt":9090}`
var c1 Config // has Host and Port fields only
json.Unmarshal([]byte(input), &c1)
// -> default: {Host:localhost Port:8080} (no error, typo swallowed)
var c2 Config
dec := json.NewDecoder(strings.NewReader(input))
dec.DisallowUnknownFields()
err := dec.Decode(&c2)
// -> strict err: json: unknown field "portt"
The "portt" typo means the caller’s 9090 is thrown away and Port keeps 8080, with zero indication anything went wrong. When you’re parsing config or a request body where an unrecognized field signals a real mistake, call DisallowUnknownFields() on a Decoder and it turns that silence into an error naming the offending key. It’s opt-in, and it only exists on the streaming Decoder, not on plain Unmarshal, so this is one more reason to reach for NewDecoder on anything you don’t fully control.
Gotcha two: a JSON number becomes a float64
When you unmarshal into a concrete struct, numbers land in the typed field you declared. But when you unmarshal into an interface{} or a map[string]any — which you do any time the shape is dynamic — every JSON number becomes a float64, regardless of whether it looked like an integer:
input := `{"id":42,"price":19.5,"count":3}`
var m map[string]any
json.Unmarshal([]byte(input), &m)
for _, k := range []string{"id", "price", "count"} {
fmt.Printf("%-6s value=%v Go type=%T\n", k, m[k], m[k])
}
id value=42 Go type=float64
price value=19.5 Go type=float64
count value=3 Go type=float64
42 and 3 are float64, not int. This is the single most common encoding/json surprise. The bug it produces is a panic when you assert the wrong type:
id as float64: 42
recovered from: interface conversion: interface {} is float64, not int
m["id"].(int) panics; m["id"].(float64) is correct. If you genuinely need integers out of dynamic JSON, either unmarshal into a real struct with int fields (the right answer almost always), or call Decoder.UseNumber() so numbers arrive as the exact-precision json.Number string type instead of float64. But the reflex to build is: numbers in a map[string]any are float64, and asserting .(int) on one is a latent panic.
time.Time round-trips as RFC3339
A pleasant one to end the gotchas on: time.Time marshals to and from a string, using the RFC 3339 format, because time.Time implements its own MarshalJSON/UnmarshalJSON. You don’t configure anything:
e := Event{Name: "shipped", At: time.Date(2026, 6, 24, 9, 30, 0, 0, time.UTC)}
out, _ := json.Marshal(e)
// -> {"name":"shipped","at":"2026-06-24T09:30:00Z"}
Unmarshaling that back and comparing with .Equal reports round-trip equal: true. The Z is the UTC zone; a zoned time carries its offset. RFC 3339 is a strict subset of ISO 8601 and is what most JSON APIs expect, so this default plays well with the outside world.
When you need custom rules: MarshalJSON
That time.Time behavior is not special-casing in the package; it’s an interface anyone can implement. If a type defines MarshalJSON() ([]byte, error), encoding/json calls it instead of using reflection, and UnmarshalJSON([]byte) error does the same on the way in. That’s how you make a type serialize as something other than its struct shape — say, money that goes on the wire as "$19.50" rather than {"Cents":1950}:
func (m Money) MarshalJSON() ([]byte, error) {
s := fmt.Sprintf("$%d.%02d", m.Cents/100, m.Cents%100)
return json.Marshal(s) // reuse Marshal to get the quoting right
}
marshaled: {"item":"Go in Practice","price":"$19.50"}
unmarshaled: item="Widget" cents=705
One detail that saves grief: return json.Marshal(s) rather than hand-building the bytes, so the string quoting and escaping are handled for you. And note the receiver types — MarshalJSON on a value receiver, UnmarshalJSON on a pointer receiver, because unmarshaling has to mutate the value.
Final thoughts
encoding/json gives you Marshal/Unmarshal for byte slices and Encoder/Decoder for streams, and reflection maps struct fields to JSON keys through tags like json:"title,omitempty". The one rule that will silently eat your data is that only exported fields are seen, so a lowercased field just disappears. Past that, keep three run-verified facts in your head: unknown JSON keys are ignored unless you call DisallowUnknownFields(); a number decoded into interface{} or map[string]any is a float64, and asserting .(int) on it panics; and time.Time round-trips as RFC 3339 for free. When the default shape isn’t what the wire wants, implement MarshalJSON/UnmarshalJSON and the package steps aside. Now that we can read and write bodies fluently, the next piece is wrapping handlers with reusable behavior.
Next: handlers wrapping handlers — logging, auth, and request-scoped context, built from one small function type.
Comments