Back to blog

Sunday, October 25, 2026

Why Does Go JSON Turn My Empty Slice Into Null?

Title card for 'Why Does Go JSON Turn My Empty Slice Into Null?' with a short code example

Introduction

Go JSON turns your empty slice into null because the slice was nil, not empty in the sense the encoder cares about. The encoder checks whether the slice is nil, and a nil slice encodes as the JSON value null, while a non-nil slice with length zero encodes as the JSON array []. Both forms have length zero in Go, and both behave the same in a range loop and under append, which is why the difference stays hidden until the value reaches JSON.

This article covers encoding/json, the v1 JSON package, with the Go 1.20 baseline used in the complete example. The newer encoding/json/v2 package changes the headline rule: a nil slice marshals as [] by default in v2, not null. If you use v2, expect [] where this article says null.

Before You Start

  • Slice: a Go value that describes a sequence of elements. A slice header holds a pointer to an underlying array, a length, and a capacity. Length is the number of elements in the slice, capacity is the number of elements the underlying array can hold from the slice start.
  • Nil slice: a slice with no underlying array. It is created by declaring a slice variable without giving it a value, for example var tags []string. A nil slice has length zero and capacity zero, and it compares equal to nil.
  • Empty slice: a slice with an underlying array, or at least a non-nil pointer, and length zero. It is created by a literal such as []string{} or by make([]string, 0). An empty slice has length zero, and it does not compare equal to nil.
  • Marshal: converting a Go value to JSON, with json.Marshal in the encoding/json package. Marshal walks the value and applies a type dependent encoding rule to each part of it.
  • Unmarshal: converting JSON back into a Go value, with json.Unmarshal. Unmarshal fills the value pointed to by its second argument, following the inverse of the Marshal rules, with extra rules for null and for missing keys.
  • JSON null: the JSON literal null, which means no value is present. In Go, null unmarshals into a slice by setting that slice to nil.
  • JSON array: the JSON value written with square brackets, for example [] for an array with no elements, or ["go", "json"] for an array with two elements.
  • Struct tag: a string literal written after a struct field, in back quotes, that controls how the field is encoded. A JSON tag gives the field a JSON key name, and it can carry options after a comma.
  • omitempty: a struct tag option that omits a field from the encoded object when the field has an empty value. For a slice, an empty value means any slice with length zero, nil or not.
  • Zero value: the value a variable holds when it is declared without an explicit value. The zero value of a slice is nil.

Prerequisites

  • Go 1.20 or later, because the article uses encoding/json (the v1 package), struct tags, and slice operations that are stable in that version.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.20 or later.

Project Layout

json-null/
├── go.mod       # module declaration, Go version
├── main.go      # playlist type, constructor, and demonstrations
└── main_test.go # marshal and unmarshal tests for nil and empty slices
  • go.mod: declares the module and the minimum Go version.
  • main.go: holds the playlist type and the demonstrations, so the nil and empty cases can be read side by side.
  • main_test.go: holds tests that assert the marshal and unmarshal behaviour for nil, empty, and omitted slices.

Nil Is Not The Same As Empty

A slice has three parts: a pointer, a length, and a capacity. A nil slice has a nil pointer, length zero, and capacity zero. An empty slice has a non-nil pointer, length zero, and a capacity that depends on how it was created. The length is the same in both cases, and that shared length is the source of the confusion.

FormCodeNil checkLengthJSON output
Nil slicevar tags []stringtags == nil is true0null
Empty literaltags := []string{}tags == nil is false0[]
Empty maketags := make([]string, 0)tags == nil is false0[]
Slice with itemstags := []string{"go"}tags == nil is false1["go"]

In ordinary Go code, the difference between a nil slice and an empty slice rarely matters. Ranging over either one runs zero times. Appending to either one works, and append allocates an underlying array when the slice is nil. Passing either one to len returns zero. A reader who checks length alone therefore sees no difference at all, and the bug only appears at the JSON boundary, where the encoder looks at nil, not at length.

What Marshal Does

The encoding/json documentation states the rule directly: array and slice values encode as JSON arrays, except that a nil slice encodes as the null JSON value. That exception is the whole article in one sentence. A non-nil slice, whatever its length, encodes as an array, and a nil slice encodes as null, whatever its length, because its length is zero by definition.

var nilTags []string
emptyTags := []string{}
madeTags := make([]string, 0)

json.Marshal(nilTags)   // null
json.Marshal(emptyTags) // []
json.Marshal(madeTags)  // []

