nil Is Six Different Behaviors, Not One
A single keyword, six distinct contracts — and most nil bugs come from assuming one type behaves like another. Line them up:
var (
p *int
s []int
m map[string]int
f func() int
ch chan int
)
fmt.Println(len(s)) // 0 — nil slice is a working empty slice
fmt.Println(m["x"]) // 0, and comma-ok gives false — reads are safe
// fmt.Println(*p) // panic: nil pointer dereference
// m["x"] = 1 // panic: assignment to entry in nil map
// f() // panic: runtime error: invalid memory address
// <-ch // blocks forever — no panic, no progress
The pattern behind the table: nil is safe wherever the operation needs no state, and fatal wherever it does. A nil slice answers len, cap, range, and even append (which allocates fresh storage) because those operations can proceed from an empty description. A nil map answers reads because "missing key" needs no buckets, but the first write panics — buckets are the state. A nil pointer or func panics on dereference/call because there is no target. A nil channel blocks on send and receive: the operation waits for a rendezvous that will never arrive, which is rarely what you want and never produces an error message.
Channels are the trap with the worst failure mode — <-ch on a nil channel hangs the goroutine silently instead of crashing, so the bug surfaces as a stuck service rather than a stack trace. Funcs have the mirror-image rule: nil funcs panic at call time, which is why optional callbacks must either be checked with if cb != nil or defaulted before use.
Interfaces get their own chapter later (Interface Basics), including the typed-nil problem, but the working rule stands now: err != nil and x != nil are checks you write at boundaries, and every nil-producing return value in your API owes the caller either a documented nil case or a design where nil is harmless. Learn the table per type — guessing is how nil ends up in an incident report.
Design the Type So Nobody Needs Your Constructor
The standard library's most reused types share one property: they work the moment they exist.
var buf bytes.Buffer // no new, no make, no NewBuffer()
buf.WriteString("hi")
var mu sync.Mutex // zero value is an unlocked lock
mu.Lock()
var b strings.Builder // ready to grow
Contrast the design that fails on contact:
type Tally struct {
mu sync.Mutex
count map[string]int // zero value: nil
}
func (t *Tally) Inc(k string) {
t.mu.Lock()
defer t.mu.Unlock()
t.count[k]++ // panics if the zero value was used — nil map write
}
A var t Tally — perfectly reasonable code — explodes on first use. Two sanctioned fixes, pick one per type:
// Fix A: lazy init — the zero value stays legal.
func (t *Tally) Inc(k string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.count == nil {
t.count = map[string]int{}
}
t.count[k]++
}
// Fix B: constructor owns initialization — and every doc comment says so.
func NewTally() *Tally {
return &Tally{count: make(map[string]int, 64)}
}
Lazy init shines for types embedded in other structs or declared as package-level vars, where forcing every site through a constructor is friction with no payoff. The constructor shines when initialization has real work — opening a file, sizing a pool, starting a goroutine — or when you must guarantee invariants before any method runs. What is not acceptable is silence: a type that panics under its zero value and documents nothing.
Zero-value friendliness compounds. Structs containing your type embed without ceremony; make-free declarations read cleanly; tests build fixtures with field literals; generics instantiate var v T without knowing your constructor's name. The cost is one if x == nil or one init branch per reference-shaped field. For a map-backed type, that branch is the entire insurance policy — and it is exactly the pattern new, make, and Choosing Allocation warns you about when new(map[K]V) hands someone a pointer to nil storage.
Checks at the Boundary, Contracts in the Middle
Nil safety fails in two opposite directions: checking nothing where nil is real, and checking everything everywhere because nil might appear somewhere. The discipline that works — put checks where nil enters or leaves your code, and let the interior assume the contract holds.
// Boundary: the pointer arrives from a caller you don't control.
func (s *Server) Handle(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config is required")
}
// Interior: no more nil checks — every function below may trust cfg.
return s.route(cfg.Routes)
}
Every interior if cfg != nil past that gate is noise that hides the one check that matters and suggests the invariant is optional when it is not. The same applies to returns: if a lookup can miss, say so in the signature — (*T, bool) or (T, error) — rather than returning nil and hoping the caller notices (Pointers as Function Parameters). Optional values deserve explicit types: *T fields where nil means "unset", ok where absence is data, errors.Is(err, ErrNotFound) where absence is a condition.
The trap waiting at the error boundary is the typed nil:
type NotFound struct{ Key string }
func (e *NotFound) Error() string { return "not found: " + e.Key }
func find(key string) error {
var e *NotFound
return e // WRONG: interface holds (*NotFound, nil)
}
err := find("x")
if err != nil { // TRUE — the interface's type word is non-nil
fmt.Println("failed") // runs, although nothing "failed"
}
An interface is a pair: dynamic type plus value. err != nil asks whether that pair is empty, and (type=*NotFound, value=nil) is not empty. The fix is at the return, not the check: if miss { return nil }, returning an untyped nil, and keeping nil-ness out of concrete types you hand back. Watch the mirror case too — var e *NotFound; e.Error() is legal if Error() handles a nil receiver, which is a feature some optional-value types exploit deliberately (Value Semantics and Copies covers why receivers matter). Nil is a normal, documented inhabitant of your API's domain — the failure mode is pretending otherwise in either direction.
Quiz
1. var m map[string]int is never initialized. Which line panics at runtime?
2. A worker goroutine does result := <-ch where ch was never initialized. What happens?
3. func find() error returns var e *NotFound typed into an error interface. Why does the caller's if err != nil still fire?
4. A struct with a map field panics when used from its zero value. Which design is idiomatic?
Flashcards
Question
For a nil slice and a nil map, which operations work and which panic?
Click to reveal answer
Answer
Nil slice: len, cap, index-read, range, append, and reslicing all work (append allocates fresh storage). Nil map: reads, len, delete, and range work, but the first write panics with 'assignment to entry in nil map'. The dividing line is whether the operation needs storage the nil value does not have.
Question
What makes a zero value 'usable', and what does a type with a map field owe its callers?
Click to reveal answer
Answer
Usable means every method works on var T with no constructor — bytes.Buffer and sync.Mutex are the models. A map-backed type cannot satisfy that raw, so it owes a choice made explicit: lazy-init the map on first write (zero value legal) or export NewT and document that construction is required. Silence is the only wrong answer.
Question
Where should nil checks live in a codebase, and why not everywhere?
Click to reveal answer
Answer
At boundaries: parameters entering from callers, pointers/nils leaving as results, and interface values whose provenance is unclear. Checking again in interior code implies the invariant is optional, buries the real guard, and lets a broken contract drift through layers instead of failing where it entered.
Question
Why is err != nil true when the concrete *MyErr inside err is nil?
Click to reveal answer
Answer
An error interface is (dynamic type, value). Returning a nil *MyErr as an error stores the type *MyErr with a nil value, and the pair is non-empty — so the interface is not nil even though its payload is. Return an untyped nil from the function instead of boxing a nil pointer.
Question
What failure mode do nil channels and nil funcs have, and how does each surface?
Click to reveal answer
Answer
Calling a nil func panics immediately — visible, stack-traced, recoverable. Sending or receiving on a nil channel blocks forever with no error — the goroutine wedges and the symptom is a hung service discovered by timeout, not by logs. Optional callbacks therefore need an if cb != nil guard or a no-op default.
Revision Notes
Key Takeaways
- 1. nil is type-dependent: slice reads fine, map writes panic, nil funcs panic on call, and nil channels hang silently
- 2. Zero-value design is an API contract — lazy init or a constructor, documented; a map field without either is a landmine
- 3. Nil checks belong at boundaries; interior re-checks weaken the invariant they pretend to protect
- 4. Typed nils make err != nil lie: the interface's type word is set, so fix the return, not the check
- 5. Optional values deserve representation — *T for unset, comma-ok for presence, errors.Is for conditions — not sentinel values
Interview Tips
- • "What does a nil slice do?" — len 0, range fine, append works; contrast with nil map (write panics) and nil channel (blocks) in the same breath
- • "How do you design a type with a map field?" — present lazy init versus constructor and say which you'd pick for embedded structs
- • Defend your error handling: 'returns untyped nil on success — typed nils break err != nil' shows you've been bitten
- • "Where do you nil-check?" — at boundaries, then explain that interior checks dilute the contract; interviewers look for that judgment
Cheat Sheet
Nil Checks and Zero-Value Design
Behavior table:
| Type | On use of nil |
|---|---|
*T |
dereference panics; == nil check works |
| slice | fully usable (len/range/append); nil differs from empty only in == nil and JSON null |
| map | reads/delete/range OK · write panics |
| func | call panics — guard optional callbacks |
| chan | send/receive blocks forever — silent hang |
| interface | != nil checks the (type, value) pair — typed nils pass as non-nil |
Zero-value contract (pick one per type):
// A: lazy init — var usable
func (t *Tally) Inc(k string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.count == nil {
t.count = map[string]int{}
}
t.count[k]++
}
// B: constructor — documented as required
func NewTally() *Tally { return &Tally{count: make(map[string]int, 64)} }
Typed-nil rule: never return a nil *Concrete as an interface — return nil, not var e *MyErr; return e.
Placement rule: check where values cross boundaries; inside, trust the contract instead of re-checking.
Continue Learning
Before You Start
- No prerequisites listed.