skip to content

Why does a counter struct with a map field panic on first use, and how do you make the type safe to declare?

level: middleimportance: should knowfreq 66%

answer

  1. reads are fine, writes are not
  2. the map member is nil at zero
  3. the panic names the assignment, not the declaration
  4. make it lazily, or make it in New
  5. a slice member would not have this problem

basics

~20 s

The struct zero value holds a nil map, and writing to a nil map panics with "assignment to entry in nil map". Create the map on first write inside the method, or provide a New that makes it.

solid answer

~50 s

Go zero-fills the struct, so an uninitialised map field is nil. Reading a nil map is legal — you get the zero element and `ok == false` — but any assignment to a key panics, which is why a metrics daemon that declares `var c Counter` looks fine until the first `Add` and then dies with `assignment to entry in nil map`. There are three honest fixes. Create the map lazily inside the method that writes to it, under the mutex the type already holds, so `var c Counter` keeps working. Or accept that the zero value is unusable, export a `NewCounter` that calls `make`, keep the map field unexported so a struct literal cannot fake it, and say so in the doc comment. Or change the representation so nothing needs allocating — named `int64` fields, or a slice, since `append` works on a nil slice. Which one you pick is a promise to callers, so state it in the docs either way.

code

go · 10 lines
go
type Counter struct {
	mu     sync.Mutex
	counts map[string]int64
}

func (c *Counter) Add(name string, n int64) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[name] += n // panic: assignment to entry in nil map
}

go deeper

for a junior

Remember the asymmetry: reading from a nil map is fine, writing to one panics. If a struct has a map field, something has to call make before the first write.

for a middle

Be able to walk through all three repairs — lazy creation under the lock, a constructor plus an unexported field, or a representation with no map — and say what each costs. Naming the exact panic text is a good signal.

for a senior

Frame it as a failure-mode problem: the panic surfaces far from the declaration that caused it, so the fix is a type contract, not a patch at one call site. Explain how you would review every write path and document the zero-value promise.

for a principal

Decide the house rule. If your packages promise usable zero values, map members carry an ongoing lazy-init obligation on every write path; if they promise constructors, that must be uniform and documented, because a mixed convention is what leaves teams guessing.

## What actually happens A struct declared as ```go type Counter struct { mu sync.Mutex counts map[string]int64 } ``` has a perfectly ordinary zero value: an unlocked mutex and a **nil map**. Nothing about `var c Counter` is invalid, and nothing at compile time complains. The asymmetry that bites is in the map itself: - **Reading a nil map is legal.** `len(m)` is 0, `m["a"]` returns the element type's zero value, `v, ok := m["a"]` gives `ok == false`, and `for k := range m` iterates zero times. The language defines a nil map as an empty map you cannot grow. - **Writing to a nil map panics.** `m["a"] = 1` and `m["a"] += 1` both stop the program with `panic: assignment to entry in nil map`, and the stack trace points at the assignment line, not at the declaration that caused it. So a daemon whose collector is a plain struct field — `var collector Counter`, or a `Counter` field inside a bigger `Daemon` struct — passes review, starts up, serves traffic, and panics the first time a measurement is recorded. The distance between the cause (a declaration with no `make`) and the symptom (a panic in an unrelated method, possibly minutes later) is exactly what makes this a recurring production bug rather than a beginner's typo. ## Fix 1: create the map on first use If you want to keep the zero value usable, allocate inside the method that writes: The nil check must sit **inside** the critical section, not before it, because two goroutines can otherwise both see nil, both call `make`, and one of the two maps — with its counts — is silently thrown away. Cost: one branch per call, and every write path has to remember the check. Benefit: `var c Counter` works, the type embeds anywhere, and there is nothing for a caller to forget. ## Fix 2: require a constructor and say so Sometimes lazy creation is not worth it — there are many write paths, or the map is only one of several members that need building. Then be explicit: ```go // NewCounter returns a Counter ready for use. // The zero value of Counter is not usable. func NewCounter() *Counter { return &Counter{counts: make(map[string]int64)} } ``` Two details make this stick. Keep the map field **unexported**, so a caller in another package cannot build a working `Counter` with a struct literal and cannot accidentally build a broken one either — the only correct path is the constructor. And put the contract in the **doc comment on the type**: nothing in a Go type declaration tells a reader whether zero works, so the sentence "the zero value is not usable; call NewCounter" is the whole interface. A third, blunter option is to make the failure loud and immediate: a method can check `if c.counts == nil { panic("counter: use NewCounter") }`. That converts a confusing panic into an instructive one, but most Go packages prefer lazy creation over a hand-written panic. ## Fix 3: pick a representation whose zero value is already right The most durable fix is often to not need the map. Compare the member kinds: | Member | Zero value | Usable without setup? | |---|---|---| | `int64`, `string`, `bool`, `time.Duration` | 0, "", false, 0 | yes | | `[]T` | nil slice | yes — `len` is 0, `range` yields nothing, `append` allocates | | `bytes.Buffer`, `sync.Mutex`, `sync.Once` | empty / unlocked / not-yet-run | yes | | `map[K]V` | nil map | reads yes, **writes panic** | | `chan T` | nil channel | no — blocks forever, silently | | `func(...)` | nil func | no — calling panics | | `*T`, interface | nil | only if nil has a defined meaning | If the daemon really tracks a fixed set of measurements, a struct with named `int64` fields under the mutex has a correct zero value and is faster besides. If it accumulates a growing list, a nil slice already appends. Reach for the map only when the key set is genuinely dynamic — and then choose fix 1 or fix 2 deliberately. ## The review heuristic When you add a map, channel or func member to an exported struct, you have just changed the type's zero-value contract. Either restore it (lazy create, or a different representation) or document that it is gone and provide the constructor. Leaving the question unanswered is what produces the panic, and no compiler check will ask it for you.

  • If reading a nil map is legal, why did the language designers not make writing to one allocate?
    Because a map value is a reference to a header the caller shares. An assignment through a nil map would have to allocate and then store the new map somewhere the caller can see, which a method with a copy of the value cannot do. Reads need no such write-back, so they were made safe; writes fail loudly instead.
  • Where exactly should the nil check go if the type is used from several goroutines?
    Inside the critical section, after acquiring the mutex the type already holds. If the check runs before the lock, two goroutines can both observe nil, both call `make`, and one map — along with everything written into it — is discarded. It is also a data race that a `-race` build will report.
  • How do you stop callers from building a half-initialized value with a struct literal?
    Keep the members that must be allocated unexported. Code outside the package can then write only `Counter{}`, never `Counter{counts: nil}` with a false sense of control, and the constructor becomes the only way to get a working value. Pair it with a doc comment on the type stating that the zero value is unusable.

saying these in an interview costs you the question

  • Says writing to a nil map creates it on demand
  • Claims the compiler catches an uninitialized map field
  • Puts the nil check outside the lock and calls it safe
  • Thinks make on a nil map is needed before reading it too
  • Assumes a struct literal in another package can set unexported fields
  • Treats the panic as a bug in the map implementation