The same null rule applies to other reference types in the package: a nil pointer encodes as null, a nil interface value encodes as null, and a nil map encodes as null. The article stays with slices, because the question is about slices, and because a map has its own empty form, an empty object, which is a separate case.

One exception inside slices deserves a note, because it breaks the pattern a reader might expect. A []byte value does not encode as a JSON array at all. It encodes as a base64 encoded string, so a nil []byte encodes as null, and a non-nil empty []byte encodes as the empty string "", not as []. That is a []byte rule, not a general slice rule, and it does not change the rule for []string or any other element type.

What Unmarshal Does Back

Unmarshal preserves the distinction that Marshal created, and it adds one more distinction, between a key that is present with null and a key that is absent.

JSON inputResult in GoNil check after
"tags": nullSlice is set to niltags == nil is true
"tags": []Slice is replaced with a new empty slicetags == nil is false
"tags": ["go"]Slice holds one element, "go"tags == nil is false
Key absentField is left untouchedDepends on the value before unmarshal

The empty array case is a special case in the documentation: to unmarshal an empty JSON array into a slice, Unmarshal replaces the slice with a new empty slice. The null case is the general null rule: the JSON null value unmarshals into a slice by setting that slice to nil. A round trip therefore preserves the original state: nil marshals to null and unmarshals back to nil, and an empty slice marshals to [] and unmarshals back to an empty slice.

The absent key case is where a second bug hides. Unmarshal only sets fields for keys that are present in the input. A missing key leaves the field exactly as it was before the call. If the target struct was freshly declared, the field is nil, so a missing key and an explicit null both leave the field nil, and the two inputs look the same after the call. If the target struct was reused, and the field already held an empty slice, a missing key leaves that empty slice in place, while an explicit null sets it to nil. Reusing a target struct across unmarshal calls therefore mixes old state with new input, and a fresh target per decode avoids that mixing.

Why omitempty Does Not Fix It

When a reader sees null in an API response and wants [] instead, the first tag tried is often omitempty. It does not produce []. It removes the key entirely, for both nil and empty slices, because the documentation defines an empty value for omitempty as any slice with length zero.

type Playlist struct {
    Name string   `json:"name"`
    Tags []string `json:"tags,omitempty"`
}

json.Marshal(Playlist{Name: "A"})                    // {"name":"A"}
json.Marshal(Playlist{Name: "A", Tags: []string{}}) // {"name":"A"}
json.Marshal(Playlist{Name: "A", Tags: nil})        // {"name":"A"}

All three forms give the same output, with no tags key at all. That is correct behaviour for omitempty, and it is the wrong tool for a contract that requires the key to be present with []. A consumer that reads tags and expects an array will find no key, which is a different failure from finding null, and it is not a fix for the original problem. Use omitempty when the contract allows the key to be absent. Use initialisation, described in the next section, when the contract requires the key to be present with [].

Fixing It At The Source

The most direct fix is to make sure the slice is never nil at the point where it is marshalled. That means initialising it where the value is created, and normalising it where the value enters from a source that can return nil, such as a database query that returns no rows, or a function that returns a nil slice when it finds nothing.

func NewPlaylist(name string, tags []string) Playlist {
    if tags == nil {
        tags = []string{}
    }
    return Playlist{Name: name, Tags: tags}
}

A constructor like this fixes the common case, and it has one limit that matters: it only works when the value is created through the constructor. A struct literal written directly, with the field left out, still gives a nil slice, and a struct filled by Unmarshal from a JSON null still gives a nil slice. The fix therefore belongs at the boundary where the value leaves the program, not only at one constructor. A handler that builds a response, or a function that converts a database row into an API type, should normalise the slice there, so every path that reaches Marshal has already decided whether the contract is [] or null.

Normalising has a cost, and it should be stated openly. Turning a null into [] changes the meaning of the response for a consumer that distinguishes the two, for example a consumer that treats null as "not loaded" and [] as "loaded, with no items". If that distinction is part of the contract, normalising everywhere destroys information, and the correct fix is to keep null and document it, not to force []. The fix in this section is for contracts where [] is the agreed meaning of "no items", which is the common case for a list field in an API response.

Forcing [] With Custom MarshalJSON

When a field must always encode as [], even when the value is nil, and when initialisation at every creation point is not practical, a custom MarshalJSON method on a named slice type gives a single place to enforce the rule.

type Tags []string

func (t Tags) MarshalJSON() ([]byte, error) {
    if t == nil {
        return []byte("[]"), nil
    }
    return json.Marshal([]string(t))
}

