← All posts

What Go Slice Growth Reveals About append and Memory

A close look at how Go slice growth works in practice, from the runtime growth rule to allocator size classes and abandoned backing bytes. The article shows what to inspect, how to verify behavior, and where append-heavy code often goes wrong.

Go slices look simple from the language surface. You append values and the slice grows. In practice, growth is a small allocator story. Capacity changes follow runtime rules, element width matters, and the allocator rounds requests into size classes. If you work on performance, memory use, or leak hunting, those details shape what your program keeps alive.

Go Slice Growth — what append actually does to the capacity focuses on one narrow but important behavior. It shows the growth formula, the rounded allocation size, the size class selected by the allocator, and the bytes left behind after growth. This is useful because append behavior often gets discussed as a rule of thumb. Runtime behavior is more concrete. You want to inspect the exact steps.

A tool like this matters when your code builds buffers, queues, batches, or lookup tables in a loop. A small difference in element size or initial capacity changes the growth path. That affects heap pressure, copy cost, cache behavior, and how much old backing storage stays resident until the garbage collector reclaims it.

Inspect the growth path, not the headline rule

Many engineers carry a simplified model of slice growth. Double for small slices. Grow more slowly later. That rough model helps, but it hides the parts that matter in production.

What you want to inspect is the sequence:

  1. The requested new length after append.
  2. The runtime’s target capacity from its growth rule.
  3. The raw bytes implied by capacity * element_size.
  4. The allocator’s rounding to a size class.
  5. The final capacity after rounding back from bytes to elements.

Those are different values. If you only look at the target capacity, you miss allocator effects. If you only look at allocated bytes, you miss how element width changes the usable result.

This is why two slices with the same logical growth pattern behave differently. A []byte and a slice of larger structs do not land on the same capacities, even under the same append pattern. The allocator works in bytes. Your code reasons in elements. The translation between the two is where surprises start.

For a practitioner, this is the core lesson. Capacity growth is not only a language feature. It is a runtime and allocator interaction.

Verify claims with small, repeatable cases

You do not need a large benchmark to verify what the tool shows. Start with small slices and deterministic append counts.

Useful cases to inspect:

  • []byte with zero initial capacity.
  • []int with a small initial capacity.
  • A slice of structs with an awkward element width, such as 24 or 40 bytes.
  • A slice grown one element at a time versus in larger bursts.

For each case, track three numbers after each append or growth event:

  • length
  • capacity
  • total backing bytes

Then compare what happened with the expected runtime growth step and the allocator rounding step. The point is not to memorize exact capacities. The point is to see where the final result departs from the formula because the allocator had to fit the request into a class.

This is also where the “bytes left behind” view matters. When a slice outgrows its backing array, append allocates a new array and copies the existing elements. The old array does not disappear at that instant. If anything still references it, those bytes stay live. Even without an extra reference, they remain allocated until the collector runs.

In practical terms, you should verify two separate effects:

  • growth overshoot from allocator rounding
  • retention of old backing storage after repeated growth

Those are distinct costs. One is over-allocation in the new array. The other is stale memory waiting to die.

Read the signal in element size and size classes

One of the strongest signals in this class of system is element width. A change in struct layout changes slice behavior even when your append logic stays the same.

Say you add a field to a struct. Your append loop is unchanged. Your target capacity from the runtime growth rule looks similar. Yet allocated bytes jump more than expected. Why. Because the request crossed a different size-class boundary. The allocator rounded to a larger bucket, and the final capacity in elements shifted with it.

This is why memory regressions sometimes appear after a harmless-looking type edit. You changed the economics of every backing array growth.

What to inspect:

  • element size before and after a type change
  • the size class selected at each growth point
  • whether the final capacity in elements grew by a larger step than the formula alone suggests
  • how much slack space sits in the newly allocated backing store

Slack space is not automatically bad. Extra headroom reduces the number of future allocations and copies. The signal to read is mismatch. If your workload appends predictable batch sizes, and your slice keeps landing with far more spare capacity than needed, you are paying for growth policy and allocator rounding in a way your workload does not need.

This is where preallocation earns its place. If you know the rough final size, allocating near it up front avoids repeated copies and reduces abandoned backing arrays. The tool demonstrates why this helps. It is not a style preference. It changes the allocator path.

Where slice growth commonly goes wrong

The mistakes around slices are usually not syntax mistakes. They are model mistakes.

Mistake 1: treating capacity as if it were exact

Engineers often expect capacity to match a formula or a prior observation. Runtime growth rules and allocator rounding mean the observed capacity is a result, not a promise. If you write tests or assumptions around exact capacities across environments or versions, you are tying logic to runtime internals.

Mistake 2: ignoring old backing arrays

A slice that grows repeatedly leaves prior arrays behind. Usually that is fine. It becomes a problem when a small subslice or another reference keeps a large old array live.

A common pattern looks like this:

buf := make([]byte, 0, 1024)
// buf grows a lot over time
small := buf[:16]

If small escapes and the large backing array stays referenced through it, your program holds far more memory than the visible data suggests. The issue is not append alone. The issue is append plus aliasing.

Mistake 3: missing the copy cost

Every growth step copies existing elements into the new array. For large structs or large capacities, copy cost becomes visible in profiles. If your hot path appends one item at a time with no preallocation, repeated growth adds allocator work and memcpy work.

Mistake 4: using large element types in append-heavy paths

A slice of pointers grows differently from a slice of wide structs. If append frequency is high, moving wide values around is expensive. Sometimes a pointer slice or a packed representation lowers copy and allocation cost. Sometimes it harms locality and increases indirection. The tool gives you a concrete basis for that tradeoff by exposing the byte-level growth path.

How to use this signal in code review

You do not need heap dumps for every change. In code review, a few questions catch many slice issues early.

  • Is the final size roughly knowable.
  • Does the code append in a loop without preallocation.
  • Did a struct type grow in width.
  • Is a subslice returned from a much larger buffer.
  • Does the path sit in request handling, parsing, batching, or serialization.

If the answer is yes to several of those, inspect slice growth behavior before merging. Your goal is not to force every slice into a fixed capacity. Your goal is to align allocation behavior with workload shape.

A practical way to verify is to create a reduced example with the real element type and append pattern. Then compare observed capacities and allocations with the growth path shown by the tool. If the example shows repeated large jumps, large abandoned arrays, or excessive spare capacity, you have a concrete reason to preallocate, reuse buffers, or change representation.

For broader memory analysis on a production target, this page pairs well with standard profiling. Profiles tell you where allocations happen. A slice-growth view tells you why the capacity sequence looks the way it does.

What to watch next

The details here are runtime details. They are stable enough to reason about, but they are still implementation behavior. When you upgrade Go, verify any code or tests that depend on observed capacity growth. Watch for type-width changes in shared structs. Watch for append-heavy loops in code paths with strict latency or memory budgets.

The durable habit is simple. Treat slice growth as an allocator interaction. Inspect it in bytes, not only elements. That is the level where the surprises show up.