Overview
Go's encoding/json package is one of those things that feels clunky for about a week and then feels obvious. The friction comes from the same place Go's friction always comes from: the compiler wants to know the shape of your data, and JSON doesn't have shapes.
Here's how to work with it in practice.
Structs and tags
The normal case is that you know the shape and you write a struct for it:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
var u User
if err := json.Unmarshal(data, &u); err != nil {
return err
}
A few things about tags that trip people up:
- Without a tag, Go matches field names case-insensitively.
Namematches"name","Name", and"NAME". This is convenient right up until you have two fields that differ only in case. omitemptydrops the field on marshal when it's the zero value. For a string that means empty, for an int it means zero. So a field you intentionally set to0disappears. That surprises people writing APIs.-as the tag name means "never touch this field." Use it for computed fields you don't want serialized.
The time.Time problem
time.Time marshals to RFC 3339 by default, which is fine. What isn't fine is that it requires a timezone offset on input. If your API sends "2026-09-18 14:30:00" — no T, no offset — unmarshalling fails.
Two options. Change the producer, which you should do if you control it. Or use a custom type:
type FlexibleTime struct {
time.Time
}
func (t *FlexibleTime) UnmarshalJSON(data []byte) error {
s := strings.Trim(string(data), `"`)
if s == "" || s == "null" {
return nil
}
parsed, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
return err
}
t.Time = parsed
return nil
}
I've written this type in three different codebases. It's a rite of passage.
When you don't know the shape
For webhooks, third-party APIs, and config files, you often can't declare a struct upfront. Use a map:
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
if eventType, ok := payload["type"].(string); ok {
switch eventType {
case "order.created":
// ...
}
}
The type assertion is the annoying part. Every value comes back as any, and you have to check. The one that catches everyone: all numbers become float64. An ID of 1234567890123456789 loses precision. If you're handling large integers, use json.Decoder with UseNumber():
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var payload map[string]any
dec.Decode(&payload)
if n, ok := payload["id"].(json.Number); ok {
id, err := n.Int64()
// ...
}
This is the single most common source of "the ID got mangled" bugs in Go JSON handling.
Streaming with json.Decoder
json.Unmarshal reads the entire input into memory first. For a large response body or a file, decode the stream instead:
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
For arrays where each element should be handled as it arrives:
dec := json.NewDecoder(resp.Body)
// read the opening bracket
if _, err := dec.Token(); err != nil {
return err
}
for dec.More() {
var item Item
if err := dec.Decode(&item); err != nil {
return err
}
process(item)
}
This keeps memory flat regardless of how large the response is, which matters when you're consuming a paginated export of a million records.
Custom marshalling
If a type needs a non-standard representation, implement the interfaces:
type Money struct {
Cents int64
Currency string
}
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"amount": float64(m.Cents) / 100,
"currency": m.Currency,
})
}
func (m *Money) UnmarshalJSON(data []byte) error {
var raw struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
m.Cents = int64(math.Round(raw.Amount * 100))
m.Currency = raw.Currency
return nil
}
Watch the pointer receiver on UnmarshalJSON. If you define it on the value receiver, json.Unmarshal won't find it and you'll get silent, confusing behavior.
Mistakes I keep making
| Mistake | What happens |
|---|---|
| Unexported struct fields | Silently skipped, field stays zero |
Missing & in json.Unmarshal(data, u) | Compile error, which is the good case |
Numbers into any | Become float64, precision loss on large ints |
Ignoring the error from Encode | Half-written response bodies |
| Assuming field order on marshal | Structs serialize in declaration order, maps alphabetically, and neither is guaranteed by the spec |
Is encoding/json fast enough?
For most services, yes. If profiling says JSON is your bottleneck, the usual replacements are github.com/json-iterator/go (drop-in, API-compatible) or github.com/goccy/go-json. Both are meaningfully faster on large payloads. But measure first — I've seen teams swap libraries to fix a problem that was actually a database query.