The conversion to []string inside the method is the part that must not be dropped. Calling json.Marshal(t) on the named type would call the same MarshalJSON method again, and that recursion never ends. Converting to the underlying slice type removes the method from the value being marshalled, so the standard slice encoding runs, and a non-nil value encodes as an array of its elements.

A custom MarshalJSON method changes only marshalling. Unmarshal still follows the standard rules, so a JSON null still unmarshals into a nil value of this type, unless a matching UnmarshalJSON method is also written. That asymmetry is a property of the pattern, not a defect in it, and it should be recorded wherever the type is defined, so a later reader does not assume the two directions match.

Design Decisions And Tradeoffs

OptionUse whenCost
Leave nil as nullThe contract distinguishes "not present" from "present, with no items"Consumers must handle null, and a consumer that calls array methods on null will fail
Initialise to an empty sliceThe contract says a list field is always an array, with [] for no itemsEvery creation path and every boundary must normalise, or a direct literal or a null input reintroduces null
omitemptyThe contract allows the key to be absent for no itemsThe key disappears for both nil and empty slices, so it never produces []
Custom MarshalJSONThe field must always encode as [], whatever the valueA named type is required, the conversion to the underlying type must be kept to avoid recursion, and unmarshal stays standard unless a matching method is added
Pointer to a sliceThe API must distinguish a present [] from an absent key, for example in a patch requestExtra indirection, a nil check before use, and a missing key and an explicit null both leave the pointer nil

A design in this article has a deliberate compromise, stated openly. The complete example uses a playlist with a single slice field, not a full API with many list fields, so the nil, empty, null, and omitempty cases can be read side by side without extra domain code. In a real API, the same rule applies to every list field, and the decision about null versus [] belongs in the API contract, not in each handler. The single field here is a teaching compromise, not a production layout. The encoding rule itself is not compromised: nil encodes as null, and a non-nil empty slice encodes as [].

Complete Example

The complete example is a small playlist program. It shows a nil slice, an empty slice from the constructor, a slice with items, a round trip from null, a round trip from an empty array, and the omitempty form, so every claim in the article can be checked in one run.

// go.mod
module jsonnull

go 1.20
// main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
)

type Playlist struct {
    Name string   `json:"name"`
    Tags []string `json:"tags"`
}

type PlaylistOmit struct {
    Name string   `json:"name"`
    Tags []string `json:"tags,omitempty"`
}

func NewPlaylist(name string, tags []string) Playlist {
    if tags == nil {
        tags = []string{}
    }
    return Playlist{Name: name, Tags: tags}
}

func mustMarshal(v any) string {
    b, err := json.Marshal(v)
    if err != nil {
        log.Fatal(err)
    }
    return string(b)
}

func main() {
    direct := Playlist{Name: "Silent"}
    fmt.Println(mustMarshal(direct))

    built := NewPlaylist("Empty", nil)
    fmt.Println(mustMarshal(built))

    withItems := NewPlaylist("Mixed", []string{"go", "json"})
    fmt.Println(mustMarshal(withItems))

    var fromNull Playlist
    if err := json.Unmarshal([]byte(`{"name":"FromNull","tags":null}`), &fromNull); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("null round trip: tags == nil: %v, len: %d\n", fromNull.Tags == nil, len(fromNull.Tags))

    var fromEmpty Playlist
    if err := json.Unmarshal([]byte(`{"name":"FromEmpty","tags":[]}`), &fromEmpty); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("empty round trip: tags == nil: %v, len: %d\n", fromEmpty.Tags == nil, len(fromEmpty.Tags))

    fmt.Println(mustMarshal(PlaylistOmit{Name: "OmitNil"}))
    fmt.Println(mustMarshal(PlaylistOmit{Name: "OmitEmpty", Tags: []string{}}))
}

Commands to run it, from the json-null folder:

go run .

Expected output:

{"name":"Silent","tags":null}
{"name":"Empty","tags":[]}
{"name":"Mixed","tags":["go","json"]}
null round trip: tags == nil: true, len: 0
empty round trip: tags == nil: false, len: 0
{"name":"OmitNil"}
{"name":"OmitEmpty"}

