skip to content

append and Slice Aliasing

append either writes into spare capacity you are sharing with someone else or quietly reallocates, and knowing which happened is the whole game. This is the classic Go whiteboard question: predict what two slices print after an append, then explain the capacity rule that caused it.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In Go, what is the difference between a nil slice and an empty non-nil slice?

level: juniorimportance: must knowfreq 70%

answer

  1. both have length zero
  2. one of them has no array at all
  3. append needs no make either way
  4. compare with == nil, not len
  5. JSON: null versus empty brackets

basics

~20 s

A nil slice has no backing array; an empty non-nil slice points at one holding zero elements. Both have length 0 and both accept append. They differ when compared to nil, and in JSON: nil marshals to null, empty to [].

solid answer

~40 s

`var s []int` gives a nil slice: its length and capacity are 0 and it refers to no array. `[]int{}` and `make([]int, 0)` give empty slices that are not nil. Almost everything treats them identically — `len` and `cap` are 0 for both, ranging over either does nothing, indexing either panics, and `append` works on both because append allocates when there is no room. The two observable differences are `s == nil`, and serialization: `encoding/json` writes a nil slice as `null` and an empty one as `[]`. Idiomatic Go tests emptiness with `len(s) == 0`, which is true for both, and returns a nil slice rather than an allocated empty one for "no results".

code

go · 9 lines
go
var a []int      // nil slice
b := []int{}     // empty, not nil
c := make([]int, 0)

fmt.Println(len(a), len(b), len(c)) // prints: 0 0 0
fmt.Println(a == nil, b == nil, c == nil) // prints: true false false

a = append(a, 1) // append allocates; no make needed
fmt.Println(a)   // prints: [1]

go deeper

for a junior

Be ready to state that both have length 0, that append works on a nil slice with no make, and that len(s) == 0 is the emptiness check you should write.

for a middle

Explain the mechanics: the nil slice's zero value has a nil array pointer, append allocates when capacity is 0, and encoding/json distinguishes the two as null versus [].

for a senior

Show the judgment about boundaries: return nil inside the program, but decide deliberately at a serialized API edge, because a client parsing null where it expected an array is a real outage.

for a principal

Own the convention across a codebase: one rule for whether response payloads normalise empty collections, enforced at the encoding layer rather than by asking every handler to remember it.

