1. Why preallocate slice capacity
When beginners hear the word “performance,” they often switch into a mode like “I haven’t even broken my calculator in a loop yet, and you’re already talking about optimization.” That’s a normal reaction. But today we won’t measure nanoseconds or argue about runtime philosophy. We’ll cover one very practical and safe technique: preallocating slice capacity so that append in a loop doesn’t do extra copying and extra memory allocations.
Imagine you’re building a user’s task list (strings) or collecting numbers from input. The most natural approach is to start with an empty slice and call append in a loop. It works, and that’s already good. But if you know in advance (or at least suspect) how many elements there will be, you can help Go make this more predictable and cheaper.
It’s important to set the boundary right away: we’re not doing “magic optimization”; we’re simply choosing a more suitable form of make. The code stays readable, and the program often “twitches” less in memory.
append in a loop sometimes becomes “expensive”
When you write s = append(s, x), it looks like a simple “add to the end” operation. And often it is. But sometimes append ends up in the situation: “no more space.” Then it runs roughly this scenario (simplified):
- allocates a new backing array (usually larger than the previous one),
- copies the old elements into it,
- adds the new element,
- returns a new slice (with a new pointer and a new cap).
This isn’t “bad” and it isn’t a “mistake”; it’s a normal part of development. In early versions of Go, programmers even wrote this by hand, and the appearance of append as a built-in function is exactly what removed the routine list-growth code. The Go blog even shows an example of “manual append” from the old days that later inspired the built-in append.
But if you do this inside a large loop, you get an unpleasant effect: you think you’re adding 100000 elements, but in reality you periodically trigger an “office move”: renting a new space and moving all the desks.
Helpful mental picture:
flowchart TD
A[Have slice: len=8 cap=8] -->|append 1 more| B{enough cap?}
B -->|yes| C[Write to the same backing array]
B -->|no| D[Allocate a new backing array]
D --> E[Copy 8 elements]
E --> F[Add the new one]
F --> G[Return a new slice header]
Key idea: reallocations do not happen on every operation, but each reallocation can cost noticeably more than a regular append. So if we have a chance to reduce their number—why not.
2. The pattern and the correct make form
Preallocating capacity via make(..., 0, cap)
Preallocation means we create a slice with zero length, but with a predefined capacity: make([]T, 0, n). The meaning sounds almost like a programmer’s toast: “We have nothing right now, but space for N elements is already reserved.”
Why exactly len = 0? Because len in Go is “how many elements actually exist and are accessible,” not “how many I plan to have.” If you plan to add via append, it’s more convenient to start with an empty list so you don’t end up with “extra zeros”/empty values. We’ve already seen this: make([]int, 5) creates five zeros, and then append adds the sixth element after them.
As a result, the pattern for “collect elements” looks like this:
- create s := make([]T, 0, expectedSize)
- in a loop do s = append(s, ...)
That’s it. No secret packages, no complex structures.
make([]T, n) vs make([]T, 0, n)
This difference seems small until you catch a bug like “why do I get five empty strings first, and then my data.” So let’s compare the two options slowly and honestly. Imagine you’re preparing a box for items.
- make([]T, n) — you already put n items inside (still empty/zero-valued); the box is “filled by length.”
- make([]T, 0, n) — the box is empty, but it has n free slots so you can add quickly.
Table to lock it in:
| Expression | len immediately | cap immediately | What actually “exists” inside | When it’s convenient |
|---|---|---|---|---|
|
|
|
already has n elements with value 0 | when you will fill by index s[i] = ... |
|
|
|
no elements, but allocated memory for growth | when you will build up via append |
A mini example so your brain stops arguing with your eyes:
package main
import "fmt"
func main() {
a := make([]int, 5) // len=5: already 5 zeros
b := make([]int, 0, 5) // len=0: empty, but cap=5
fmt.Println(a, len(a), cap(a)) // [0 0 0 0 0] 5 5
fmt.Println(b, len(b), cap(b)) // [] 0 5
}
Note: the second variant prints as [], but it’s not a nil slice; it’s a regular empty slice, just with extra capacity reserved.
3. Practice: TaskBox and cap growth
Example: building a task list
From this point on, let’s assume we’re writing a small console app TaskBox: the user enters the number of tasks, then the tasks themselves (for now, let them be single-word so fmt.Fscan doesn’t struggle), and the program prints a numbered list. Yes, it’s not a full todo manager yet, but we are already doing something useful: collecting a string collection.
First, let’s show the naive version: start from nil/empty and grow. It’s correct.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
in := bufio.NewReader(os.Stdin)
var n int
fmt.Fscan(in, &n)
var tasks []string // nil slice
for i := 0; i < n; i++ {
var t string
fmt.Fscan(in, &t)
tasks = append(tasks, t)
}
fmt.Println(tasks) // for example: [buy_code sleep repeat]
}
Now let’s improve this same code with exactly one idea: since n is known, let’s preallocate capacity.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
in := bufio.NewReader(os.Stdin)
var n int
fmt.Fscan(in, &n)
tasks := make([]string, 0, n) // len=0, cap=n
for i := 0; i < n; i++ {
var t string
fmt.Fscan(in, &t)
tasks = append(tasks, t)
}
fmt.Println(tasks) // [buy_code sleep repeat]
}
The logic didn’t change, readability didn’t get worse, but now we very likely avoided a series of reallocations during growth, because the backing array is allocated at the required size immediately.
And let’s add a nice output (without complications, just so there’s a result “like in an app” rather than “like in a lab”):
package main
import "fmt"
func main() {
tasks := []string{"buy_code", "sleep", "repeat"}
for i, t := range tasks {
fmt.Printf("%d) %s\n", i+1, t)
}
// 1) buy_code
// 2) sleep
// 3) repeat
}
Yes, these are still simple strings. We won’t touch task structures or a richer model—our goal right now is specifically the slice workflow pattern.
How to see reallocations with your own eyes via cap
Preallocation is especially easy to understand once you see cap “jumps” during append. We won’t measure time or write benchmarks—for a beginner, that often turns into a shamanic ritual. We’ll do simple diagnostics: print len and cap, and separately count how many times cap changed.
Here’s a mini observation of “how a slice grows”:
package main
import "fmt"
func main() {
s := make([]int, 0) // without preallocation
prevCap := cap(s)
for i := 0; i < 10; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Println("cap changed:", prevCap, "->", cap(s))
prevCap = cap(s)
}
}
}
You will see several cap changed: ... lines, and how many you get depends on how the runtime decides to increase capacity. The important thing is not to “memorize numbers,” but to catch the principle: cap changes rarely, but each change is a sign of growth/reallocation.
Now let’s compare it with preallocation. We’ll explicitly set cap=10 so that within 10 appends there are no expansions:
package main
import "fmt"
func main() {
s := make([]int, 0, 10) // preallocated
prevCap := cap(s)
for i := 0; i < 10; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Println("cap changed:", prevCap, "->", cap(s))
prevCap = cap(s)
}
}
fmt.Println("final:", len(s), cap(s)) // final: 10 10
}
With high probability, you won’t see a single line about cap changed (because there’s nowhere to grow and no need to), and the final result will be len=10 and cap=10. In other words, we turned growth into a predictable story: “reserve space and fill calmly.”
How to choose cap if the exact size is unknown
In real life, you often don’t know the exact number of elements. Then the question is: “What do you put in make(..., 0, cap)—a magic number 42?” (42 is allowed, but it’s better to be intentional.)
A useful way to think about it: cap is not a “commitment,” it’s a hint. If you set cap smaller than needed, the program won’t break: append will just start expanding later. If you set cap larger than needed, the program also won’t break, but you will allocate a bit more memory upfront. So choosing cap is a balance between “avoid extra reallocations” and “avoid extra allocation.”
Typical practical situations look like this.
If the size is known exactly (as in our TaskBox, where n is read first), you simply set cap = n and don’t think further. This is the ideal scenario.
If an upper bound is known (for example, “there will be no more than 1000 elements”), you can set cap to that upper bound if the memory usage doesn’t scare you. For []string this can already be noticeable, while for []int it’s usually tolerable, but we won’t turn this lecture into a byte calculator. The idea is simple: an upper bound is a good candidate.
If the size is completely unknown, you can either not preallocate at all, or preallocate a “modest start” (for example, 16 or 32) so that the first expansions don’t happen at every step. It’s like choosing a grocery bag: you can take a tiny one and suffer, you can take a slightly bigger one “just in case,” but you don’t carry a huge box if you’re buying a single loaf of bread.
Remember: algorithm correctness must not depend on cap. cap is about efficiency and predictability, not about the meaning of the result.
4. Common mistakes with preallocation and append in a loop
Mistake #1: use make([]T, n) and then build via append, getting “extra zeros.”
This is one of the most common traps. make([]int, 5) creates five existing elements, and if you then call append, you add elements after them. If what you wanted was “an empty list with capacity 5,” then the correct form is make([]int, 0, 5).
Mistake #2: confuse cap with “data size” and try to index up to cap.
Even if cap(s) == 100, but len(s) == 0, the expression s[0] is a runtime error (panic). The access boundary is always len, while cap only tells you how far you can grow without expanding the backing array.
Mistake #3: forget to store the result of append in a loop (or accidentally lose it because of :=).
In a loop you almost always must write s = append(s, x). If you write just append(s, x), then len “outside” won’t change. And if inside a block you habitually write s := append(s, x), you can accidentally create a new variable and then wonder why “nothing got added outside.” This is especially nasty because it looks almost correct.
Mistake #4: try to do “perfect optimization” and rely on specific cap growth numbers.
Go does not promise that cap grows exactly by 2× or by any particular formula. Those are implementation details, not a language contract. So diagnosing growth is useful, but writing logic that “expects cap=64 on the 8th step” is a path to weird bugs and even weirder interviews.
Mistake #5: preallocate a gigantic capacity “just in case” without a reason.
Preallocation is a good tool, but it should not turn into a habit of “allocate a million, what if it’s needed.” Especially for []string, where each element is not only text, but also additional data structures. It’s better to preallocate when you have knowledge: an exact size, an upper bound, or a reasonable estimate.
GO TO FULL VERSION