Why this output follows the rules, in order:

  • direct leaves Tags out of the literal, so the field holds its zero value, nil, and Marshal writes null.
  • built passes nil through the constructor, which replaces nil with an empty literal, and Marshal writes [].
  • withItems holds two elements, and Marshal writes an array with those elements.
  • fromNull unmarshals an explicit null, which sets the slice to nil, so the nil check is true and the length is zero.
  • fromEmpty unmarshals an empty array, which replaces the slice with a new empty slice, so the nil check is false and the length is zero.
  • Both omitempty forms omit the key, because both nil and empty slices are empty values for omitempty.

Testing

Test a JSON contract by exact output and by nil state, not by length alone. A length check passes for both nil and empty slices, so it cannot catch the bug this article is about. A test must assert the encoded string, and, after unmarshal, the nil state.

// main_test.go
package main

import (
    "encoding/json"
    "testing"
)

func TestNilSliceMarshalsAsNull(t *testing.T) {
    got := mustMarshal(Playlist{Name: "A"})
    want := `{"name":"A","tags":null}`
    if got != want {
        t.Errorf("got %s, want %s", got, want)
    }
}

func TestEmptySliceMarshalsAsArray(t *testing.T) {
    got := mustMarshal(NewPlaylist("A", nil))
    want := `{"name":"A","tags":[]}`
    if got != want {
        t.Errorf("got %s, want %s", got, want)
    }
}

func TestUnmarshalNullGivesNil(t *testing.T) {
    var p Playlist
    if err := json.Unmarshal([]byte(`{"name":"A","tags":null}`), &p); err != nil {
        t.Fatal(err)
    }
    if p.Tags != nil {
        t.Errorf("tags == nil is false after null, want true")
    }
}

func TestUnmarshalEmptyArrayGivesEmptySlice(t *testing.T) {
    var p Playlist
    if err := json.Unmarshal([]byte(`{"name":"A","tags":[]}`), &p); err != nil {
        t.Fatal(err)
    }
    if p.Tags == nil {
        t.Errorf("tags == nil is true after [], want false")
    }
    if len(p.Tags) != 0 {
        t.Errorf("len(tags) = %d, want 0", len(p.Tags))
    }
}

func TestOmitEmptyOmitsBoth(t *testing.T) {
    want := `{"name":"A"}`
    if got := mustMarshal(PlaylistOmit{Name: "A"}); got != want {
        t.Errorf("omit nil: got %s, want %s", got, want)
    }
    if got := mustMarshal(PlaylistOmit{Name: "A", Tags: []string{}}); got != want {
        t.Errorf("omit empty: got %s, want %s", got, want)
    }
}

Commands to run the tests and the static check:

go vet ./...
go test ./...

The tests assert the contract directly: nil marshals as null, an empty slice marshals as [], null unmarshals to nil, an empty array unmarshals to a non-nil empty slice, and omitempty omits both nil and empty forms. go vet reports suspicious constructs. It does not check a JSON contract, so do not treat a clean vet run as proof that the encoded output matches the contract. The tests above are the proof for this example.

Common Mistakes

  • Checking length instead of nil: a test or a branch that checks len(tags) == 0 treats nil and empty slices as the same value, and it passes for both, so it cannot catch a null in the output. The fix is to check tags == nil when the distinction matters, and to assert the exact encoded string in tests.
  • Expecting omitempty to produce []: a tag with omitempty removes the key for both nil and empty slices, because both are empty values for that option. The fix is initialisation for a contract that requires [], and omitempty only for a contract that allows an absent key.
  • Building a value with a struct literal after writing a constructor: a constructor that normalises nil to an empty slice only runs when it is called, and a direct literal with the field left out still gives nil. The fix is to normalise at the boundary where the value is encoded, in addition to the constructor.
  • Re-marshalling a value decoded from null: a struct decoded from a JSON null holds a nil slice, and marshalling it again writes null, even if the original program never created a nil slice on purpose. The fix is to normalise after decode, when the contract requires [].
  • Treating a missing key as null: a missing key leaves a field untouched on unmarshal, while an explicit null sets it to nil, and the two cases only look the same when the target starts nil. The fix is a fresh target per decode, and a pointer to a slice when the API must distinguish a present [] from an absent key.
  • Comparing two slices directly: Go allows a slice to be compared with nil, and it does not allow a slice to be compared with another slice, so a direct comparison fails to compile. The fix is a nil check, a length check, and an element by element comparison when element equality is needed.
  • Recursing in a custom MarshalJSON method: calling json.Marshal on the same named type inside its own MarshalJSON method calls the method again, without end. The fix is to convert the value to its underlying slice type first, as in the custom method section.
  • Expecting []byte to behave like other slices: a []byte value encodes as a base64 string, not as a JSON array, so a non-nil empty []byte encodes as "", not as []. The fix is to treat []byte as a string encoding, and to keep it out of list fields that must encode as arrays.
  • Assuming a database layer returns an empty slice: a query that finds no rows commonly returns a nil slice, and a handler that marshals that result directly writes null. The fix is to normalise the result at the API boundary, where the contract is decided, rather than in every handler.

