The clipboard that points to nothing
You write a function that collects user IDs from a database query. You declare a slice at the top of the function, loop through the rows, and try to assign the first result directly to index zero. The program crashes with a runtime panic. You switch to a map to store configuration flags, forget to initialize it, assign a key, and crash again. The compiler did not warn you. The variable was declared, it exists, and it holds a value. That value is nil.
In Go, nil is not the same as empty. An empty collection has been allocated and is ready to hold data. A nil collection has no underlying storage. It is a placeholder that says the data structure has not been created yet. Confusing the two is one of the most common runtime crashes for developers coming from languages that auto-initialize collections.
Zero values and the unallocated state
Every variable in Go gets a default value the moment it is declared. Integers start at 0. Booleans start at false. Strings start at "". Slices and maps start at nil. This is the language's zero value philosophy: variables are never uninitialized garbage. They always hold a predictable default.
The catch is that nil for slices and maps means unallocated. Think of a slice like a clipboard holding a reference to a physical whiteboard. A nil slice is a blank clipboard. You can look at it and confirm it holds nothing, but you cannot write on a whiteboard that does not exist yet. An empty slice created with make([]int, 0) is a clipboard pointing to a real whiteboard that just happens to be clean. You can write on it immediately.
Maps work the same way. A map variable holds a pointer to a hash table managed by the runtime. nil means the pointer is null. There is no hash table to insert into or look up.
Go makes reading from a nil slice or map safe. It returns the zero value for the element type. Writing to a nil slice or map panics. The language forces you to acknowledge allocation before mutation.
The minimal difference
Here is the simplest way to see the boundary between unallocated and allocated.
package main
import "fmt"
func main() {
// Zero value: unallocated, safe to read, panics on write
var s []int
fmt.Println(s, len(s), s == nil) // prints: [] 0 true
// Allocated but empty: ready for mutation
s2 := make([]int, 0)
fmt.Println(s2, len(s2), s2 == nil) // prints: [] 0 false
// Literal allocation: equivalent to make for known sizes
s3 := []int{}
fmt.Println(s3, len(s3), s3 == nil) // prints: [] 0 false
}
The output shows three variables that all print as [] and all report a length of zero. Only the first one equals nil. The difference lives in memory, not in the printed representation. The fmt package deliberately prints nil slices and empty slices identically to avoid confusing beginners, which is exactly why the confusion persists.
Check the clipboard before you write.
What happens under the hood
A slice is not an array. It is a small descriptor struct that the compiler hides from you. It contains three fields: a pointer to an underlying array, a length, and a capacity. When you declare var s []int, all three fields are zeroed. The pointer is null. The length and capacity are zero.
When you call append(s, 1), the runtime checks the pointer. If it is null, append allocates a new array, updates the pointer, sets the length to one, and returns the new slice descriptor. That is why append works on nil slices. The function is designed to handle the unallocated state gracefully.
Direct index assignment skips that check. s[0] = 1 tells the runtime to dereference the null pointer and write at offset zero. The runtime catches the null dereference and panics with panic: runtime error: index out of range [0] with length 0.
Maps are pointers to a runtime-managed hash table. A nil map pointer means no hash table exists. The map[key] = value syntax tries to locate a bucket, insert the key, and update the table. Without an allocated table, the runtime panics with panic: assignment to entry in nil map. Map lookups on nil maps return the zero value for the value type and a boolean false, which matches the standard two-value lookup pattern.
The compiler cannot catch these panics at compile time because nil is a valid, type-correct value. The type system only guarantees that s is a slice of ints and m is a map of strings to ints. It does not guarantee allocation. That is a runtime concern.
Trust the zero value. Allocate before you mutate.
A realistic crash site
You are building a middleware function that aggregates request headers into a map for logging. You want to collect only the headers that start with X-Custom-.
package main
import (
"fmt"
"net/http"
"strings"
)
// CollectCustomHeaders builds a map of custom headers from a request.
func CollectCustomHeaders(r *http.Request) map[string]string {
// Zero value map: unallocated until make is called
custom := make(map[string]string)
for key, values := range r.Header {
// Skip non-custom headers to keep the log clean
if !strings.HasPrefix(key, "X-Custom-") {
continue
}
// HTTP headers can have multiple values; join them safely
custom[key] = strings.Join(values, ", ")
}
return custom
}
func main() {
// Simulate a request with mixed headers
req := &http.Request{
Header: http.Header{
"Content-Type": {"application/json"},
"X-Custom-Auth": {"token-123"},
"X-Custom-Debug": {"true"},
},
}
result := CollectCustomHeaders(req)
fmt.Printf("%+v\n", result)
}
If you remove the make call and leave var custom map[string]string, the loop reaches the assignment line and the program panics. The panic happens exactly where the mutation occurs. The stack trace points directly to the line. This is intentional. Go prefers a loud, immediate crash over silent data corruption or hidden state.
The convention here is straightforward. If a function returns a map or slice, initialize it with make or a literal before the first mutation. If you only read from it or pass it to append, the zero value is fine. The community treats make as the explicit boundary between declaration and readiness.
Declare intent. Initialize before mutation.
The JSON serialization trap
The nil versus empty distinction matters most when you cross boundaries. JSON marshaling is the classic trap.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// Unallocated slice
var s1 []string
// Allocated empty slice
s2 := make([]string, 0)
b1, _ := json.Marshal(s1)
b2, _ := json.Marshal(s2)
fmt.Println(string(b1)) // prints: null
fmt.Println(string(b2)) // prints: []
}
The encoding/json package treats nil slices and maps as null in JSON. It treats empty but allocated slices and maps as [] or {}. This behavior is deliberate. null signals absence. [] signals presence with zero items. Many APIs rely on this distinction to differentiate between "not provided" and "provided but empty".
If your API contract expects [] for empty results, you must allocate with make or a literal. If you accidentally return a nil slice, the client receives null and may interpret it as a missing field or a server error. The compiler will not warn you. The type is correct. Only the runtime representation differs.
Always initialize collections that cross serialization boundaries.
When to pick which form
Use a var s []T declaration when you plan to pass the slice to append or when the slice will remain empty until a conditional branch allocates it. Use make([]T, 0) when you need to return the slice to a caller that inspects len or marshals it to JSON and expects []. Use []T{} when you want a literal that is immediately ready for indexing or when you are initializing a struct field inline. Use make(map[K]V) when you intend to assign keys immediately or when the map will be returned to a JSON consumer. Use a nil map when you only perform lookups and treat missing keys as absent data.