Working with JSON in Go

JSON is how most programs talk to each other: web APIs, config files, log lines. In this lesson you’ll turn Go structs into JSON and back with the encoding/json package, control the field names with struct tags, handle JSON you don’t know the shape of, process streams of JSON, and reject unexpected fields. At the end I’ll show what the new encoding/json/v2 package in Go 1.27 changes.

From Go to JSON with json.Marshal

json.Marshal takes any Go value and returns its JSON encoding as a []byte:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Book struct {
	Title  string
	Author string
	Pages  int
	Tags   []string
	secret string
}

func main() {
	b := Book{
		Title:  "The Go Programming Language",
		Author: "Donovan and Kernighan",
		Pages:  380,
		Tags:   []string{"go", "programming"},
		secret: "not exported",
	}

	data, err := json.Marshal(b)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}Code language: Go (go)
{"Title":"The Go Programming Language","Author":"Donovan and Kernighan","Pages":380,"Tags":["go","programming"]}Code language: plaintext (plaintext)

Go types map to JSON in the obvious way: structs and maps become objects, slices become arrays, strings become strings, numbers become numbers, bool becomes true/false, and nil becomes null.

Notice that secret is missing. The json package can only see exported fields, the ones that start with a capital letter (see the packages lesson). Lowercase fields are silently skipped, which is a classic “why is my JSON empty?” moment.

For output that humans read, json.MarshalIndent adds line breaks and indentation. The second argument is a prefix for every line (almost always empty), the third is the indentation:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Point struct {
	X, Y int
}

func main() {
	route := map[string][]Point{
		"home": {{0, 0}},
		"work": {{3, 4}, {5, 12}},
	}

	data, err := json.MarshalIndent(route, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}Code language: Go (go)
{
  "home": [
    {
      "X": 0,
      "Y": 0
    }
  ],
  "work": [
    {
      "X": 3,
      "Y": 4
    },
    {
      "X": 5,
      "Y": 12
    }
  ]
}Code language: plaintext (plaintext)

Maps have no order in Go (as you saw in the maps lesson), but encoding/json always writes map keys sorted, so the output is the same every time.

Choose field names with struct tags

JSON APIs usually use lowercase names like "title" or "created_at", while Go fields must start with a capital letter to be exported. A struct tag bridges the gap. It’s a string in backticks after the field type, and the json package reads the part after json::

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	ID       int      `json:"id"`
	Name     string   `json:"name"`
	Email    string   `json:"email,omitempty"`
	Roles    []string `json:"roles,omitempty"`
	Password string   `json:"-"`
}

func main() {
	full := User{ID: 1, Name: "Ana", Email: "[email protected]", Roles: []string{"admin"}, Password: "hunter2"}
	minimal := User{ID: 2, Name: "Ben", Password: "swordfish"}

	a, _ := json.Marshal(full)
	b, _ := json.Marshal(minimal)
	fmt.Println(string(a))
	fmt.Println(string(b))
}Code language: Go (go)
{"id":1,"name":"Ana","email":"[email protected]","roles":["admin"]}
{"id":2,"name":"Ben"}Code language: plaintext (plaintext)
  • json:"id" uses id as the JSON name.
  • ,omitempty leaves the field out when it’s empty: "", 0, false, nil, or a slice or map with no elements. Without it, Ben would get "email":"" and "roles":null.
  • json:"-" means “never include this field”. Use it for things like passwords that must not leak into an API response.

There’s also ,omitzero, which leaves a field out when it has its zero value. The difference shows up with structs: omitempty never considers a struct empty, so a zero time.Time still appears as "0001-01-01T00:00:00Z", while omitzero drops it.

Tags are just strings, and the compiler doesn’t check them. Write json: "id" (with a space) or forget the quotes and the tag is ignored. go vet catches many of these mistakes, which is one more reason to run it.

Struct tags map Go fields to JSON names json.Marshal turns a User struct into a JSON object and json.Unmarshal turns the object back into the struct. The tags decide the JSON names: ID becomes id, Name becomes name, an empty Email is left out because of omitempty, and Password is never included because of the dash tag. go struct ID `json:”id”` Name `json:”name”` Email `json:”email,omitempty”` Password `json:”-“` Marshal Unmarshal json { “id”: 2, “name”: “Ben” } An empty Email is left out (omitempty). Password never appears (the dash tag).
The tags, not the Go field names, decide what the JSON looks like.

