Last Updated: May 22, 2026
Go has two ways to spell "a slice with no items in it", and they look almost identical from the outside. One is the zero value (var orders []string) and the other is an explicit empty literal (orders := []string{}). They behave the same under len, range, and append, but they're not the same value, and the difference shows up in JSON output, reflection, and explicit nil checks. This lesson nails down when each one exists, how to tell them apart, and which one to return from a function.
A nil slice is what you get when you declare a slice variable without initializing it. The variable exists, but it has no backing array.
var orders []string declares orders and assigns it the zero value of the slice type, which is nil. The slice header has a nil pointer, length 0, and capacity 0. There's no backing array allocated anywhere, so the slice owns nothing on the heap.
A few other patterns produce nil slices too:
return nil from a function with a slice return type, an uninitialized variable declared with var, and an explicit conversion []int(nil) all give you a nil slice. The print confirms all three are nil.
An empty slice is a slice that's been initialized but has no elements. The header points at a real (zero-length) backing array, so the pointer is not nil.
Both []string{} (a slice literal with no elements) and make([]string, 0) (a make call with length and capacity zero) produce empty but non-nil slices. len and cap are both 0, the same numbers a nil slice reports, but == nil is now false.
The two forms are interchangeable in behavior. The literal []string{} is shorter and is what is most often. make([]T, 0) is more useful when you already know you'll pass a non-zero capacity, like make([]T, 0, 100), to pre-size the backing array.
[]string{} and make([]string, 0) both allocate. They reserve a small header and a zero-length backing array. If the slice is going to stay empty (return nil is an alternative), the nil version skips that allocation.
len, cap, and == nil ReportThis is the part that confuses people. len and cap give the same answers for both kinds of slice, but == nil does not.
| Operation | Nil slice (var s []int) | Empty slice (s := []int{}) |
|---|---|---|
len(s) | 0 | 0 |
cap(s) | 0 | 0 |
s == nil | true | false |
| Header pointer | nil | non-nil (points at zero-length array) |
| Allocates? | No | Yes (small) |
The rule of thumb: if you want to know whether a slice has any items, always use len(s) == 0. If you specifically want to know whether the slice header is nil, use s == nil. They answer different questions, and most code only ever cares about the first one.
A slice value in Go is a small three-field struct: a pointer to the backing array, a length, and a capacity. The diagram below shows what the two headers look like in memory.
The nil header has its pointer field set to nil. There's nothing on the heap to point at. The empty slice header points at a real (if tiny) backing array with room for zero elements. Both report len 0 and cap 0, but the pointer fields differ, and that's the entire mechanical reason == nil returns different answers.
range, append, and IndexingThe reason most Go code can ignore the nil-vs-empty distinction is that the operations you actually use treat both the same.
range over a nil slice runs zero times, exactly like an empty slice. No nil check needed before the loop. This is a deliberate language design: the zero value of a slice is usable. You can treat an uninitialized slice as an empty collection without a guard.
append is the same story. Appending to a nil slice works and gives you back a real (non-nil) slice.
The first append allocates a backing array, copies the new element into it, and returns a new slice header. The nil header is replaced. This is why var cart []string followed by a loop of appends is a common, idiomatic pattern: there's no need to initialize the slice with make or []T{} first.
Indexing is where the symmetry breaks down, but not in the expected way. Both slices panic on out-of-range access, with the same error.
Replacing the nil slice with cart := []string{} produces the exact same panic message. The panic isn't about nil, it's about length zero. So the consistent rule is: never index a slice without checking len(s) > 0 first, and don't worry separately about nil.
Here's where the nil-vs-empty difference stops being theoretical. encoding/json distinguishes between the two.
The nil slice marshals to JSON null. The empty slice marshals to JSON []. Same len(s) == 0, completely different wire output.
For an HTTP API or a stored document, this matters. A client iterating over customer.orders in JavaScript will crash on null and work fine on []. A schema validator that expects "orders": [...] will reject null. If you're shipping JSON to a frontend, the empty-slice form is almost always what you want.
A short guard before marshaling converts any nil slice on the struct into an empty slice, which produces [] instead of null on the wire. The other option is to leave the slice nil and add the ,omitempty tag, which drops the field from the JSON entirely. Which is right depends on whether your consumers expect the field to always be present.
Forcing every nil slice to an empty slice before marshaling costs one allocation per slice. That's fine for response payloads, but if you're marshaling millions of records in a tight loop.
Most Go code never touches reflection, but it's the two slices look different through reflect.Value.IsNil.
reflect.DeepEqual also distinguishes them: a nil slice is not DeepEqual to an empty slice, even though len says they're both zero-length. This shows up most often in tests, where comparing an actual result of []int{} against an expected nil (or vice versa) with reflect.DeepEqual will fail in a way that surprises you. The fix is to be consistent in which form your function returns and what your tests expect.
When a function returns a slice and there are no items to return, you have a choice. The idiomatic answer in Go is: return nil, not `[]T{}`.
The "no items" case returns nil. The caller uses len(orders) to check whether there are any, and range over the result is safe whether the function returned nil or a populated slice. No allocation happens for the empty case, which is a small but real saving when the function gets called often.
This is the convention in the standard library too. bytes.Split on an empty input returns nil. strings.Split("", ",") returns a single-element slice, not nil, because that matches the documented behavior. Look at what the standard library does for similar shapes when you're picking a return.
There are exactly two cases where you should return []T{} instead of nil:
[] over null. Convert at the boundary, not in every internal helper.reflect.DeepEqual and you want a stable, predictable comparison without writing len(got) == 0 && len(want) == 0 everywhere.Outside those cases, prefer return nil.
==A natural follow-up question: if s == nil works, why can't you compare two slices with ==? Because Go disallows it for slice types.
The compiler reports invalid operation: a == b (slice can only be compared to nil). The only legal use of == on a slice is slice == nil. Anything else is a build error.
The reason is partly historical and partly philosophical. Element-wise comparison would have to walk both slices, which hides O(n) work behind an O(1)-looking operator, and the rules for handling sub-slices, capacities, and overlapping backing arrays are messy. Rather than pick one set of semantics, Go disallows == on slices entirely.
To compare two slices for equal contents, use slices.Equal from the standard library's slices package. The practical rules are:
s == nil is fine and is the only == use that compiles.slices.Equal or a hand-written loop.reflect.DeepEqual, even though they're both length zero.A small program that exercises every distinction we've covered:
Three things worth noticing in this run. First, Orders is empty but not nil, because it was initialized with []string{}. Second, Wishlist is nil and has length zero, exactly like the empty Orders to any code that calls len or iterates. Third, the JSON output omits the wishlist entirely because of the ,omitempty tag, which treats a nil slice as "missing". Drop the tag and the output would include "wishlist":null.