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, and Encode — 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 omitempty for 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 []byte when working with HTTP handlers — it avoids an extra allocation and copy.
  • When decoding JSON from an untrusted network source, wrap the body in io.LimitReader first to bound how much memory a malicious or buggy client can make you allocate.
  • Decode into a concrete, typed struct rather than map[string]any whenever you know the shape — it’s faster, safer, and avoids the float64-for-every-number surprise.
  • Implement MarshalJSON/UnmarshalJSON on 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 an Encoder if you need the literal characters <, >, & in output rather than their \u00XX escapes.

Practice Exercises

  • Define a Product struct with Name string, PriceCents int, and an optional Description string using omitempty. Marshal a value that leaves Description empty 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 all Score values. Expected output: 35.
  • Write a type Celsius float64 and implement MarshalJSON on it so that marshaling Celsius(20) produces the JSON string "20.0\u00b0C" instead of a bare number. (Hint: your MarshalJSON method should return the result of marshaling a Go string, not the number itself.)

Summary

  • json.Marshal converts a Go value to JSON bytes; json.Unmarshal parses 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.Marshal escapes <, >, and & to \u00XX sequences by default for HTML safety; disable this with Encoder.SetEscapeHTML(false) if you need raw output.
  • json.NewDecoder/json.NewEncoder stream JSON directly over an io.Reader/io.Writer, which is the idiomatic way to handle JSON in HTTP handlers.
  • A type implicitly satisfies json.Marshaler/json.Unmarshaler just by defining MarshalJSON/UnmarshalJSON methods, 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.