The API that broke on "nothing"
You build a notification service. The endpoint /api/notifications returns a JSON array of messages. A user logs in for the first time. The database query returns no rows. Your Go code returns a nil slice. The JSON encoder outputs null. The frontend JavaScript expects an array and calls .map(). The page throws a TypeError. You check the network tab. The response is null. You realize the backend sent the wrong kind of empty. This is the classic nil versus empty slice trap. It breaks APIs, confuses callers, and wastes debugging time.
What a slice actually is
A slice in Go is not the data itself. It is a lightweight descriptor that points to data stored elsewhere. Under the hood, a slice is a struct with three fields: a pointer to the underlying array, a length, and a capacity. The pointer tells the runtime where the elements live. The length tells the runtime how many elements are valid. The capacity tells the runtime how much space is available in the backing array before a reallocation is needed.
When you declare a slice variable without assigning a value, the compiler initializes the header to its zero value. The pointer is nil. The length is zero. The capacity is zero. This is a nil slice. It has no backing array. The pointer field is null.
When you create a slice using a composite literal like []int{} or the make function, the runtime allocates a backing array. The pointer in the header points to that array. The length is zero. The capacity is zero, unless you specified otherwise. This is an empty slice. It has a backing array, but the array holds no elements. The pointer field is valid.
The distinction lives in the pointer. Nil means the pointer is null. Empty means the pointer points to a valid array, even if that array has length zero.
Minimal example
Here's the simplest way to see the difference. A variable declared with var stays nil. A literal or make creates a non-nil slice.
package main
import "fmt"
func main() {
// var declares a slice without initialization.
// The zero value of a slice is nil.
// No backing array exists.
var nilSlice []int
// A composite literal creates a slice header.
// The runtime allocates a backing array, even if empty.
// The pointer is valid, length is zero.
emptySlice := []int{}
// make initializes the slice header and allocates storage.
// Length 0 means no elements.
// The slice is not nil because the pointer is set.
madeSlice := make([]int, 0)
// Compare each slice to nil.
// nilSlice is nil because the pointer is null.
fmt.Println(nilSlice == nil) // true
// emptySlice is not nil because it points to an array.
fmt.Println(emptySlice == nil) // false
// madeSlice is not nil for the same reason.
fmt.Println(madeSlice == nil) // false
}
Nil is the absence of a slice. Empty is a slice with no elements.
How the runtime handles them
When the compiler processes var s []int, it emits code that leaves the slice header as three zero words. No heap allocation occurs. The memory for the slice header lives on the stack or in the enclosing struct. When the compiler sees s := []int{}, it generates code to allocate a backing array. The runtime might optimize this by pointing to a shared zero-length array, but the pointer field is non-null. The slice header is fully initialized.
The make function works similarly. make([]int, 0) allocates a zero-length array and sets the header. make([]int, 0, 10) allocates an array of capacity 10 and sets length to 0. The slice is non-nil.
The runtime treats nil and empty slices identically for most operations. len returns 0 for both. cap returns 0 for both. range iterates zero times for both. The compiler allows indexing syntax, but the runtime checks bounds. Accessing s[0] on a nil slice panics. Accessing s[0] on an empty slice panics. The panic message is identical: panic: runtime error: index out of range [0] with length 0.
The append function handles nil slices gracefully. If you call append(nilSlice, 1), the runtime allocates a new array, copies the element, and returns a non-nil slice. You do not need to check if a slice is nil before appending. The runtime does the work.
Realistic example: JSON and APIs
This difference matters most when serializing data. JSON encoders distinguish between nil and empty slices. A nil slice becomes null. An empty slice becomes []. This affects how clients interpret the response.
package main
import (
"encoding/json"
"fmt"
)
// FetchItems returns a list of items based on a filter.
// It demonstrates the impact of nil vs empty on JSON serialization.
func FetchItems(filter string) []string {
if filter == "none" {
// Returning nil signals "no data" or "uninitialized".
// JSON encoders often output null for nil slices.
return nil
}
// Returning an empty slice signals "data exists, but is empty".
// JSON encoders output [] for empty slices.
return []string{}
}
func main() {
// Fetch with a filter that yields no results.
// The function returns a nil slice.
nilItems := FetchItems("none")
// Fetch with a filter that yields an empty list.
// The function returns an empty slice.
emptyItems := FetchItems("active")
// Marshal the nil slice to JSON.
// The encoder sees a nil pointer and writes null.
nilJSON, _ := json.Marshal(nilItems)
fmt.Println(string(nilJSON)) // prints: null
// Marshal the empty slice to JSON.
// The encoder sees a valid slice with length 0 and writes [].
emptyJSON, _ := json.Marshal(emptyItems)
fmt.Println(string(emptyJSON)) // prints: []
}
JSON encoders expose the truth. Nil becomes null. Empty becomes [].
Pitfalls and common mistakes
The biggest pitfall is inconsistency. If a function sometimes returns nil and sometimes returns an empty slice, callers must check for nil before using the result. This adds boilerplate. Callers might forget the check and panic when indexing. Or, in JSON APIs, the output format changes, breaking clients.
Another pitfall is passing a nil slice to a function that modifies it in place. If the function does s[0] = value, it panics. If the function does s = append(s, value), it works, but the caller's variable remains nil. Go passes the slice header by value. The function updates its local copy of the header. The caller sees no change. You must return the slice to propagate the update.
package main
import "fmt"
// AddItem attempts to add an item to a slice.
// It shows why returning the slice is necessary.
func AddItem(items []string, item string) {
// append returns a new slice header.
// Assigning to items updates the local variable only.
items = append(items, item)
}
// AddItemCorrect adds an item and returns the updated slice.
func AddItemCorrect(items []string, item string) []string {
// Return the result of append so the caller can update their variable.
return append(items, item)
}
func main() {
// Start with a nil slice.
var items []string
// Call the broken function.
AddItem(items, "first")
fmt.Println(items) // prints: [] (still nil)
// Call the correct function.
items = AddItemCorrect(items, "first")
fmt.Println(items) // prints: [first]
}
Append handles nil. Trust the runtime to allocate. Return the slice to propagate changes.
Memory and performance
Memory usage differs slightly. A nil slice consumes no heap memory for the backing array. The slice header itself lives on the stack or in a struct. An empty slice created with []int{} or make([]int, 0) involves a heap allocation for the backing array. The runtime might optimize this by pointing to a shared zero-length array, but the allocation logic still runs.
For a single slice, the difference is irrelevant. If you create millions of slices in a tight loop, the allocation overhead of empty slices adds up. In performance-critical code, prefer var s []T for local variables that you will populate with append. This avoids the initial allocation. The first append will allocate anyway, but you skip the zero-length allocation.
If you know the capacity in advance, use make([]T, 0, cap). This allocates the backing array once. Subsequent append calls reuse the capacity. This avoids reallocations and copies.
When to use nil vs empty
Use a nil slice when the collection is uninitialized or represents a missing value. Use a nil slice for optional parameters where nil means "skip this argument." Use an empty slice when you need to return a valid collection with zero elements. Use an empty slice for JSON APIs to ensure the output is [] instead of null. Use make([]T, 0, cap) when you know the capacity in advance to reduce allocations during append. Use []T{} for literals when you want a concise, non-nil empty slice. Use var s []T for local variables that you will populate later, relying on the zero value.
The Go community prefers returning empty slices over nil for success paths. This follows the principle of least surprise. Callers can range over the result without checking for nil. The gofmt tool formats nil and empty slices consistently. It does not enforce one style over the other. Your editor likely runs gofmt on save. Trust the formatting. Focus on the logic.
Return empty slices for consistency. Check nil only when it means something.