1. Why you need Marshal/Unmarshal
When you first hear “we’ll turn a struct into JSON with a single function”, your brain suspects a catch. And it’s right: there is a catch — but a friendly one. encoding/json does the routine work for us: iterating over struct fields, converting numbers/strings/arrays into JSON format, escaping characters, and so on.
The most important thing is to understand that this is not “automatic telepathy”, but a very specific pipeline: we either encode a Go value into a byte slice, or decode a byte slice into a Go variable. And this pipeline is very sensitive to types and to which fields we made exported.
We need this in our training app (a hypothetical CLI todo) so that we can: print tasks as JSON (for example, for integration with other tools), read tasks from JSON, and also define a stable external contract — which fields are called what, which can be omitted, and which must not be exposed externally.
encoding/json: two operations
In encoding/json, at a basic level we care about two functions:
- json.Marshal(v) — takes a Go value v and returns JSON as []byte.
- json.Unmarshal(data, &v) — takes JSON bytes data and fills the variable v by address.
If you want a very “human” way to remember it: Marshal means “pack”, and Unmarshal means “unpack”. And unpack specifically into an already existing box, which is why you need the variable address: where to put the result.
This idea of “encode first, then decode” is common to many formats (JSON, XML, gob, etc.): you turn data into a portable representation, and then restore it back.
json.Marshal: Go → JSON
Now we’ll do the simplest thing: take a task struct and turn it into JSON. Note: Marshal returns bytes and an error. The error is not decoration, it’s reality: for example, not everything can be serialized correctly (especially when functions, channels, and other joys of life appear in the struct).
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
Title string
Done bool
}
func main() {
t := Task{ID: 1, Title: "Read about JSON", Done: false}
b, err := json.Marshal(t)
if err != nil {
fmt.Println("marshal:", err)
return
}
fmt.Println(string(b)) // {"ID":1,"Title":"Read about JSON","Done":false}
}
There are two important observations here.
First, the JSON ended up with field names ID, Title, Done — exactly as in Go. This is the default behavior.
Second, the result is one line with no newlines. That’s normal JSON, just “compact”. Not very friendly for humans, but ideal for machines.
json.Unmarshal: JSON → Go, and why you need &
Now let’s do the reverse operation: take a JSON string and parse it into Task. The most common beginner mistake is forgetting &.
Unmarshal is designed like this: it doesn’t “create a variable for you”, it fills an existing one. So it needs to know the address to write to.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
Title string
Done bool
}
func main() {
data := []byte(`{"ID":2,"Title":"Write code","Done":true}`)
var t Task
err := json.Unmarshal(data, &t)
if err != nil {
fmt.Println("unmarshal:", err)
return
}
fmt.Printf("%+v\n", t) // {ID:2 Title:Write code Done:true}
}
If you write json.Unmarshal(data, t), the compiler won’t always save you with a “nice explanation” — but the meaning of the error will be the same: you didn’t give the function a way to change the variable.
Why only exported fields are encoded
Before we start nicely controlling field names and omitempty, you need to accept one “iron” rule of encoding/json.
Only exported struct fields participate in serialization — i.e., those that start with a capital letter. This is not a whim of encoding/json, but core Go logic: if a field is not exported, external packages (including encoding/json) should not be able to access it.
Look how the field title simply disappears from JSON, even if it’s populated:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
title string
}
func main() {
t := Task{ID: 1, title: "Hidden"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"ID":1}
}
A common “naive plan” is to put the tag json:"title" on title string and hope it starts working. Unfortunately: no. Tags control how a field is encoded, but they don’t turn an unexported field into an exported one.
2. Tags and the external JSON contract
When a program communicates with the outside world, field names in Go and JSON almost never match perfectly. In Go it’s common to use Title, while JSON often wants "title", "created_at", "task_id", and so on. To avoid renaming Go fields into something inconvenient, you use tags (struct tags).
A tag is written in backticks after the field type and looks like: json:"...".
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
t := Task{ID: 1, Title: "Tags are useful", Done: false}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"id":1,"title":"Tags are useful","done":false}
}
Now the external contract looks “JSON-like”, while the Go code remains readable.
Mini table of tags
| Tag | What it does when working with JSON |
|---|---|
|
The field is named "title" in JSON |
|
The field is completely ignored (not encoded and not decoded) |
|
When encoding, the field is omitted if it is “empty” |
(no tag) |
Uses the Go field name (Title → "Title") |
And yes,
json:"-" is a great way to avoid exposing things that shouldn’t be exposed. For example, an internal token, debug info, or a “service flag”.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Secret string `json:"-"`
}
func main() {
t := Task{ID: 1, Title: "Public", Secret: "top-secret"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"id":1,"title":"Public"}
}
omitempty: what it actually does
With omitempty, confusion is common — and that’s normal: the word sounds like “the field is optional”. But in reality, omitempty does something else.
omitempty affects encoding only (Marshal): if a field value is considered “empty”, the field will not appear in the resulting JSON. When decoding (Unmarshal), omitempty changes nothing.
Let’s feel it with the Note field. When Note == "", it does not make it into JSON:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
t := Task{Title: "Task A"} // Note == ""
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"title":"Task A"}
}
And now let’s populate Note:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
t := Task{Title: "Task A", Note: "Call mom"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"title":"Task A","note":"Call mom"}
}
What is considered “empty” for omitempty
This is worth calmly saying out loud once, because later you’ll be debugging “why did it disappear” in real projects.
| Field type | Considered empty for omitempty if… |
|---|---|
|
|
|
|
numbers ( , …) |
|
|
(both and empty) |
|
|
|
|
One of the most surprising items is slices and maps. Both nil and []string{} are considered empty (both have length 0), so with omitempty the field will disappear.
omitempty and reading with Unmarshal
When you read JSON into a struct, two scenarios are possible: the field is present in JSON, or it’s not. If the field is missing, encoding/json simply leaves in the struct whatever was already there — and if the struct was “zeroed”, the field stays at the zero value.
This is very convenient behavior, but it often creates a false feeling of “validation”. In reality it’s not a check, it’s a default.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
data := []byte(`{"title":"A"}`) // note is missing
var t Task
_ = json.Unmarshal(data, &t)
fmt.Printf("Title=%q Note=%q\n", t.Title, t.Note) // Title="A" Note=""
}
The key idea to remember: the absence of a field in JSON does not mean “everything is OK by business rules”. It only means: “no value was provided, so it will be zero”. Checking required fields is a separate step (and today we’re deliberately not covering it).
3. Practice: a task list, pretty JSON, and errors
json.MarshalIndent: JSON for humans
Compact JSON is great for network transfer and storage, but when you’re debugging an app, you want to see the structure clearly. That’s what json.MarshalIndent is for.
It does the same thing as Marshal, but adds newlines and indentation. It’s handy for logs, examples, and diagnostics.
package main
import (
"encoding/json"
"fmt"
)
func main() {
v := map[string]any{
"id": 1,
"title": "Pretty JSON",
"tags": []string{"go", "json"},
}
b, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(b))
// {
// "id": 1,
// "tags": [
// "go",
// "json"
// ],
// "title": "Pretty JSON"
// }
}
A small note in the category of “nice little details”: although normal iteration over a Go map does not guarantee order, encoding/json sorts keys when encoding a JSON object so the result is stable. So with the same data you’ll see the same output, which helps a lot in tests and diffs.
Encoding and decoding a task list
Now let’s do what apps typically do: not a single task, but a list of tasks. We’re not touching files or building CLI flags yet — the key is to reinforce the contract: Marshal/Unmarshal + tags.
Let’s make two small helpers: encodeTasks and decodeTasks.
package main
import "encoding/json"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Note string `json:"note,omitempty"`
}
func encodeTasks(tasks []Task) ([]byte, error) {
return json.Marshal(tasks)
}
And now decoding:
package main
import "encoding/json"
func decodeTasks(data []byte) ([]Task, error) {
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
And let’s test it in main to see a “task lifecycle in the wild”:
package main
import (
"fmt"
)
func main() {
tasks := []Task{
{ID: 1, Title: "Learn Marshal", Done: false},
{ID: 2, Title: "Learn Unmarshal", Done: true, Note: "already done"},
}
b, err := encodeTasks(tasks)
if err != nil {
fmt.Println("encode:", err)
return
}
fmt.Println(string(b))
// [{"id":1,"title":"Learn Marshal","done":false},{"id":2,"title":"Learn Unmarshal","done":true,"note":"already done"}]
decoded, err := decodeTasks(b)
if err != nil {
fmt.Println("decode:", err)
return
}
fmt.Printf("%+v\n", decoded)
// [{ID:1 Title:Learn Marshal Done:false Note:} {ID:2 Title:Learn Unmarshal Done:true Note:already done}]
}
Here you may have noticed: for the first task, note is missing in JSON (thanks to omitempty), but after Unmarshal, the Note field became an empty string. That’s normal and expected.
Unmarshal errors: don’t lose context
In real life, JSON is often a bit crooked: an extra comma, a missing quote, a wrong field type. So handling error is not “just for show”, it’s basic hygiene.
encoding/json has typed errors, for example *json.SyntaxError, where you can learn the problem position (offset). This is useful at least to understand “where it broke” in logs. Standard materials on error handling even show an example with json.SyntaxError and its Offset field.
A mini example of detecting a syntax error:
package main
import (
"encoding/json"
"errors"
"fmt"
)
func main() {
data := []byte(`{"title":}`) // broken JSON
var v map[string]any
err := json.Unmarshal(data, &v)
var syn *json.SyntaxError
if errors.As(err, &syn) {
fmt.Println("bad JSON at byte:", syn.Offset) // bad JSON at byte: ...
return
}
fmt.Println("other error:", err)
}
We’re not going deep into “nice UX” for errors here (that’s a separate big topic), but the rule is simple: you must not ignore the error from Unmarshal, especially if JSON comes from outside.
4. Common mistakes when working with Marshal/Unmarshal, tags, and omitempty
Mistake #1: forgetting & in Unmarshal.
This is the most common story: people write json.Unmarshal(data, t) and wonder why nothing gets populated (or why the compiler complains). Unmarshal must modify the variable, so it needs its address: &t. Mentally repeat: “where should it write the result?”.
Mistake #2: making struct fields unexported and expecting tags to help.
The field title string will not become part of JSON even with the tag json:"title". encoding/json works only with exported fields. If a field must be part of the contract — make it Title string. If a field must not participate — it’s better to exclude it explicitly with
json:"-", so it’s obvious in code.
Mistake #3: confusing omitempty with “optional”.
omitempty does not validate input and does not say the field “may be omitted”. It only says: “if the value is empty, don’t output it during Marshal”. Required fields are checked by separate logic; otherwise you’ll get silent zero values and very strange bugs.
Mistake #4: being surprised that an empty []T{} behaves like nil with omitempty.
For omitempty, what matters is “empty or not”, not “nil or not nil”. An empty slice and a nil slice both have len == 0, so the field will be omitted. If you need to distinguish these states at the contract level, you’ll have to design the format intentionally (but that won’t be solved by a single checkbox in a tag).
Mistake #5: ignoring Marshal/Unmarshal errors because “JSON is simple”.
JSON is indeed simple… until you receive {"id":"oops"} instead of a number, or until someone adds an extra comma. Always check err. If you want it “shorter”, remember: short code that crashes in production often suddenly becomes very long when you start debugging it at night.
GO TO FULL VERSION