From JSON to Go with json.Unmarshal

json.Unmarshal goes the other way. You give it the JSON bytes and a pointer to the value to fill in:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Address struct {
	City    string `json:"city"`
	Country string `json:"country"`
}

type Person struct {
	Name    string   `json:"name"`
	Age     int      `json:"age"`
	Address Address  `json:"address"`
	Pets    []string `json:"pets"`
}

func main() {
	input := []byte(`{
		"name": "Ana",
		"age": 34,
		"address": {"city": "Lisbon", "country": "PT"},
		"pets": ["cat", "fish"],
		"favorite_color": "green"
	}`)

	var p Person
	if err := json.Unmarshal(input, &p); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", p)
	fmt.Println(p.Name, "lives in", p.Address.City)
}Code language: Go (go)
{Name:Ana Age:34 Address:{City:Lisbon Country:PT} Pets:[cat fish]}
Ana lives in LisbonCode language: plaintext (plaintext)

How the matching works:

  • Nested objects fill nested structs, arrays fill slices.
  • JSON fields without a matching struct field (favorite_color) are ignored.
  • Struct fields missing from the JSON keep whatever value they had, which for a fresh variable is the zero value.
  • Names match case-insensitively: "NAME" or "Name" would also fill Name.

Why the pointer? Unmarshal has to change your variable, and a function only gets a copy of what you pass (the pointers section explains why). If you forget the &, go vet stops you:

package main

import (
	"encoding/json"
	"fmt"
)

type Person struct {
	Name string `json:"name"`
}

func main() {
	var p Person
	err := json.Unmarshal([]byte(`{"name":"Ana"}`), p)
	fmt.Println(err, p)
}Code language: Go (go)
call of Unmarshal passes non-pointer as second argumentCode language: plaintext (plaintext)

When the JSON is wrong

Unmarshal returns an error for broken JSON and for values that don’t fit the Go type. Always check it:

package main

import (
	"encoding/json"
	"fmt"
)

type Person struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func main() {
	inputs := []string{
		`{"name": "Ana", "age": 34}`,
		`{"name": "Ana", "age": 34,}`,
		`{"name": "Ana", "age": "thirty-four"}`,
	}
	for _, in := range inputs {
		var p Person
		err := json.Unmarshal([]byte(in), &p)
		fmt.Println(err)
	}
}Code language: Go (go)
<nil>
invalid character '}' looking for beginning of object key string
json: cannot unmarshal string into Go struct field Person.age of type intCode language: plaintext (plaintext)

The second input has a trailing comma, which JSON doesn’t allow. The third has a string where the struct wants an int.

JSON of unknown shape: map[string]any

If you really don’t know what the JSON looks like, you can decode into map[string]any. Every JSON value then becomes one of a few Go types:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

func main() {
	input := []byte(`{"name": "Ana", "age": 34, "admin": true, "pets": ["cat"], "boss": null}`)

	var m map[string]any
	if err := json.Unmarshal(input, &m); err != nil {
		log.Fatal(err)
	}
	for _, key := range []string{"name", "age", "admin", "pets", "boss"} {
		fmt.Printf("%-5s %-15T %v\n", key, m[key], m[key])
	}

	age, ok := m["age"].(float64)
	fmt.Println("next year:", age+1, ok)
}Code language: Go (go)
name  string          Ana
age   float64         34
admin bool            true
pets  []interface {}  [cat]
boss  <nil>           <nil>
next year: 35 trueCode language: plaintext (plaintext)

JSON objects become map[string]any, arrays become []any (printed with its old name []interface {}), strings become string, true/false become bool, null becomes nil, and every number becomes a float64, even 34. To use a value, you need a type assertion like m["age"].(float64), as in the interfaces lesson.

That’s why I reach for a struct whenever I know which fields I need, even if the JSON has many more:

  • A struct documents the shape of the data in one place.
  • The compiler catches typos in field names; with a map, m["nmae"] quietly returns nil.
  • You get real types (int, time.Time, nested structs) without type assertions everywhere.
  • Wrong data shows up as an error from Unmarshal, not as a panic or a silent zero later.