## Two zero-length slices A Go slice value is a small descriptor holding a pointer to a backing array, a length and a capacity. There are two ways for that descriptor to describe zero elements. **Nil slice.** `var s []int` declares a slice and leaves it at its zero value: the pointer is nil, length 0, capacity 0. There is no array anywhere. This is what you get from a `return nil`, from a struct field you never set, and from a failed lookup that returns the zero value. **Empty non-nil slice.** `[]int{}` (a composite literal with no elements) and `make([]int, 0)` both produce a slice whose length and capacity are 0 but whose pointer is not nil — it points at a zero-sized allocation. `s[0:0]` on any slice is also non-nil. ## What behaves the same Almost everything, and that is the point: - `len(s)` and `cap(s)` are `0` for both. `len` on a nil slice is defined, not a panic. - `for i, v := range s` runs zero iterations for both. - `s[0]` panics with an index-out-of-range error for both. - `append(s, 1)` works on both and returns a one-element slice. `append` does not require an existing array; when there is no capacity it allocates one, and a nil slice simply has no capacity. This is why `var out []string` followed by appends in a loop is completely idiomatic Go and needs no `make`. - `copy(dst, s)` and `copy(s, src)` are fine with a nil argument; they copy `min(len(dst), len(src))` elements, which is 0. - `fmt.Println` prints `[]` for both. ## What behaves differently 1. **Comparison with nil.** A slice can only be compared to `nil`, and only a nil slice equals it. `var a []int; a == nil` is true; `[]int{} == nil` is false. This is the only language-level distinction. 2. **JSON encoding.** `encoding/json` marshals a nil slice as the JSON literal `null` and an empty non-nil slice as `[]`. For an API response that is a visible contract difference: a client doing `data.items.length` breaks on `null` and is fine with `[]`. If a field must always appear as an array, initialise it (`items: []Item{}`) or normalise before encoding. Note that unmarshalling `[]` into a slice field yields an empty non-nil slice, while `null` leaves it nil. ## Which one should your code produce? The conventional Go answer is: **return a nil slice for "nothing to return", and let callers use `len()`.** A function like `func FindAll(...) []Result` returning `nil` on no matches is normal and costs no allocation. Callers must never write `if results == nil` as their emptiness test, because a function that returns an allocated-but-empty slice is equally correct — `len(results) == 0` is the check that works against both. The exception is a boundary where the nil/empty distinction is observable to someone else: a JSON response body, a struct you compare in a test with a deep-equality helper, or an API whose documentation promises an array. There, be explicit and construct the empty slice deliberately. ## The mistakes this trips up - Testing `if s != nil` to mean "has elements". A non-nil slice of length 0 passes that test and then yields nothing. - Calling `make([]T, 0)` defensively before an append loop "so append works". It works on nil already; the `make` only matters when you want to preselect a capacity with `make([]T, 0, n)`. - Being surprised that a freshly declared slice field serialises as `null` and a client crashes on it. - Assuming `make([]T, n)` gives you an empty slice — it gives you a slice of length `n` full of zero values, and appending to it adds an `n+1`-th element rather than filling the first.

  • Why does append work on a nil slice without any call to make?
    append never assumes an existing array. It looks at the slice's length and capacity, sees there is no room (a nil slice has capacity 0), allocates a backing array large enough, copies the existing elements — none — and returns a slice describing the new array. That is why `var out []string` followed by appends in a loop is the normal Go pattern.
  • How should a function report "no results", and how should its caller test for that?
    Return a nil slice; it is free and idiomatic. The caller must test `len(results) == 0`, never `results == nil`, because a different implementation may legitimately return an allocated empty slice and both mean the same thing. Only normalise to a non-nil empty slice when the distinction escapes your program, such as a JSON body a client parses.
  • Does make([]int, 5) give you an empty slice?
    No. It gives a slice of length 5 whose elements are all the zero value 0, so it is neither nil nor empty. Appending to it produces a sixth element rather than filling the first. If you want an empty slice with room reserved, write `make([]int, 0, 5)` — length 0, capacity 5.

A nil slice is an empty shopping list you never wrote down; an empty slice is a blank sheet of paper with nothing on it. Either way you have bought nothing, but only one of them exists as a piece of paper.

saying these in an interview costs you the question

  • Says append panics or fails on a nil slice
  • Uses s != nil as the test for "has elements"
  • Claims len on a nil slice panics
  • Thinks make([]T, n) produces an empty slice
  • Unaware that a nil slice marshals to JSON null
open as a page

When does append write into a Go slice's existing backing array instead of a new one?

level: middleimportance: must knowfreq 78%

basics

~20 s

append writes in place when the new length still fits the slice's capacity. Otherwise it allocates a fresh array and copies into it. You cannot tell which happened at the call site, so always use append's return value.

open as a page

A log shipper keeps each parsed line as a subslice of the 1 MB buffer it was read from, and heap use climbs steadily. Why?

level: seniorimportance: should knowfreq 46%

basics

~20 s

A slice keeps its whole backing array alive, not just the elements it exposes. Each retained line pins its entire 1 MB buffer, so the heap grows with buffers referenced. Copy kept bytes into right-sized slices.

open as a page

What does the third index in Go's full slice expression s[a:b:c] control, and why use it?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

The third index sets capacity: s[a:b:c] has length b-a and capacity c-a. Capping capacity at the length forces any later append to allocate a fresh array, so it cannot overwrite elements the source still uses.

open as a page