The hidden cost of growing slices
You are processing a CSV file with a million rows. You read each line, parse the fields, and append the result to a slice. The first thousand rows fly by. Then the program slows down. The CPU usage spikes. The profiler shows runtime.growslice eating half your time. You didn't write that function. The runtime did. It is resizing the slice behind your back, copying data over and over because you didn't tell it how much space to reserve.
This happens when you start with an empty slice and append blindly. Go has to guess how big the slice needs to grow. It allocates a small array, fills it, allocates a bigger array, copies everything, and repeats. Each copy wastes CPU cycles and fragments memory. Preallocation solves this by reserving the backing array upfront. You pay for the memory once. Appends become fast writes. No copying. No allocation spikes.
How slices actually work
A slice in Go is not an array. It is a lightweight descriptor that points to an array. The slice header contains three values: a pointer to the underlying array, the length (how many elements are valid), and the capacity (how many elements fit in the array).
// The runtime representation of a slice header.
// You cannot access this struct directly, but it explains slice behavior.
type slice struct {
array unsafe.Pointer // Pointer to the first element of the backing array
len int // Number of elements currently in the slice
cap int // Total number of elements the backing array can hold
}
When you create a slice with make([]int, 0, 1000), Go allocates an array of 1000 integers on the heap. The slice header gets a pointer to that array, length 0, and capacity 1000. The length is zero because the slice is empty. The capacity is 1000 because the array has room for 1000 items. When you append, Go writes to the next slot in the array and increments the length. As long as the length stays below the capacity, nothing changes in memory. The append is just a store instruction and an increment.
When the length hits the capacity, Go must grow the slice. It allocates a new, larger array. It copies all existing elements from the old array to the new one. It updates the slice header to point to the new array. The old array becomes garbage. This reallocation is expensive. It costs CPU time for the copy and memory pressure for the allocation. Preallocation avoids reallocation by setting the capacity high enough to hold all expected elements.
Minimal example
Here is the syntax for preallocation. You use make with three arguments: the type, the length, and the capacity.
// Preallocate a slice with zero length but room for 1000 items.
// The first argument is the type. The second is length (starts empty).
// The third is capacity (reserves memory for 1000 ints).
items := make([]int, 0, 1000)
// Appending stays within capacity. No reallocation happens.
// Go writes to the reserved slot and increments length.
items = append(items, 42)
// The slice now has length 1 and capacity 1000.
// The backing array still has space for 999 more items.
fmt.Println(len(items), cap(items)) // prints: 1 1000
If you know the exact final size and you will fill the slice by index rather than appending, you can set the length equal to the capacity. This creates a slice where every index is initialized to the zero value.
// Create a slice with length 100 and capacity 100.
// All 100 elements are initialized to zero.
// You can assign by index without appending.
scores := make([]int, 100)
// Direct assignment works because length matches capacity.
scores[0] = 95
scores[99] = 88
Convention aside: always assign the result of append. Go does not modify slices in place. The append function returns a new slice header. If you write append(items, 42) without assigning items = append(items, 42), the append happens, the result is discarded, and items stays the same. The compiler allows this because ignoring return values is legal in Go. This is a common bug that wastes CPU and leaves data missing.
What happens without preallocation
When you append to a slice that is full, Go does not just add one slot. Adding one slot at a time would make appends slow. If you have 1 million items and append one more, allocating a single new slot and copying 1 million items is fine for that operation, but doing this repeatedly leads to quadratic performance. Go uses a doubling strategy. When the slice grows, Go allocates a new array with roughly double the capacity.
This keeps the amortized cost of append constant. Most appends are cheap. Occasionally, a reallocation happens, but the new capacity is large enough that the next reallocation is far away. The math works out to O(1) per append on average. The problem is the spike. The reallocation copies all existing data. If you are processing a stream with strict latency requirements, that copy can cause a pause. Preallocation removes the spikes. You get predictable performance.
The doubling strategy also wastes memory temporarily. When Go doubles from 100 to 200 slots, you use 200 slots of memory while you only have 100 items. The old 100-slot array sits in memory until the garbage collector reclaims it. If you preallocate, you allocate the final size once. There is no old array to clean up. Memory usage is stable.
Realistic example: parsing with known size
Real code often knows the size ahead of time. Maybe you read a header that specifies a record count. Maybe you iterate over a map and know the map size. Preallocation shines here.
// ParseRecords reads a count from the header and preallocates the result slice.
// This avoids reallocations when the count is large.
func ParseRecords(data []byte) []Record {
// Extract count from the first 4 bytes.
// In production code, validate bounds and handle errors.
count := binary.BigEndian.Uint32(data[:4])
// Preallocate based on the known count.
// Length is 0 because we fill the slice in the loop.
// Capacity matches the count to prevent growth.
records := make([]Record, 0, count)
for i := uint32(0); i < count; i++ {
// Parse each record from the fixed-size block.
// Appends are cheap because capacity is reserved.
rec := parseOneRecord(data[4+i*32 : 4+(i+1)*32])
records = append(records, rec)
}
return records
}
Preallocation is dangerous if the size comes from untrusted input. If a user sends a file claiming it has a billion records, make([]T, 0, 1_000_000_000) will crash your server with an out-of-memory error. The standard library handles this by validating the count against the actual data size. In archive/zip, the code checks that the claimed directory size matches the file size before allocating the slice of files.
// Safety check from archive/zip: validate count against file size.
// The code ensures the file is large enough to hold the claimed records.
// Each record takes at least 30 bytes. If the math fails, skip preallocation.
if end.directorySize < uint64(size) && (uint64(size)-end.directorySize)/30 >= end.directoryRecords {
// Safe to allocate. The file contains enough bytes for the claimed count.
r.File = make([]*File, 0, end.directoryRecords)
} else {
// Count looks suspicious or file is too small.
// Fall back to empty slice. Appends will grow it safely.
r.File = make([]*File, 0)
}
Always validate external sizes. Cap the capacity if the number looks suspicious. A common pattern is to check if count > maxSafeSize { count = maxSafeSize } before calling make. This prevents denial-of-service attacks via memory exhaustion.
Pitfalls and runtime errors
Preallocation has trade-offs. The biggest risk is over-allocation. If you reserve 1 million slots but only use 100, the slice still points to a 1 million-slot array. The garbage collector sees that array as in use. You waste memory. To release the memory, you must create a new slice with smaller capacity. This requires copying the used elements.
// Shrinking a slice requires a copy.
// Trimming length does not reduce capacity.
large := make([]int, 0, 1_000_000)
large = append(large, 1, 2, 3)
// Length is 3, but capacity is still 1,000,000.
// The backing array holds 1 million ints in memory.
fmt.Println(len(large), cap(large)) // prints: 3 1000000
// To shrink capacity, create a new slice and copy.
// This allocates a small array and copies the 3 elements.
small := make([]int, len(large))
copy(small, large)
// small now has length 3 and capacity 3.
// The large backing array can be garbage collected.
Another pitfall is holding references to the backing array. If you take a pointer to an element in a preallocated slice, and then the slice grows, the pointer might become invalid. This cannot happen with preallocation if you sized correctly, but it is a general slice hazard. Go's garbage collector moves objects, so pointers to slice elements are fragile. Prefer copying values out of slices rather than holding pointers to elements.
The compiler will reject invalid capacity arguments. If you pass a negative capacity, you get runtime error: makeslice: cap out of range. If you request too much memory, the runtime panics with runtime: out of memory. These errors happen at allocation time, not at compile time. Always check that your capacity calculation cannot overflow or exceed reasonable limits.
Convention aside: the receiver name in methods is usually one or two letters matching the type. If you have a Buffer type, the receiver is (b *Buffer), not (this *Buffer). This applies to methods that manipulate slices too. Keep receiver names short and consistent with the type name.
When to preallocate
Use make([]T, 0, n) when you know the approximate final size and want to avoid reallocation overhead. Use make([]T, n) when you know the exact size and will fill every index by position, not by appending. Use a slice literal []T{a, b, c} when the size is small and fixed at compile time. Use an empty slice []T{} or var s []T when the size is unknown and small, letting the runtime grow it. Use a capped preallocation when the size comes from untrusted input, validating the count against a safe maximum before calling make.
Preallocation is a promise. You tell Go how much space you need. Go reserves it. You deliver the data. If you break the promise by over-allocating, you waste memory. If you under-allocate, you pay for copies. Measure your data. Size your slices. Trust the profiler, not the guess.