JavaRush /Courses /Go SELF /Marshal/Unmarshal, struct tags, and omitempty

Marshal/Unmarshal, struct tags, and omitempty

Go SELF
Level 45, Lesson 1
Available

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
json:"title"
The field is named "title" in JSON
json:"-"
The field is completely ignored (not encoded and not decoded)
json:"note,omitempty"
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…
string
""
bool
false

numbers (

int
, …)

0
slice, map
len == 0
(both
nil
and empty)
*T
nil
any
nil

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.

1
Task
Go SELF, level 45, lesson 1
Locked
Print the task card
Print the task card
1
Task
Go SELF, level 45, lesson 1
Locked
Note and Secret
Note and Secret
1
Task
Go SELF, level 45, lesson 1
Locked
Receiving form data
Receiving form data
1
Task
Go SELF, level 45, lesson 1
Locked
Find the typo
Find the typo
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION