skip to content

Why does writing to a nil Go map panic when reading from one works fine?

level: middleimportance: must knowfreq 68%

answer

  1. the zero value refers to no table
  2. reads have a harmless answer, writes do not
  3. len, range and delete all tolerate it
  4. the panic message names an assignment

basics

~20 s

A nil map has no hash table behind it. Reads, len, range and delete are defined to behave as if it were empty, but a write has nowhere to store the entry, so it panics with "assignment to entry in nil map". Create the map with make or a map literal.

solid answer

~50 s

`var m map[string]int` declares a map variable whose value is `nil` — there is no hash table allocated for it. The language deliberately defines the read side of a nil map to behave like an empty one: `m[k]` returns the zero value with `ok == false`, `len(m)` is 0, `range m` yields nothing and `delete(m, k)` is a no-op. Only an assignment has nowhere to go, and it panics at runtime with `assignment to entry in nil map`. The fix is to allocate: `m := make(map[string]int)` or `m := map[string]int{}`. In real code the bug almost never looks like a bare `var`: it is a struct whose map field was never initialised because the value was built with a composite literal or `new(T)` instead of its constructor, so the panic surfaces on the first write, far from the declaration.

code

go · 9 lines
go
var m map[string]int // nil map

fmt.Println(len(m))  // prints: 0
fmt.Println(m["x"])  // prints: 0
delete(m, "x")       // no-op, no panic
for range m {        // zero iterations
}

m["x"] = 1 // panic: assignment to entry in nil map

go deeper

for a junior

Remember that a declared-but-not-made map is nil, that you can read and range it safely, and that the first write panics. Know both ways to allocate one: make and the empty literal.

for a middle

Explain the asymmetry: reads have a well-defined empty-map answer, a write has nowhere to store the entry and cannot hand a new header back to the caller. Contrast with append on a nil slice.

for a senior

Show how a nil map field reaches production — a struct built with a composite literal or decoded from a payload that omitted the key — and how you make the type safe: constructor, unexported fields, or a usable zero value with lazy initialisation.

for a principal

Own the API decision. Whether your exported types have a usable zero value or require a constructor is a contract other teams will build on, and changing it later breaks every caller who wrote T{} by hand.

## What a nil map actually is A map variable in Go holds a small header value that refers to a hash table; the zero value of that header is `nil`, meaning it refers to no table at all. So: ```go var m map[string]int // m is nil fmt.Println(m == nil) // true ``` is a perfectly valid, usable-but-empty map variable, while `make(map[string]int)` allocates the table and returns a non-nil map. `map[string]int{}` — the empty composite literal — is also non-nil. ## The asymmetry, and why it is deliberate Everything that only *observes* a map is defined on a nil map to behave exactly as it would on an empty one: - `v := m[k]` yields the zero value of the value type. - `v, ok := m[k]` yields the zero value and `false`. - `len(m)` is `0`. - `for k, v := range m` executes zero iterations. - `delete(m, k)` does nothing and does not panic. Only an assignment `m[k] = v` panics, with the runtime message `assignment to entry in nil map`. The reasoning is that the read side has an obvious, harmless answer — an empty map's answer — so demanding a nil check at every read would be pure ceremony. A write has no harmless answer: there is nothing to write into, and silently allocating a table would mean the value stored is dropped as soon as the local map variable goes out of scope, since the caller's copy of the header would still be nil. Panicking is the honest option. Note that Go does *not* extend this symmetry to a `nil` slice, and the contrast is worth holding: `append` on a nil slice works, because `append` returns a new slice header you must assign. There is no equivalent "returns a new map" operation, so the map case has to fail. ## Where nil maps actually come from A bare `var m map[string]int` followed by a write is a beginner mistake that the compiler cannot catch but that reviewers spot instantly. The version that survives into production is structural: ```go type Index struct { postings map[string][]int } func NewIndex() *Index { return &Index{postings: map[string][]int{}} } func (ix *Index) Add(term string, doc int) { ix.postings[term] = append(ix.postings[term], doc) } ``` `Add` is correct for any `*Index` that came out of `NewIndex`, and panics for one built as `&Index{}` or `new(Index)`. That second spelling shows up in exactly two places: another package that only sees the exported struct, and a unit test that constructs a fixture by hand. The failure is a hard panic on the first write, which makes it cheap to reproduce — a two-line test that builds the zero value and calls `Add` pins the bug immediately and stays as the regression guard. Other common sources: a map field inside a struct decoded from a payload where the corresponding key was absent (`encoding/json` leaves it nil rather than allocating an empty map); a map returned by a function on its error path where the author returned the zero value and the caller kept writing to it; and a map obtained from another map or slice of structs, where the zero-valued element brings a nil field with it. A subtle related note: `ix.postings[term] = append(ix.postings[term], doc)` reads *and* writes. The read of a nil map is fine, `append` on the resulting nil slice is fine, and the panic comes from the assignment — so people often misdiagnose the line as an `append` problem. ## Defensive shapes Three ways to make this class of bug impossible, in rough order of preference: 1. **Initialise at construction.** A constructor that allocates every map field, and an unexported struct so callers cannot build the zero value themselves. 2. **Lazy initialisation in the writer.** `if ix.postings == nil { ix.postings = map[string][]int{} }` at the top of the mutating method. This makes the zero value usable, which is idiomatic Go, at the cost of a check per write and a note that it is not safe to do from multiple goroutines. 3. **Document the parameter.** A function that takes a map and writes into it should say the map must be non-nil, or accept and return the map instead so it can allocate. A `nil` map is also not the same as an empty one for `encoding/json`: a nil map marshals to `null`, an empty non-nil map to `{}`. Recovering from the panic instead of fixing the initialisation is always the wrong answer — the panic is a programming error, not a runtime condition.

  • Why is append to a nil slice fine when assignment to a nil map is not?
    `append` returns a new slice header that you assign back, so it can allocate a backing array and hand it to you. A map assignment produces no value to hand back; allocating a table inside the write would leave the caller's nil header untouched, so the entry would be lost. Panicking is the only honest outcome.
  • How does encoding/json treat a nil map field versus an empty one?
    A nil map marshals to `null`; an empty non-nil map marshals to `{}`. On the way in, unmarshalling a JSON object into a nil map field allocates it, but a payload that omits the key leaves the field nil — which is exactly how a nil map reaches code that later writes to it.
  • When would you lazily initialise a map field instead of doing it in a constructor?
    When you want the struct's zero value to be usable, which is an idiomatic Go goal. Guard each mutating method with `if m.f == nil { m.f = map[K]V{} }`. The costs are a branch per write and the fact that the lazy allocation is a write itself, so it needs the same discipline as any other mutation of shared state.

saying these in an interview costs you the question

  • Says reading from a nil map panics as well
  • Says delete or len on a nil map panics
  • Thinks var m map[string]int is equivalent to make(map[string]int)
  • Blames append rather than the assignment in m[k] = append(m[k], v)
  • Wraps the write in recover instead of initialising the map