JSON Encoding and Decoding
JSON (JavaScript Object Notation) is the standard data format for web APIs, configuration files, and service-to-service communication. Go’s standard library ships a complete JSON toolkit in the encoding/json package, so you never need a third-party dependency just to talk to a REST API or write a config file. This lesson covers converting Go values to JSON and back, shaping that conversion with struct tags, streaming JSON through io.Reader/io.Writer for HTTP handlers, and the mistakes that trip up nearly every Go developer the first time they touch JSON.
Overview: How JSON Encoding Works in Go
The two workhorse functions are json.Marshal, which converts a Go value into a JSON-encoded byte slice, and json.Unmarshal, which parses JSON bytes into a Go value. Both work through reflection: at runtime, encoding/json inspects the type you pass in, walks its fields, and decides how to encode or decode each one. This is slower than a hand-written, code-generated encoder, but it is flexible enough to work with any type without you writing conversion code by hand, and for the vast majority of programs the difference is not measurable.
Reflection can only see what the language makes visible, which leads to the single most important rule in this lesson: only exported struct fields (those starting with an uppercase letter) are encoded or decoded. An unexported field like title string is completely invisible to encoding/json, even though your own package code can read and write it freely. If a field silently never shows up in your JSON output, this is almost always why.
By default, an exported field named Title is encoded using the key "Title", capital letter and all. Since JSON APIs conventionally use lowerCamelCase or snake_case keys, you control the output key with a struct tag: Title string `json:"title"`. Tags can also add options, the most common being omitempty, which drops the field entirely from the output when it holds its zero value (empty string, 0, nil, false, empty slice/map), and -, which excludes the field from JSON entirely regardless of its value.
The JSON and Go type systems don’t map one-to-one, and understanding the mapping avoids surprises. A JSON object maps to a Go struct (or a map[string]any if you don’t know the shape ahead of time); a JSON array maps to a Go slice; a JSON string, number, and bool map to their obvious Go equivalents; and JSON null decodes to the Go zero value of the target type (or a nil pointer/interface/slice/map). One sharp edge: when you decode into an any (for example inside a map[string]any), every JSON number becomes a Go float64, regardless of whether it looked like an integer. Decoding a large integer ID this way can silently lose precision, so decode into a concrete typed struct whenever you can, or use json.Number when you genuinely don’t know the shape in advance.
Another edge worth knowing early: json.Marshal HTML-escapes the characters <, >, and & inside string values by default, turning them into \u003c, \u003e, and \u0026. This exists so JSON can be safely embedded inside an HTML <script> tag without accidentally closing it, but it means the bytes you get back are not always byte-for-byte what you might expect from a naive string concatenation. If you need the raw characters (for example writing a JSON API response, not an HTML page), use json.NewEncoder and call SetEscapeHTML(false) before encoding.
json.Unmarshal requires you to pass a pointer to the destination, never a plain value. Go is call-by-value: if you passed b instead of &b, the decoder would only ever see a copy and have no way to write the parsed data back into your variable, so it returns an error instead.
Finally, you can customize how a specific type is encoded or decoded by implementing the json.Marshaler and json.Unmarshaler interfaces — methods named MarshalJSON() ([]byte, error) and UnmarshalJSON([]byte) error. As with every Go interface, there is no implements keyword to write: any type that happens to define those two methods automatically satisfies the interface, and encoding/json will call them instead of using its default reflection-based logic. This is exactly how time.Time encodes itself as an RFC 3339 string instead of a raw struct.
Syntax
The core API surface is small. These are the functions and struct tag forms you’ll use in almost every program:
| Form | What it does |
|---|---|
json.Marshal(v any) ([]byte, error) |
Encodes a Go value to JSON bytes. |
json.MarshalIndent(v any, prefix, indent string) ([]byte, error) |
Like Marshal, but pretty-prints with the given indentation. |
json.Unmarshal(data []byte, v any) error |
Decodes JSON bytes into v, which must be a non-nil pointer. |
json.NewEncoder(w io.Writer) *Encoder |
Creates an encoder that writes JSON directly to a stream (e.g. an HTTP response). |
json.NewDecoder(r io.Reader) *Decoder |
Creates a decoder that reads JSON directly from a stream (e.g. an HTTP request body). |
`json:"name"` |
Encode/decode this field using the JSON key name instead of the Go field name. |
`json:"name,omitempty"` |
Use key name, and omit the field entirely when it’s the zero value. |
`json:"-"` |
Never include this field in JSON output or input, no matter its value. |
Examples
Example 1: Marshaling a struct
package main
import (
"encoding/json"
"fmt"
)
type Book struct {
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
func main() {
b := Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan", Year: 2015}
data, err := json.Marshal(b)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(data))
}
Output:
{"title":"The Go Programming Language","author":"Donovan \u0026 Kernighan","year":2015}
Each exported field is encoded using its json tag as the key. Notice that the & in "Donovan & Kernighan" came back as \u0026 — that’s the default HTML-safety escaping described above, not a bug.
Example 2: Unmarshaling into a struct
package main
import (
"encoding/json"
"fmt"
)
type Book struct {
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
func main() {
data := []byte(`{"title":"Go in Action","author":"William Kennedy","year":2015}`)
var b Book
if err := json.Unmarshal(data, &b); err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%s by %s (%d)\n", b.Title, b.Author, b.Year)
}
Output:
Go in Action by William Kennedy (2015)
The decoder matches each JSON key against the struct’s tags (case-insensitively as a fallback), converts each value to the field’s Go type, and writes it through the pointer &b.
Example 3: Nested structs, omitempty, and pretty-printing
package main
import (
"encoding/json"
"fmt"
)
type Address struct {
City string `json:"city"`
Country string `json:"country"`
}
type Person struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
Address Address `json:"address"`
Tags []string `json:"tags,omitempty"`
}
func main() {
p := Person{
Name: "Ada Lovelace",
Address: Address{City: "London", Country: "UK"},
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(data))
}
Output:
{
"name": "Ada Lovelace",
"address": {
"city": "London",
"country": "UK"
}
}
Email and Tags were left at their zero values (empty string and nil slice), so omitempty dropped both keys entirely. The nested Address struct is encoded as a nested JSON object automatically — encoding/json recurses into any struct, slice, map, or pointer it finds.
How It Works Step by Step
When you call json.Marshal(v), roughly this happens: Go reflects on the type of v; for a struct, it walks the fields in declaration order, skipping unexported ones; for each exported field it reads the json tag (or falls back to the field name), checks omitempty/- rules, and recursively encodes the field’s value using the same process; primitive values are written directly, with strings HTML-escaped as covered earlier; the accumulated bytes are returned as a single []byte.
json.Unmarshal(data, &v) works in the opposite direction: it parses the raw JSON text into a token stream, and for each key in a JSON object it looks for a matching exported field on the destination struct (by tag first, then by case-insensitive name match), converts the JSON value to that field’s Go type, and writes it in place through the pointer. Any JSON key with no matching field is simply ignored by default — Go does not require your struct to cover every key in the input.
For programs that read or write JSON over a network connection or HTTP body, allocating a full []byte up front is wasteful. json.NewEncoder and json.NewDecoder work directly against any io.Writer/io.Reader, streaming the data instead:
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
)
type Event struct {
Name string `json:"name"`
Code int `json:"code"`
}
func main() {
r := strings.NewReader(`{"name":"login","code":200}`)
dec := json.NewDecoder(r)
var e Event
if err := dec.Decode(&e); err != nil {
fmt.Println("error:", err)
return
}
enc := json.NewEncoder(os.Stdout)
if err := enc.Encode(e); err != nil {
fmt.Println("error:", err)
return
}
}
Output:
{"name":"login","code":200}
Here the decoder reads straight from a strings.Reader (standing in for an HTTP request body), and the encoder writes straight to os.Stdout (standing in for an http.ResponseWriter). In a real HTTP handler you’d write json.NewDecoder(r.Body).Decode(&e) and json.NewEncoder(w).Encode(e) — no intermediate byte slice needed. Note that Encoder.Encode appends a trailing newline after each value, which json.Marshal does not.
Common Mistakes
Mistake 1: Forgetting the pointer (and ignoring the error)
data := []byte(`{"title":"Go","year":2020}`)
var b Book
json.Unmarshal(data, b) // BUG: missing & before b, and the error is discarded
fmt.Println(b)
json.Unmarshal‘s second parameter is typed any, so passing b instead of &b still compiles — it just can’t do anything useful, since it only receives a copy. At runtime it returns an error like json: Unmarshal(non-pointer main.Book), but because the error return was never checked, b silently stays at its zero value and the program keeps running with wrong data. Always pass a pointer, and always check the error:
package main
import (
"encoding/json"
"fmt"
)
type Book struct {
Title string `json:"title"`
Year int `json:"year"`
}
func main() {
data := []byte(`{"title":"Go","year":2020}`)
var b Book
if err := json.Unmarshal(data, &b); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(b)
}
Output:
{Go 2020}
Mistake 2: Unexported fields vanish silently
type Book struct {
title string // unexported: the json package can't see it via reflection
Year int `json:"year"`
}
b := Book{title: "Go", Year: 2020}
data, _ := json.Marshal(b) // error ignored -- a second mistake stacked on the first
fmt.Println(string(data))
Because title starts with a lowercase letter, reflection cannot access it from outside the type’s own methods, so encoding/json simply skips it — no error, no warning, the key just never appears. Combined with discarding the error using _, this bug is very easy to miss in review. Capitalize any field you intend to serialize:
package main
import (
"encoding/json"
"fmt"
)
type Book struct {
Title string `json:"title"`
Year int `json:"year"`
}
func main() {
b := Book{Title: "Go", Year: 2020}
data, err := json.Marshal(b)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(data))
}
Output:
{"title":"Go","year":2020}
Best Practices
- Always check the error returned by
Marshal,Unmarshal,Decode, andEncode— malformed input is common when the data comes from a network client. - Use struct tags to pin down the exact JSON key names your API contract requires; don’t rely on the default (capitalized Go field name) matching what a client sends.
- Use
omitemptyfor genuinely optional fields, but remember it can’t distinguish “absent” from “explicitly zero” — use a pointer field (e.g.*int) when that distinction matters. - Prefer
json.NewDecoder(r.Body)/json.NewEncoder(w)over buffering into a full[]bytewhen working with HTTP handlers — it avoids an extra allocation and copy. - When decoding JSON from an untrusted network source, wrap the body in
io.LimitReaderfirst to bound how much memory a malicious or buggy client can make you allocate. - Decode into a concrete, typed struct rather than
map[string]anywhenever you know the shape — it’s faster, safer, and avoids the float64-for-every-number surprise. - Implement
MarshalJSON/UnmarshalJSONon a type when you need custom formatting (dates in a specific layout, enums encoded as strings, etc.) instead of restructuring your domain type around JSON’s shape. - Call
SetEscapeHTML(false)on anEncoderif you need the literal characters<,>,&in output rather than their\u00XXescapes.
Practice Exercises
- Define a
Productstruct withName string,PriceCents int, and an optionalDescription stringusingomitempty. Marshal a value that leavesDescriptionempty and confirm the key disappears from the output. - Given the JSON text
[{"name":"a","score":10},{"name":"b","score":25}], unmarshal it into a[]struct{ Name string; Score int }-style type and print the sum of allScorevalues. Expected output:35. - Write a type
Celsius float64and implementMarshalJSONon it so that marshalingCelsius(20)produces the JSON string"20.0\u00b0C"instead of a bare number. (Hint: yourMarshalJSONmethod should return the result of marshaling a Go string, not the number itself.)
Summary
json.Marshalconverts a Go value to JSON bytes;json.Unmarshalparses JSON bytes into a Go value passed by pointer.- Only exported struct fields are visible to
encoding/json; struct tags like`json:"name,omitempty"`control the JSON key and whether zero values are omitted. json.Marshalescapes<,>, and&to\u00XXsequences by default for HTML safety; disable this withEncoder.SetEscapeHTML(false)if you need raw output.json.NewDecoder/json.NewEncoderstream JSON directly over anio.Reader/io.Writer, which is the idiomatic way to handle JSON in HTTP handlers.- A type implicitly satisfies
json.Marshaler/json.Unmarshalerjust by definingMarshalJSON/UnmarshalJSONmethods, letting you fully customize encoding for a specific type. - Always check the errors from encoding/decoding calls — silently discarding them, as shown in the Common Mistakes section, hides real bugs like unexported fields or missing pointers.
