Skip to content
intermediate Phase 6 · Pointers, Allocation, and Memory Semantics

Nil Checks and Zero-Value Design

Handle nil receivers and empty collections without defensive boilerplate.

35m
0 problems
Topic Progress 0%

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?

Question 1 options

2. A worker goroutine does result := <-ch where ch was never initialized. What happens?

Question 2 options

3. func find() error returns var e *NotFound typed into an error interface. Why does the caller's if err != nil still fire?

Question 3 options

4. A struct with a map field panics when used from its zero value. Which design is idiomatic?

Question 4 options

Flashcards

Question

For a nil slice and a nil map, which operations work and which panic?

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?

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?

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?

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?

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.

Related Reading