Use of omitzero json struct tag in Go

Go 1.24 introduced a new json struct tag called omitzero. It handles a couple of scenarios that the existing omitempty tag couldn’t. Let’s look at them one by one.

Marshalling empty time.Time struct fields

Let’s say we have the following customer struct

1type customer struct {
2	Name string    `json:"name,omitempty"`
3	DOB  time.Time `json:"dob,omitempty"`
4}

We have added the omitempty json struct tag to both the Name and DOB fields with the expectation that if the field is not set, that particular field must be removed when the json is created. Let’s write a program to test this out.

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7	"time"
 8)
 9
10type customer struct {
11	Name string    `json:"name,omitempty"`
12	DOB  time.Time `json:"dob,omitempty"`
13}
14
15func main() {
16	c := customer{}
17	cJson, err := json.Marshal(c)
18	if err != nil {
19		slog.Error("Unable to marshal customer", "error", err)
20	}
21	fmt.Println(string(cJson))
22}

Run in playground

Running the above program will print,

1{"dob":"0001-01-01T00:00:00Z"}

The omitempty tag works well for string types but not for time.Time. Even when the DOB was not specified when creating the struct, the output shows the zero value of time.Time.

This becomes a hassle say when this json is passed to a web API call. The API doesn’t know whether the customer provided the DOB or not. The way this was handled in omitempty was using pointers.

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7	"time"
 8)
 9
10type customer struct {
11	Name string     `json:"name,omitempty"`
12	DOB  *time.Time `json:"dob,omitempty"` //DOB changed to pointer
13}
14
15func main() {
16	c := customer{}
17	cJson, err := json.Marshal(c)
18	if err != nil {
19		slog.Error("Unable to marshal customer", "error", err)
20	}
21	fmt.Println(string(cJson))
22}

Run in playground

In the above program DOB is changed to a pointer. Since the pointer is nil it is omitted in json. When the above program is run, it prints,

1{}

The json now doesn’t contain the DOB field but there is a unnecessary pointer used to handle this scenario. This is fixed with the new omitzero json tag.

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7	"time"
 8)
 9
10type customer struct {
11	Name string    `json:"name,omitempty"`
12	DOB  time.Time `json:"dob,omitzero"` //DOB json tag changed to omitzero and pointer removed
13}
14
15func main() {
16	c := customer{}
17	cJson, err := json.Marshal(c)
18	if err != nil {
19		slog.Error("Unable to marshal customer", "error", err)
20	}
21	fmt.Println(string(cJson))
22}

Run in playground

In the above program, DOB struct tag is changed to omitzero and also the pointer is removed. Running this program prints,

1{}

The above output doesn’t have dob in the json, just what we wanted!

Marshalling empty nested structs

The second case where omitempty fails to handle zero values is when using nested structs. Consider the following program,

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7	"time"
 8)
 9
10type customer struct {
11	Name string    `json:"name,omitempty"`
12	DOB  time.Time `json:"dob,omitzero"`
13}
14
15type address struct {
16	Line1 string `json:"photo_id,omitempty"`
17	Line2 string `json:"location,omitempty"`
18}
19
20type SomeAPIRequest struct {
21	Customer customer `json:"customer,omitempty"`
22	Address  address  `json:"address,omitempty"` //omitempty set on nested struct
23}
24
25func main() {
26	c := customer{
27		Name: "John Doe",
28	}
29	apiRequest := SomeAPIRequest{
30		Customer: c,
31	}
32	apiRequestJson, err := json.Marshal(apiRequest)
33	if err != nil {
34		slog.Error("Unable to marshal api request", "error", err)
35	}
36	fmt.Println(string(apiRequestJson))
37}

Run in playground

In the above program, in line no. 22, the customer’s address is specified using another struct address and it has omitempty set. In line no. 26, the customer is created by setting the Name but not setting the Address. Let’s run the above program and check the output. Running the above program prints,

1{"customer":{"name":"John Doe"},"address":{}}

