Become Ardan Labs Certified | Test your knowledge with our Go & Rust exams at ardanlabs.training

Introduction

In part 1 we took a higher level view on serialization in general and JSON in specific. In part 2 we looked at emitting JSON.

In part 3, we’ll look at an issue you might encounter when consuming JSON, Zero vs NULL field values. To clarify the definition of NULL, this means the absence of value.

So here is the question:

Given a field in a Go struct set to its zero value, how do you know that zero value was set by the user or it’s zero because it was never provided?

By the way, this issue is not just restricted to JSON, you can have the same issue when working with maps, channels, database queries, and more.

Simple Unmarshalling

Let’s start with understanding basic unmarshalling.

Say we have a start VM (VM = virtual machine) request. In JSON, the request may look like this:

Listing 1: Start VM request

01 {
02   "image": "debian:bookworm-slim",
03   "count": 1
04 }

Listing 1 shows the “start VM” request in JSON format. On line 02, we have the image name and on line 03, we have the machine count.

So on the Go side, the first thing we need is to define a StartVM struct:

Listing 2: StartVM

08 type StartVM struct {
09     Image string
10     Count int
11 }

Listing 2 shows the StartVM struct. It has two fields: Image and Count.

The encoding/json package APIs only work with exported fields and when JSON field tags are not provided, the package will use a case-insensitive match of the field name.

Note: To apply JSON field tags review this json.Marshal. documentation

And now we can unmarshal data into this struct:

Listing 3: Unmarshalling JSON

14     data := `
15     {
16         "image": "debian:bookworm-slim",
17         "count": 1
18     }`
19 
20     var req StartVM
21     if err := json.Unmarshal([]byte(data), &req); err != nil {
22         return err
23     }
24 
25     fmt.Printf("%#v\n", req)
26     return nil

Listing 3 shows a simple example of unmarshalling JSON into a Go struct. On lines 14-18, we define a JSON document. On line 20, we create a variable of type StartVM and then on lines 21-23, we use the json.Unmarshal function to extract the data from the JSON document and populate the fields in the req variable.

This code prints:

Listing 4:

main.StartVM{Image:"debian:bookworm-slim", Count:1}

Zero Values vs NULL

The Unmarshal function will not fail if fields are not present from the JSON document or not present from the Go struct. This allows us to define only the fields we are interested in, but this can cause issues at times when it comes to NULL vs Zero value.

Note: The json.Decoder type has a DisallowUnknownFields method that causes the decoder to return an error if the JSON contains fields that are not present in the struct. This approach works well if you need to validate the exact JSON input.

Let’s see an example:

Listing 5: Missing “count”

08 type StartVM struct {
09     Image string
10     Count int
11 }

31     data := []byte(`{"image": "debian:bookworm-slim"}`)
32 
33     var req StartVM
34     if err := json.Unmarshal(data, &req); err != nil {
35         return err
36     }
37 
38     fmt.Printf("%#v\n", req)
39     return nil

Listing 5 shows the unmarshalling of a JSON document where the count field is missing from the JSON.

This code will print:

Listing 6:

main.StartVM{Image:"debian:bookworm-slim", Count:0}

How can you know if the user didn’t send the count field as part of the JSON document (NULL) or if it was included and set to 0 (Zero Value)? In some cases, this might not be an issue. But assume that the requirements for this API endpoint are: “If the user does not pass count, use 1 as the count, otherwise validate that the count is bigger than 0.”

I’ll show you three ways to deal with the “Zero Value vs NULL” problem:

  • Using Pointer Semantics
  • Using A Map
  • Using Default Values

Using Pointer Semantics

Using pointer semantics will allow you to determine if a field was provided in the JSON document by leveraging the semantic of nil for NULL.

Listing 7: StartVM Using Pointer Fields

08 type StartVM struct {
09     Image string //
10     Count *int   // Changed to a pointer of type int
11 }

48     data := []byte(`{"image": "debian:bookworm-slim"}`)
49 
50     var req StartVM
51     if err := json.Unmarshal(data, &req); err != nil {
52         return err
53     }
54 
55     if req.Count == nil { // User didn't send "count", use default value
56         v := 1
57         req.Count = &v
58     }
59 
60     if *req.Count < 1 {
61         return fmt.Errorf("bad count: %d", *req.Count)
62     }
63 
64     fmt.Printf("%#v\n", req)
65     return nil

