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

📊 The JSON Toolkit

FunctionDirectionReturns
json.Marshal(v)Go → JSON[]byte, error
json.MarshalIndentGo → pretty JSON[]byte, error
json.Unmarshal(b, &v)JSON → Goerror

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

Common Errors (and the fix)

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!

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