The struct only needs the fields you care about. Use maps for data that really is a map (say, {"en": "Hello", "pt": "Olá"} as a map[string]string) or for tools that work on arbitrary JSON.

Streams: json.Decoder and json.Encoder

Marshal and Unmarshal work on byte slices you already have in memory. When the JSON comes from somewhere you read from (a file, standard input, an HTTP request body) or goes to somewhere you write to, use json.NewDecoder with any io.Reader and json.NewEncoder with any io.Writer.

A decoder can also read several JSON values in a row. Here it reads one event per line (a common log format called JSON Lines) until the input runs out, and an encoder writes a summary to standard output:

package main

import (
	"encoding/json"
	"errors"
	"io"
	"log"
	"os"
	"strings"
)

type Event struct {
	User   string `json:"user"`
	Action string `json:"action"`
}

type Summary struct {
	Events int            `json:"events"`
	ByUser map[string]int `json:"by_user"`
}

func main() {
	input := strings.NewReader(`{"user": "ana", "action": "login"}
{"user": "ben", "action": "login"}
{"user": "ana", "action": "upload"}
`)

	sum := Summary{ByUser: map[string]int{}}
	dec := json.NewDecoder(input)
	for {
		var e Event
		err := dec.Decode(&e)
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			log.Fatal(err)
		}
		sum.Events++
		sum.ByUser[e.User]++
	}

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(sum); err != nil {
		log.Fatal(err)
	}
}Code language: Go (go)
{
  "events": 3,
  "by_user": {
    "ana": 2,
    "ben": 1
  }
}Code language: plaintext (plaintext)

Decode returns io.EOF when there’s nothing left, which is how the loop knows to stop. Encode writes the JSON followed by a newline, straight to the writer, with no []byte in between. In the HTTP server lesson you’ll use exactly these two to read requests and write responses.

Reject unknown fields

Ignoring extra fields is usually what you want for data from other services: they can add fields without breaking you. For input that people write by hand, such as a config file, it’s the opposite. A typo like "prot" instead of "port" would be ignored, and your program would quietly use the default port. A decoder can refuse such input with DisallowUnknownFields:

package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type Config struct {
	Host string `json:"host"`
	Port int    `json:"port"`
}

func parseConfig(s string) (Config, error) {
	var c Config
	dec := json.NewDecoder(strings.NewReader(s))
	dec.DisallowUnknownFields()
	err := dec.Decode(&c)
	return c, err
}

func main() {
	c, err := parseConfig(`{"host": "localhost", "port": 8080}`)
	fmt.Println(c, err)

	c, err = parseConfig(`{"host": "localhost", "prot": 8080}`)
	fmt.Println(c, err)
}Code language: Go (go)
{localhost 8080} <nil>
{localhost 0} json: unknown field "prot"Code language: plaintext (plaintext)

To use this on a []byte, wrap it with bytes.NewReader(data). json.Unmarshal itself has no such switch.

JSON v2 in Go 1.27

According to the Go 1.27 release notes, Go 1.27 adds two new packages: encoding/json/v2, a major revision of the JSON API, and encoding/json/jsontext for lower-level work with JSON tokens. They had been available as an experiment before and are now on by default. Two things matter for you right now:

  • Nothing in this lesson changes. encoding/json keeps its API and behavior, and you don’t have to migrate. Under the hood it now runs on the new implementation (the release notes say unmarshaling got significantly faster); only the exact wording of some error messages may differ from older Go versions.
  • v2 has stricter defaults. Among other things, it matches field names case-sensitively, encodes a nil slice or map as [] or {} instead of null, and rejects invalid UTF-8 and duplicate names in an object.

The v2 functions take options as extra arguments instead of using methods on a decoder. Here’s the same kind of work with v2:

package main

import (
	"encoding/json/jsontext"
	"encoding/json/v2"
	"fmt"
)

type User struct {
	Name  string   `json:"name"`
	Roles []string `json:"roles"`
}