Listing 7 shows how to use pointer semantics to know if a field was not provided in the JSON document. On lines 08-11, we change the fields in the StartVM struct to use pointer semantics.

Then on lines 50-53, we unmarshal the JSON document and then check if the Count field is nil (meaning the user didn’t provide the count field). If the Count field is nil, the Count field is defaulted to 1. Finally on line 60-62, we validate if the Count field is valid by checking it’s not less than 1.

This code will print:

Listing 8:

main.StartVM{Image:"debian:bookworm-slim", Count:0xc000098170}

Many Go developers choose this option because it’s quick and simple to implement. You do need to be careful of nil pointer errors like dereferencing a nil pointer and causing a panic.

Using A Map

Using a map is a second option. We’ll use a map declared as map[string]any in Go since this represents the type system of a JSON document.

Listing 9: Using A Map

 08 type StartVM struct {
 09     Image string
 10     Count int
 11 }

 69     data := []byte(`{"image": "debian:bookworm-slim"}`)
 70 
 71     var m map[string]any
 72     if err := json.Unmarshal(data, &m); err != nil {
 73         return err
 74     }
 75 
 76     if _, ok := m["count"]; !ok { // User didn't send "count", use default value
 77         m["count"] = 1.0
 78     }
 79 
 80     image, ok := m["image"].(string)
 81     if !ok || image == "" {
 82         return fmt.Errorf("bad image: %#v", m["image"])
 83     }
 84 
 85     count, ok := m["count"].(float64)
 86     if !ok {
 87         return fmt.Errorf("bad count: %#v", m["count"])
 88     }
 89 
 90     if count < 1 {
 91         return fmt.Errorf("bad count: %f", count)
 92     }
 93 
 94     req := StartVM{
 95         Image: image,
 96         Count: int(count),
 97     }
 98 
 99     fmt.Printf("%#v\n", req)
100     return nil

Listing 9 shows how to use a Go map. On lines 71-74, we unmarshal the JSON document into a Go map. Then on lines 76-78, we check if the count field was provided in the JSON document and if not, the count key in the map is set to 1.0.

On lines 80-92, we validate the JSON document provided values we expect in the proper type.

One line 94-97 we define a variable of type StartVM and set the values from the map.

_Note: Instead of manually converting the map to a struct, you can use this cool package called mapstructure.`

This approach is more tedious and complex, but it will work. It’s important to note that by default encoding/json converts numbers to float64 and you’ll need to convert them to int (or any other type you require).

Using Default Values

Using default values can help to simplify the Zero Value vs NULL issues when this is an option. For this to work, the default value can’t match the zero value for the specified field.

Listing 10: Using Default Values

 08 type StartVM struct {
 09     Image string
 10     Count int
 11 }

104     data := []byte(`{"image": "debian:bookworm-slim"}`)
105 
106     req := StartVM{
107         Count: 1,
108     }
109 
110     if err := json.Unmarshal(data, &req); err != nil {
111         return err
112     }
113 
114     if req.Count < 1 {
115         return fmt.Errorf("bad count: %d", req.Count)
116     }
117 
118     fmt.Println(req)
119 
120     return nil

Listing 10 shows how to use default values. On lines 106-108, we create a StartVM variable and set the Count field to 1 as part of the construction. Then on lines 110-112, we unmarshal the JSON document and then verify as before the final value of the count field. If the count field is provided in the JSON document, the default value will be replaced with what was provided. In this example the count field is not provided so the count field will be 1.

When you have a situation where a default value can be used and it won’t represent the zero value for the field, this is a great option.

Conclusion

The “Zero Value vs NULL” problem can present itself in several places when coding in Go, including the process of unmarshalling JSON. In this article I’ve shown you three ways to solve this issue. I hope you learned the power of default values and consider their use in the future.

Do you have other methods of dealing with this issue? I’d love to hear, mail me at miki@ardanlabs.com.

Trusted by Top Technology Companies

We've built our reputation as educators and bring that mentality to every project. When you partner with us, your team will learn best practices and grow along the way.

30,000+

Engineers Trained

1,000+

Companies Worldwide

14+

Years in Business