JSON in Go (Golang)
Marshalling and unmarshalling with encoding/json — struct tags, the exported-field rule, omitempty's sharp edges, and decoding unknown shapes.
Structs First
Go's approach to JSON is shaped by its type system. Where Python hands you a dictionary and JavaScript an object, Go wants a struct that declares in advance what the data looks like. That is more work up front and considerably safer afterwards: the shape is checked at compile time, and reading a field that does not exist is an error rather than an undefined discovered in production.
Everything lives in encoding/json, part of the standard library.
import "encoding/json"
type User struct {
ID int `json:"id"`
Name string `json:"full_name"`
Email string `json:"email,omitempty"`
Roles []string `json:"roles"`
}The backtick strings are struct tags — metadata read by reflection at runtime. Without them, encoding/json matches on the Go field name, so Name would map to a JSON key of "Name". Since most APIs use lowercase or snake_case, tags are effectively mandatory.
The Exported Field Rule
This is the single most common Go JSON bug, and it fails silently in both directions.
type User struct {
ID int `json:"id"`
name string `json:"name"` // lowercase — invisible to encoding/json
}
// Marshal produces {"id":1} — name is simply absent
// Unmarshal leaves name as "" no matter what the JSON containsencoding/json works through reflection, and Go's reflection cannot read or write unexported fields. A field starting with a lowercase letter is therefore invisible to the package — no error, no warning, just a field that never populates. If a struct field is not appearing in your JSON, check its capitalisation first.
Marshalling and Unmarshalling
// Go value → JSON bytes
user := User{ID: 1, Name: "Alice", Roles: []string{"admin"}}
data, err := json.Marshal(user)
// {"id":1,"full_name":"Alice","roles":["admin"]}
// Indented output
data, err := json.MarshalIndent(user, "", " ")
// JSON bytes → Go value. Note the pointer — Unmarshal must be able to write.
var u User
if err := json.Unmarshal(data, &u); err != nil {
log.Fatalf("decode failed: %v", err)
}For streams — HTTP request bodies, files, network connections — the Decoder and Encoder types avoid buffering the whole document:
func handler(w http.ResponseWriter, r *http.Request) {
var input CreateUserRequest
// Reject unknown fields rather than silently ignoring them
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}DisallowUnknownFields() is worth considering on inbound request bodies. The default silently ignores anything it does not recognise, which means a client sending emial instead of email gets a cheerful 200 and no email address stored.
omitempty and the Zero-Value Problem
omitempty drops a field from the output when its value is Go's zero value. That sounds harmless until you consider what counts as zero.
type Settings struct {
Theme string `json:"theme,omitempty"`
Volume int `json:"volume,omitempty"`
Verbose bool `json:"verbose,omitempty"`
}
json.Marshal(Settings{Theme: "dark", Volume: 0, Verbose: false})
// {"theme":"dark"}
//
// volume: 0 and verbose: false have vanished — but the user
// genuinely chose silence and non-verbose mode.omitempty cannot distinguish "not set" from "deliberately set to the zero value". For a boolean, that means false is unrepresentable. When the difference matters — settings, patch requests, anything with a meaningful default — use a pointer:
type Settings struct {
Volume *int `json:"volume,omitempty"`
Verbose *bool `json:"verbose,omitempty"`
}
// nil → field omitted ("not specified")
// pointer to false → "verbose": false ("explicitly off")One more nil-related surprise: a nil slice marshals to null, not []. Clients that iterate the result usually prefer an empty array, so initialise slices you intend to return:
var roles []string
json.Marshal(roles) // null
roles := []string{}
json.Marshal(roles) // []Decoding Unknown Shapes
When you genuinely do not know the structure ahead of time, decode into map[string]interface — and accept that you are now doing type assertions by hand.
var result map[string]interface{}
json.Unmarshal(data, &result)
// Every JSON number is now a float64, whatever it looked like
if id, ok := result["id"].(float64); ok {
fmt.Println(int(id))
}
// Nested access means asserting at every level
if profile, ok := result["profile"].(map[string]interface{}); ok {
if city, ok := profile["city"].(string); ok {
fmt.Println(city)
}
}The float64 conversion is the trap. A 19-digit database ID decoded this way is silently rounded, exactly as it would be in JavaScript. Decoding into a struct with an int64 field preserves it, or you can ask the decoder for exact numbers:
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber() // numbers arrive as json.Number, a string wrapper
// Convert exactly when you need to
n := result["id"].(json.Number)
id, err := n.Int64()If part of a document is well-known and part is arbitrary, json.RawMessage lets you defer decoding of the variable portion until you know which type it should be — useful for envelope formats where a type field determines the shape of payload.
Custom Marshalling
Implement MarshalJSON and UnmarshalJSON to control how a type crosses the boundary. The usual reason is a type JSON has no equivalent for:
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String()) // "1h30m0s"
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(parsed)
return nil
}Note the receivers: MarshalJSON takes a value, UnmarshalJSON takes a pointer because it must modify the receiver. Getting this wrong is a common cause of "my custom unmarshaller is never called".
time.Time already implements both, serialising as RFC 3339 — which is why dates usually work in Go without any effort, in pleasant contrast to Python and Java.
Frequently Asked Questions
Why is my Go struct field empty after Unmarshal?
Almost always because the field is unexported. encoding/json uses reflection and can only see fields beginning with a capital letter. A field named 'name' is invisible to the package; it must be 'Name', with a json tag mapping it to the lowercase key if needed.
What does omitempty actually omit?
Go's zero values: empty strings, 0, false, nil pointers, and nil or empty slices and maps. It does not distinguish 'absent' from 'deliberately zero', so a field with omitempty disappears when set to false or 0. Use a pointer type when that difference matters.
How do I handle JSON whose shape I do not know?
Unmarshal into map[string]interface{} or interface{}. Every number then arrives as float64, every object as another map, so you must type-assert your way down. For anything you will touch more than once, defining a struct is safer and considerably faster.
Why do my large integers lose precision in Go?
Only when you decode into interface{}, because JSON numbers become float64 there and lose exactness beyond 2^53. Decoding into a struct field typed int64 keeps full precision. Alternatively call decoder.UseNumber() to receive json.Number, a string wrapper you can convert exactly.
Should I use Marshal or an Encoder?
Marshal returns a byte slice, which is convenient when you need the bytes. Encoder writes straight to an io.Writer, avoiding an intermediate allocation — better for HTTP handlers and large payloads. Note that Encoder.Encode appends a trailing newline, which Marshal does not.