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.
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"usesidas the JSON name.,omitemptyleaves 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.
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 fillName.
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 returnsnil. - 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/jsonkeeps 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 ofnull, 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/MarshalIndentturn Go values into JSON;json.Unmarshalfills a value through a pointer.- Only exported fields are encoded. Struct tags set the JSON name,
omitemptydrops empty values,"-"hides a field. - Decode into a struct whenever you know the fields;
map[string]anyturns every number intofloat64and needs type assertions. - Use
json.NewDecoderandjson.NewEncoderfor readers and writers, andDisallowUnknownFieldsto catch typos in hand-written input. - Go 1.27 adds
encoding/json/v2with stricter defaults;encoding/jsonkeeps working as before.