func main() {
	data, _ := json.Marshal(User{Name: "Ana"})
	fmt.Println(string(data))

	data, _ = json.Marshal(User{Name: "Ana"}, jsontext.WithIndent("  "))
	fmt.Println(string(data))

	var u User
	err := json.Unmarshal([]byte(`{"Name": "Ben"}`), &u)
	fmt.Printf("%q %v\n", u.Name, err)

	err = json.Unmarshal([]byte(`{"name": "Ben", "prot": 1}`), &u, json.RejectUnknownMembers(true))
	fmt.Println(err)
}Code language: Go (go)
{"name":"Ana","roles":[]}
{
  "name": "Ana",
  "roles": []
}
"" <nil>
json: cannot unmarshal JSON string into Go main.User: unknown object member name "prot"Code language: plaintext (plaintext)

The nil Roles slice came out as [], "Name" didn’t match the name tag, and RejectUnknownMembers does what DisallowUnknownFields did. The import path ends in /v2, but the package is still called json, so you can’t import both versions in one file without renaming one of them.

My advice for now: learn and use encoding/json, which is what almost all existing code and documentation uses. Try v2 in new code when its defaults help you. The Go blog post on JSON v2 explains the reasoning behind the changes.

If your program behaves differently with Go 1.27’s JSON implementation, the release notes describe an escape hatch: build with GOEXPERIMENT=nojsonv2 to get the old implementation back. It’s expected to go away in a future release, so treat it as a temporary fix and report the problem.

Practice

1. Shape an API response. Define a Product struct with an ID (JSON id), Name (name), Price in cents (price_cents), an optional Discount (discount, left out when zero) and an internal Cost that must never appear in JSON. Marshal a slice with two products, one with a discount and one without, using two-space indentation.

Show a solution
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Product struct {
	ID       int    `json:"id"`
	Name     string `json:"name"`
	Price    int    `json:"price_cents"`
	Discount int    `json:"discount,omitempty"`
	Cost     int    `json:"-"`
}

func main() {
	products := []Product{
		{ID: 1, Name: "Mug", Price: 1200, Discount: 200, Cost: 400},
		{ID: 2, Name: "T-shirt", Price: 2500, Cost: 900},
	}
	data, err := json.MarshalIndent(products, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}Code language: Go (go)
[
  {
    "id": 1,
    "name": "Mug",
    "price_cents": 1200,
    "discount": 200
  },
  {
    "id": 2,
    "name": "T-shirt",
    "price_cents": 2500
  }
]Code language: plaintext (plaintext)

2. Total an order. Decode this JSON into structs and print the total in dollars: {"customer": "Ana", "items": [{"name": "Mug", "qty": 2, "price_cents": 1200}, {"name": "Tea", "qty": 3, "price_cents": 450}]}. Make the decoder reject unknown fields, then add a field "coupon": "SAVE10" to the input and check that you get an error.

Show a solution
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type Item struct {
	Name  string `json:"name"`
	Qty   int    `json:"qty"`
	Price int    `json:"price_cents"`
}

type Order struct {
	Customer string `json:"customer"`
	Items    []Item `json:"items"`
}

func total(input string) (int, error) {
	var o Order
	dec := json.NewDecoder(strings.NewReader(input))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&o); err != nil {
		return 0, err
	}
	sum := 0
	for _, it := range o.Items {
		sum += it.Qty * it.Price
	}
	return sum, nil
}

func main() {
	input := `{"customer": "Ana", "items": [
		{"name": "Mug", "qty": 2, "price_cents": 1200},
		{"name": "Tea", "qty": 3, "price_cents": 450}]}`

	cents, err := total(input)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("total: $%d.%02d\n", cents/100, cents%100)

	_, err = total(`{"customer": "Ana", "coupon": "SAVE10", "items": []}`)
	fmt.Println("error:", err)
}Code language: Go (go)
total: $37.50
error: json: unknown field "coupon"Code language: plaintext (plaintext)

Key takeaways

  • json.Marshal/MarshalIndent turn Go values into JSON; json.Unmarshal fills a value through a pointer.
  • Only exported fields are encoded. Struct tags set the JSON name, omitempty drops empty values, "-" hides a field.
  • Decode into a struct whenever you know the fields; map[string]any turns every number into float64 and needs type assertions.
  • Use json.NewDecoder and json.NewEncoder for readers and writers, and DisallowUnknownFields to catch typos in hand-written input.
  • Go 1.27 adds encoding/json/v2 with stricter defaults; encoding/json keeps working as before.