The output confirms that even though the address field is not set, it is present in the marshalled json. The solution to the problem is similar to the case of time.Time and it is making Address a pointer and keep using omitempty. This issue is also fixed in the omitzero tag. Let’s change the Address field’s tag to omitzero and see what happens.

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7	"time"
 8)
 9
10type customer struct {
11	Name string    `json:"name,omitempty"`
12	DOB  time.Time `json:"dob,omitzero"`
13}
14
15type address struct {
16	Line1 string `json:"photo_id,omitempty"`
17	Line2 string `json:"location,omitempty"`
18}
19
20type SomeAPIRequest struct {
21	Customer customer `json:"customer,omitempty"`
22	Address  address  `json:"address,omitzero"` //struct tag changed from omitempty to omitzero
23}
24
25func main() {
26	c := customer{
27		Name: "John Doe",
28	}
29	apiRequest := SomeAPIRequest{
30		Customer: c,
31	}
32	apiRequestJson, err := json.Marshal(apiRequest)
33	if err != nil {
34		slog.Error("Unable to marshal api request", "error", err)
35	}
36	fmt.Println(string(apiRequestJson))
37}

Run in playground

Running the program now prints the json without the address field.

1{"customer":{"name":"John Doe"}}

IsZero Method

The omitzero tags also allows to define when a field can be considered as having a zero value. If the field has a isZero method returning a bool indicating whether the field is zero or not, omitzero tag will use this method to determine whether the field’s value is a zero value or not. Let’s understand how this works by means of an example.

Consider the following struct type which indicates the retry configuration when calling a web API

1type configuration struct {
2	Timeout int // in seconds
3	Retries int
4}

The zero value 0 of retry could indicate that the API call must not be retried and hence having a custom zero value is helpful. We can define the IsZero method on configuration struct which will be used by omitzero tag to determine whether configuration is zero during json marshal.

 1package main
 2
 3import (
 4	"encoding/json"
 5	"fmt"
 6	"log/slog"
 7)
 8
 9type configuration struct {
10	Timeout int
11	Retries int
12}
13
14type APIRequest struct {
15	Configuration configuration `json:"configuration,omitzero"`
16}
17
18func (c configuration) IsZero() bool {
19	if c.Timeout == 0 && c.Retries < 0 {
20		return true
21	}
22	return false
23}
24
25func main() {
26	apiRequest1 := APIRequest{
27		Configuration: configuration{
28			Retries: -1,
29		},
30	}
31	apiRequest1Json, err := json.Marshal(apiRequest1)
32	if err != nil {
33		slog.Error("Unable to marshal api request", "error", err)
34	}
35	fmt.Println("apiRequest1Json", string(apiRequest1Json))
36
37	apiRequest2 := APIRequest{
38		Configuration: configuration{},
39	}
40	apiRequest2Json, err := json.Marshal(apiRequest2)
41	if err != nil {
42		slog.Error("Unable to unmarshal api request", "error", err)
43	}
44	fmt.Println("apiRequest2Json", string(apiRequest2Json))
45}

Run in playground

The IsZero() method in line no. 18 is used by omitzero json tag to determine whether the configuration struct is zero value of not. If Timeout is 0 and Retries is less than 0, then configuration is considered to be zero value.

The Configuration declared in line no. 27 has Retries is set to -1 and Timeout 0 hence configuration is zero value and so it’s omitted during the json marshal in line no. 31.

The Configuration declared in line no. 38 has Retries and Timeout set to 0 hence configuration is not zero value and so it’s included during json marshal. Running the above program prints

1apiRequest1Json {}
2apiRequest2Json {"configuration":{"Timeout":0,"Retries":0}}

Thanks for reading. I hope you liked this tutorial. Please leave your feedback and comments. Please consider sharing this tutorial on twitter and LinkedIn. Have a good day.

If you’re looking for job opportunities in the Go programming language, check out gojobs.run, a search engine I own that specializes in indexing Go jobs.