Production Notes

  • Contract first: decide, and document, whether a list field is null, [], or absent when it has no items, before writing handlers. A consumer written against one form will break on another, and the break is silent until the consumer reads the field.
  • Consumer impact: in JavaScript, calling an array method on null fails, while calling the same method on [] returns an empty result. A null in a list field therefore shifts a failure onto every consumer, while [] keeps the consumer code unchanged. That is a reason to prefer [] for list fields, when the contract allows it, and it is not a reason to hide a genuine "not present" meaning behind [].
  • Normalise at the boundary: convert database and query results into API types in one place, and normalise nil slices there, so handlers do not each need to remember the rule. A boundary that is missed reintroduces null, and a test on the encoded response catches it.
  • Patch semantics: when an API must distinguish "leave this list alone" from "set this list to empty", a pointer to a slice is the honest type, because a missing key leaves the pointer nil while a present [] gives a pointer to an empty slice. A plain slice cannot make that distinction on decode, because a missing key and a null both leave it nil when the target starts nil.
  • Contract changes: changing a field from null to [], or from [] to absent with omitempty, is a change to the API contract, even when the Go code change is one line. Test consumers against both forms, and record the change, because a strict consumer that validates the response shape will fail on a form it did not expect.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails the nil, empty, and omitempty tests. A vet pass alone is not enough for a JSON contract, because vet does not check encoded output.

Decision Guide

  • When the contract says a list is always an array: initialise every slice to an empty literal or with make, normalise at the encoding boundary, and test the exact encoded output.
  • When the contract allows the key to be absent: use omitempty, and accept that both nil and empty slices produce no key.
  • When the contract distinguishes "not present" from "present, with no items": keep null for the first case and [] for the second, and document the distinction for consumers.
  • When a field must always encode as []: use a custom MarshalJSON method on a named slice type, with the conversion to the underlying type, and record that unmarshal stays standard.
  • When a patch API must distinguish absent from empty: use a pointer to a slice, and check the pointer for nil before reading the list.
  • When a value comes from a database or a query: normalise it at the API boundary, because a query with no rows commonly returns nil, and a handler that marshals it directly writes null.

Conclusion

Go JSON does not turn an empty slice into null by accident, and it does not turn it into null because the slice is empty. It turns a nil slice into null because the encoder checks nil, and a nil slice is a different value from an empty slice, even though both have length zero. Once that distinction is clear, the fix follows from the contract: initialise for [], keep null when "not present" is the meaning, use omitempty only when an absent key is allowed, and test the exact output, because a length check cannot tell the two forms apart.

Summary Of Key Points

  • Nil encodes as null: a nil slice marshals to null, whatever its length, because its length is zero by definition.
  • Empty encodes as []: a non-nil slice with length zero, from a literal or from make, marshals as an empty array.
  • Length does not distinguish them: both forms have length zero, both range zero times, and append works on both, so a length check cannot catch the bug.
  • Unmarshal preserves the distinction: null sets a slice to nil, an empty array replaces it with a new empty slice, and a missing key leaves the field untouched.
  • omitempty removes the key: it omits both nil and empty slices, so it never produces [].
  • Fix at the boundary: initialise slices where values are created, normalise where values enter from outside, and test the exact encoded string.

Follow those rules, and a list field in a JSON response says exactly what it means: null when nothing is present, [] when a list is present with no items, and no key only when the contract allows the key to be absent.

Additional Resources

  • Go Packages: encoding/json package - Marshal rules, including the nil slice exception, the omitempty definition, and Unmarshal rules for null, empty arrays, and missing keys
  • Go Documentation: JSON and Go - an introduction to encoding and decoding JSON in Go, with struct and slice examples
  • Go Documentation: The Go Programming Language Specification - slice types, the zero value, and the rules that make a declared slice nil

References

  1. encoding/json package. Go team, Go Packages. https://pkg.go.dev/encoding/json. Accessed 2026-10-25.
  2. JSON and Go. Go team, Go Documentation. https://golang.org/doc/articles/json_and_go.html. Accessed 2026-10-25.
  3. The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec. Accessed 2026-10-25.