Go slices are simple on the surface. Under the hood, the interaction between length, capacity, and append has a specific failure mode I've been bitten by more than once — and seen in enough code reviews to write about. The bug is invisible at compile time, often invisible in tests, and can corrupt data silently.
The three ways to initialize a slice
Before the bug, the mechanics. make for slices takes up to three arguments:
make([]T, length)
make([]T, length, capacity)
These behave very differently:
// Length 0, capacity 100
// Content: []
// append() fills from index 0
a := make([]int, 0, 100)
// Length 5, capacity 5
// Content: [0 0 0 0 0]
// Indexed assignment fills existing slots
b := make([]int, 5)
// Length 5, capacity 10
// Content: [0 0 0 0 0]
// Indexed assignment fills existing slots 0–4
// append() fills from index 5 onward
c := make([]int, 5, 10)
The key rule: append always starts after the last element, at index len(slice). It does not fill from the beginning. It does not know or care what's in the existing slots.
s := make([]int, 5, 10)
fmt.Println(s) // [0 0 0 0 0]
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 10
s = append(s, 42)
fmt.Println(s) // [0 0 0 0 0 42] ← 42 is at index 5, not 0
This is correct, expected behavior. The problem arises when you mix the two initialization patterns during a refactor.
The bug: optimization that creates holes
Here's the sequence of events. You have code that initializes a slice with a fixed length and fills it by index:
// Version 1: works correctly
results := make([]int, 5)
for i, job := range jobs {
results[i] = process(job)
}
// results: [r0 r1 r2 r3 r4]
This is fine. Five jobs, five slots, indexed assignment, everything lines up.
Later, someone (possibly future-you) decides to optimize memory allocation by pre-allocating capacity for growth:
// Version 2: "optimization"
results := make([]int, 5, 10) // added capacity
The loop still works. Indexed assignment into results[i] fills positions 0–4. No change in behavior yet.
Now, a separate change: the indexed loop gets refactored. Maybe the loop structure changes, maybe someone decides append is more idiomatic, maybe the jobs slice changes to a channel. The replacement looks like this:
// Version 3: refactored loop using append
results := make([]int, 5, 10)
for _, job := range jobs {
results = append(results, process(job))
}
This compiles. This runs. And this produces:
// results: [0 0 0 0 0 r0 r1 r2 r3 r4]
Five zero-value holes at the front, followed by the actual results. The slice now has length 10 instead of 5. Any code downstream that iterates results processes 10 elements instead of 5, with the first 5 being garbage zeros.
If the downstream code is a JSON response, you send extra zeros to clients. If it's an aggregation, your totals are wrong. If it's a database write, you insert rows you didn't intend to.
Why this is hard to catch
No compile error. Both indexed assignment and append are valid operations on a []int. The compiler has nothing to complain about.
Tests often miss it. If your test asserts len(results) == 5, it fails — but if it only checks specific indices or iterates the whole slice, the zeros look like valid data. A test against a jobs list where process legitimately returns 0 for some inputs will pass completely.
The change looks like a safe refactor. Changing results[i] = x to results = append(results, x) seems equivalent to anyone who hasn't internalized how append interacts with a non-zero initial length. The diff is small and the intent looks the same.
The capacity change seems unrelated. The make([]int, 5) to make([]int, 5, 10) change is in a different commit, possibly by a different person. The connection between that change and the later append refactor isn't obvious.
The three clean patterns — pick one and be consistent
The root cause is mixing patterns that don't compose. Each of these is correct on its own:
Pattern 1: Length 0, append only
// Correct: length 0, append fills from the start
results := make([]int, 0, len(jobs))
for _, job := range jobs {
results = append(results, process(job))
}
// results: [r0 r1 r2 r3 r4]
Use this when you're building a slice incrementally and don't need indexed access during construction. The capacity hint len(jobs) avoids reallocations without creating holes.
Pattern 2: Length N, indexed assignment only
// Correct: length N, indexed assignment fills all slots
results := make([]int, len(jobs))
for i, job := range jobs {
results[i] = process(job)
}
// results: [r0 r1 r2 r3 r4]
Use this when you're processing a collection with known size and need random-access writes (out-of-order filling, parallel writes with sync, etc.).
Pattern 3: Length N with capacity, indexed assignment only
// Correct: length N, capacity M, indexed assignment for first N slots
// append for subsequent elements
results := make([]int, len(jobs), len(jobs)*2)
for i, job := range jobs {
results[i] = process(job)
}
// Later appends go to indices N onward — intentional
results = append(results, extraResults...)
Use this only when you genuinely need both: a pre-filled region AND growth capacity. This is the rarest case and the one that most needs a comment explaining the intent.
The linter: makezero
The makezero linter catches exactly this class of mistake. It flags append calls on slices that were initialized with non-zero length, giving you a chance to examine whether the initialization and the append are both intentional.
go install github.com/ashanbrown/makezero@latest
makezero ./...
Or via golangci-lint (recommended — runs alongside your other linters):
# .golangci.yml
linters:
enable:
- makezero
What it flags:
results := make([]int, 5, 10)
results = append(results, 42)
// makezero: append to slice results initialized with non-zero length at line N
What it does NOT flag (both are intentional patterns):
// Pattern 1: length 0, append is fine
a := make([]int, 0, 10)
a = append(a, 42) // ✅ no warning
// Pattern 2: length N, no append
b := make([]int, 5)
b[0] = 42 // ✅ no warning
The linter doesn't tell you your code is wrong — it tells you that you have a combination that might be wrong and deserves a look. That's the right level of signal. The version 3 code from the bug scenario above would have triggered this warning, prompting a review of the initialization before the append refactor shipped.
Adding an explicit comment when you genuinely mix patterns
If you have a legitimate reason to initialize with non-zero length and later append, say so:
// Pre-fill the first slot with a sentinel value.
// Actual results are appended starting at index 1.
results := make([]Result, 1, len(jobs)+1)
results[0] = Result{sentinel: true}
for _, job := range jobs {
results = append(results, process(job)) //nolint:makezero // intentional: sentinel at index 0
}
A comment here, paired with a linter suppression (//nolint:makezero), tells reviewers and future maintainers that the mixed pattern is deliberate. Without it, the next person to read the code — or the next refactor — will make the same assumption the bug relied on.
Quick mental checklist for slice initialization
Before writing make([]T, n, m) ask:
- Will I use indexed assignment or append? Pick one. If indexed, length = final size. If append, length = 0.
-
Do I need pre-filled slots AND append? Only then use
make([]T, n, m)with n > 0. Add a comment. -
Is the capacity hint right?
make([]T, 0, len(input))is almost always the right pattern for transforming one slice into another.
The simplest version of this rule: if you're using append, start with length 0.
Summary
make([]T, 0, cap) — Use with: append only. Risk if mixed: none — this is the safe default.
make([]T, n) — Use with: indexed assignment only. Risk if mixed: none if consistent.
make([]T, n, m) — Use with: indexed assignment for the first n, append after. Risk if mixed: high — easy to introduce holes during a refactor.
The bug isn't exotic. It's the interaction of two individually correct operations that become incorrect when combined without awareness of how append uses len, not cap, to decide where to write. The makezero linter closes the gap that the compiler leaves open.
This is part of a series on Go patterns and production gotchas. Previous posts cover concurrency mistakes, typed constants, context propagation, and profiling.
Top comments (0)