Working with JSON (Marshal/Unmarshal)
Reviewed & published by Brayan K
JSON is the language of web APIs, and Go's encoding/json package makes it painless. By the end you'll convert structs to JSON with Marshal, parse JSON into structs with Unmarshal, control the output with struct tags and omitempty, and handle JSON whose shape you don't know in advance.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Encode a struct to JSON with json.Marshal
- Decode JSON into a struct with json.Unmarshal
- Map field names to JSON keys with struct tags
- Drop empty fields with the omitempty option
- Pretty-print with json.MarshalIndent
- Handle nested structs and slices
- Decode unknown JSON into map[string]any
📊 The JSON Toolkit
| Function | Direction | Returns |
|---|---|---|
| json.Marshal(v) | Go → JSON | []byte, error |
| json.MarshalIndent | Go → pretty JSON | []byte, error |
| json.Unmarshal(b, &v) | JSON → Go | error |
Two rules to remember: only exported (capitalized) fields are encoded, and Unmarshal needs a pointer (&v) so it can write into your value.
1️⃣ Marshal: Go → JSON
json.Marshal turns a Go value into JSON bytes. By default it uses the exact field names, which are capitalized in Go — so a struct tag like json:"name" tells it to emit lowercase "name" instead, matching typical API conventions. Only exported fields (capital first letter) are ever included.
package main
import (
"encoding/json"
"fmt"
)
// Struct TAGS in backticks tell the encoder what JSON key to use.
// Without a tag, Go uses the exact field name (so "Name", not "name").
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
func main() {
u := User{Name: "Alice", Email: "alice@example.com", Age: 28}
// Marshal turns a Go value INTO JSON bytes.
data, err := json.Marshal(u)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(data))
}Your turn. Add the two struct tags so the JSON keys come out lowercase.
package main
import (
"encoding/json"
"fmt"
)
// 🎯 YOUR TURN — add tags so the JSON keys are "title" and "pages".
type Book struct {
Title string ___ // 👉 `json:"title"`
Pages int ___ // 👉 `json:"pages"`
}
func main() {
b := Book{Title: "Go 101", Pages: 200}
data, _ := json.Marshal(b)
fmt.Println(string(data))
// ✅ Expected output: {"title":"Go 101","pages":200}
}2️⃣ Unmarshal: JSON → Go
json.Unmarshal goes the other way: it parses JSON bytes into a Go value. The crucial detail is the pointer — you pass &u so the function can fill in your struct. Keys that aren't in your struct are simply ignored, and missing keys leave their fields at the zero value.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
// Incoming JSON, e.g. from an HTTP request body.
raw := `{"name":"Bob","age":35}`
// Unmarshal parses JSON INTO a Go value. Pass a POINTER so it can fill it.
var u User
if err := json.Unmarshal([]byte(raw), &u); err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%s is %d\n", u.Name, u.Age) // Bob is 35
}3️⃣ omitempty, Nesting & Pretty Printing
Add ,omitempty to a tag and that field disappears from the output when it holds its zero value — handy for optional fields. Nested structs encode as nested JSON objects automatically. For readable output (logs, debugging, config files), json.MarshalIndent adds newlines and indentation.
package main
import (
"encoding/json"
"fmt"
)
type Address struct {
City string `json:"city"`
}
type Profile struct {
Name string `json:"name"`
Nick string `json:"nick,omitempty"` // dropped if empty
Tags []string `json:"tags,omitempty"`
Address Address `json:"address"` // nested struct
}
func main() {
p := Profile{
Name: "Mia",
Address: Address{City: "Berlin"},
// Nick and Tags left empty -> omitted thanks to omitempty
}
// MarshalIndent pretty-prints with indentation.
data, _ := json.MarshalIndent(p, "", " ")
fmt.Println(string(data))
}4️⃣ Unknown Shapes with map[string]any
Sometimes you receive JSON whose structure you don't know ahead of time. Decode it into a map[string]any and inspect the keys. One gotcha worth memorizing: all JSON numbers decode to float64, so assert and convert before doing integer maths.
package main
import (
"encoding/json"
"fmt"
)
// When you don't know the shape ahead of time, decode into a
// map[string]interface{} (or map[string]any). Numbers become float64.
func main() {
raw := `{"id":7,"active":true,"score":9.5,"tags":["a","b"]}`
var m map[string]any
json.Unmarshal([]byte(raw), &m)
fmt.Println("id:", m["id"]) // id: 7 (a float64 under the hood)
fmt.Println("active:", m["active"]) // active: true
// Numbers decode as float64 — assert before doing integer maths.
if id, ok := m["id"].(float64); ok {
fmt.Println("id+1:", int(id)+1) // id+1: 8
}
}🧩 Reorder Challenge
These lines decode JSON into a struct and print a field. Put them in the correct order:
A. var u User
B. raw := `{"name":"Zoe"}`
C. type User struct { Name string `json:"name"` }
D. json.Unmarshal([]byte(raw), &u)
E. fmt.Println(u.Name)C, B, A, D, E. Define the struct (with the tag), set up the raw JSON, declare a zero-value User, unmarshal into its address, then print the field. Unmarshal needs the variable to exist and be passed by pointer.
🧠 Quick Recall
Predict the output before revealing the answer.
1. What does Marshal produce?
type T struct {
Name string `json:"name"`
age int // unexported
}
data, _ := json.Marshal(T{Name: "Al", age: 30})
fmt.Println(string(data))— age is lowercase (unexported), so encoding/json cannot see it and leaves it out entirely. Only exported fields are encoded.
2. What is m["n"]'s dynamic type?
var m map[string]any
json.Unmarshal([]byte(`{"n":5}`), &m)
fmt.Printf("%T\n", m["n"])float64 — every JSON number decodes to float64 in a map[string]any, even integers. Assert and convert with int(m["n"].(float64)).
3. What error does this hit?
var u User
json.Unmarshal([]byte(`{"name":"x"}`), u)"json: Unmarshal(non-pointer User)". You passed u by value; Unmarshal needs a pointer so it can write into the struct. Pass &u.
Pro Tips
- 💡 Always export fields you want serialized. Lowercase fields are invisible to encoding/json — a classic "why is my JSON empty?" bug.
- 💡 Pass a pointer to Unmarshal. &v, not v, or you'll get a "non-pointer" error.
- 💡 Prefer structs over map[string]any when you know the shape — you get type safety and clearer code. Reach for the map only for truly dynamic data.
- 💡 Use json.Decoder for streams (HTTP bodies, large files) instead of reading everything into memory first.
Common Errors (and the fix)
- Empty JSON {} — your fields are unexported (lowercase). Capitalize them and add tags for the JSON keys.
- "json: Unmarshal(non-pointer T)" — you passed the value, not its address. Use json.Unmarshal(data, &v).
- "cannot unmarshal number into Go value of type string" — the JSON type doesn't match your struct field type. Fix the field type or the data.
- Number comes out as float64 — JSON has only one number type. When decoding into any, assert .(float64) then convert to int if needed.
- Field shows up despite being empty — you forgot ,omitempty in the tag, or the value isn't actually the zero value.
Frequently Asked Questions
Q: Why is my struct serializing to {}?
Almost always because the fields are unexported (start with a lowercase letter). encoding/json can only see exported fields. Capitalize the field name and add a json:"..." tag for the wire key.
Q: What does omitempty consider "empty"?
The zero value for the type: 0, "", false, nil, and empty slices/maps. Note a struct is never "empty" by this rule, so omitempty on a struct field won't drop it.
Q: How do I keep integers as integers when decoding unknown JSON?
By default numbers become float64. Either decode into a struct with an int field, or use a json.Decoder with UseNumber(), which yields a json.Number you can convert precisely.
Q: Can I rename and reshape deeply nested JSON?
Yes — define matching nested structs with their own tags. For one-off transforms you can also implement UnmarshalJSON on a type to take full control of how it decodes.
Mini-Challenge: JSON Round Trip
No blanks — just a brief. Decode JSON into a struct, change a field, then encode it back. This decode → modify → encode loop is the core of nearly every JSON API handler you'll ever write.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// 🎯 MINI-CHALLENGE: a round trip
// 1. Define a struct Product with fields Name (json "name"),
// Price (json "price") and InStock (json "in_stock").
// 2. Unmarshal this JSON into it:
// {"name":"Mug","price":9.99,"in_stock":true}
// 3. Bump the price by 1.0, then Marshal it back to JSON and print it.
//
// ✅ Expected output:
// {"name":"Mug","price":10.99,"in_stock":true}
// your code here
}🎉 Lesson Complete!
- ✅ json.Marshal encodes Go → JSON; json.Unmarshal decodes JSON → Go
- ✅ Struct tags (json:"name") map fields to JSON keys
- ✅ Only exported fields are serialized; Unmarshal needs a pointer
- ✅ ,omitempty drops zero-value fields; nested structs nest automatically
- ✅ MarshalIndent pretty-prints; unknown JSON fits a map[string]any
- ✅ JSON numbers decode to float64 — assert before integer maths
Practice quiz
Which function encodes a Go value into JSON?
- json.Decode
- json.Marshal
- json.Encode
- json.Parse
Answer: json.Marshal. json.Marshal turns a Go value into a []byte of JSON.
Which function decodes JSON into a Go value?
- json.Marshal
- json.Encode
- json.Unmarshal
- json.Read
Answer: json.Unmarshal. json.Unmarshal parses JSON bytes into a Go value.
Which struct fields get serialized by json.Marshal?
- All fields
- Only exported (capitalized) fields
- Only lowercase fields
- Only tagged fields
Answer: Only exported (capitalized) fields. Only exported fields (starting with a capital letter) are marshaled; unexported fields are skipped.
What does the struct tag json:"name" do?
- Renames the Go field
- Maps the field to the JSON key 'name'
- Makes it required
- Hides the field
Answer: Maps the field to the JSON key 'name'. The tag maps the Go field to the JSON key 'name' during marshal and unmarshal.
What does json.Unmarshal require as its destination argument?
- A value
- A pointer
- A slice
- A string
Answer: A pointer. Unmarshal needs a pointer so it can write the decoded data into your variable.
What does the ,omitempty tag option do?
- Drops the field when it holds its zero value
- Always includes the field
- Renames the field
- Errors on empty values
Answer: Drops the field when it holds its zero value. omitempty omits the field from the JSON output when it equals its zero value.
What Go type do JSON numbers decode into when the target is interface{}?
- int
- float64
- string
- json.Number always
Answer: float64. Into an interface{}, JSON numbers decode to float64 by default.
Which function produces pretty-printed, indented JSON?
- json.Pretty
- json.MarshalIndent
- json.Format
- json.Indent only
Answer: json.MarshalIndent. json.MarshalIndent adds a prefix and indentation for human-readable output.
How can you decode JSON with unknown structure?
- Into an int
- Into a map[string]interface{}
- It is impossible
- Into a string
Answer: Into a map[string]interface{}. A map[string]any (interface{}) holds arbitrary JSON objects when you don't know the shape.
If a struct field has no json tag, what JSON key is used?
- The field name as-is
- An empty key
- A lowercased version automatically
- It is skipped
Answer: The field name as-is. Without a tag, the exported Go field name is used verbatim as the JSON key.
Continue this course
- Previous: Generics (Type Parameters)
- Next: Modules & Packages — Organise code with go mod, packages and imports