AlgoMaster Logo

Slice Gotchas and Memory Leaks

High Priority23 min readUpdated June 6, 2026

Slices share memory by design. That sharing is what makes them cheap to pass, cheap to sub-slice, and cheap to grow. It's also what makes them the most common source of surprise bugs in Go code. This lesson walks through the bugs that show up in real e-commerce systems: cart values that change behind your back, audit logs that hold onto a million stale orders, goroutines that all see the same product, and append calls that stomp on data you thought was safe.

A Sub-Slice Keeps the Whole Backing Array Alive

Consider a service that loads every order a customer has ever placed, then keeps the three most recent for a summary widget. The natural way to write it is to slice the last three off the full history and return that.

recent looks like a tiny slice of three orders. The slice header says so: length 3, capacity 3 (or close to it). The trap is that the header still points into the original backing array of a million orders. As long as recent is reachable, the garbage collector keeps the full million-element array alive. You think you freed 999,997 orders. You didn't.

The diagram below shows what's really in memory.

The fix is to copy the data into a fresh backing array so the sub-slice no longer references the original one. Any of these three work:

The pre-1.21 equivalents are append([]Order{}, tail...) or an explicit make plus copy:

All three allocate a new backing array sized to the sub-slice. Once history goes out of scope, the garbage collector is free to reclaim the million-element array.

Cloning copies the elements. For 3 orders that's nothing. For a sub-slice of 200,000 inside a 1,000,000 array, you trade one allocation and one copy for releasing 80% of the memory. Almost always worth it.

The same trap shows up with strings, since a Go string also has a pointer and length internally. Slicing a huge string and stashing a substring keeps the whole thing alive. The fix is the same idea: strings.Clone(s[a:b]) since Go 1.18.

Deleting Pointer Elements Without Zeroing the Tail

The slice-trick way to delete index i from s is:

It works fine for slices of int, string, or other non-pointer values. For slices of pointers (or structs containing pointers), it leaks. The reason is that append slides the tail down by one, but it does not clear the now-unused slot at the end of the backing array. That slot still holds the old pointer, and the GC sees it as a live reference.

customers has length 2 after the delete, but its backing array still has three slots, and the third slot still points at the original Alex struct. Until that slot is overwritten or the slice itself is garbage collected, Alex stays in memory. If your "customers" are 5 MB blobs instead of two strings, this is a real leak.

The fix is to zero the trailing slot after the slide. Doing it by hand looks like this:

Since Go 1.21, the standard library does this for you. slices.Delete zeros the trailing slots of pointer (and pointer-containing) types automatically.

The trailing slot is nil, the Alex struct has no live reference, and the GC can free it on the next cycle.

Zeroing one or two slots is free in practice. The leak it prevents can be enormous if the slice held large structs or strings.

The rule of thumb: if your slice element type contains a pointer (directly, or inside a struct, or as a slice/map/string), use slices.Delete instead of the append trick. For slices of int, float64, or plain non-pointer values, both approaches are equivalent because there's no pointer to keep anything alive.

Two Slices, One Backing Array

A slice is a pointer plus a length plus a capacity. Assigning one slice to another, or sub-slicing, copies the header but not the data. If both headers end up pointing at the same backing array, writing through one slice changes what the other sees.

This is fine when you want it. It's a disaster when you don't.

The wishlist update rewrote a slot in the cart. The two slices share the same backing array, so wishlist[0] is the same memory cell as cart[1].

If the wishlist genuinely needs to be independent, clone it:

slices.Clone allocates a new backing array, so the two slices are now genuinely independent. The price is one allocation plus a copy. For a wishlist of a few items that's nothing. For a million-item slice it's a real cost, and you should think about whether the share is actually a problem before paying it.

Another common version of this bug: a function returns a slice that aliases its input.

The caller now holds two slices that point at the same memory. If either side mutates, the other sees it. Documenting this behavior is one option. Returning a clone is the other. Pick one and stick with it.

append Sometimes Reallocates and Sometimes Doesn't

append is the most important slice operation and also the most error-prone. The rule is simple to state but easy to forget:

  • If the slice has spare capacity, append writes into the existing backing array.
  • If the slice is full, append allocates a new backing array and copies the elements over.

That conditional reallocation is what causes the next bug. You build a slice, sub-slice it for some "preview" or "audit" view, then later append more items to the original slice. If the original had spare capacity, the append writes into the same backing array that the preview is using, and the preview gets clobbered.

So far so harmless. Now a small change: the appended item lands inside the preview's range instead of past it.

The append to preview had spare capacity in the shared backing array (its capacity is 4, length is 2), so it wrote DISCOUNT_PROMO into slot 2. That slot is also cart[2]. The original "laptop" is gone, and the cart now contains a fake promo string nobody asked for.

The exact same code becomes safe if the appended preview overflows its capacity:

Because the append needed more than capacity 3, append allocated a fresh backing array and copied the elements over. preview now points at a brand-new array, and the later write to preview[0] doesn't touch cart at all.

So the same call, append(preview,...), sometimes mutates the caller's data and sometimes doesn't. The behavior depends on the capacity of the slice, which is invisible at the call site. That's the issue.

The defensive fix when you genuinely need a sub-slice that you'll later append to without affecting the original is to use a three-index slice expression to cap its capacity:

Now cap(preview) is 2, length is 2, so the next append is guaranteed to allocate a new array. The original cart is safe from any mutation through preview.

Or clone:

The three-index trick is free (it's just a header change). Cloning costs one allocation plus a copy. Pick the three-index form when you want lazy isolation, clone when you want it now.

Passing a Slice to a Function

A slice argument behaves halfway between value and reference, and the halfway is exactly where bugs hide. The slice header (pointer, length, capacity) is copied. The backing array is not. That gives three behaviors:

  • Element writes via the slice show through to the caller. The header copy still points at the caller's array.
  • Reassignment of the slice variable inside the function does not show through. The function has its own local header.
  • `append` inside the function might or might not show through, depending on whether it triggered a reallocation. Same gotcha as the previous section.

Here's the writes-show-through case:

applyDiscount writes through its header into the caller's backing array. The caller sees the change. This is usually what you want.

Now the reassignment case:

The caller still has the original cart. items = nil inside clearOut only nilled out the local copy of the header.

The dangerous case is when the function appends:

Inside addFreeGift, the append had spare capacity, so it wrote "free sticker" into the shared backing array at index 2. Then it updated the local header to length 3 and returned. The caller's header is still at length 2, so it doesn't see the new item when iterating with range or printing. The data is there at index 2 of the array, but the caller's view of the slice doesn't reach it. If the caller later appends, it will overwrite the sticker. If the caller doesn't, the sticker stays in the backing array unused, which is its own small leak.

If the function intends to grow the slice, the idiomatic shape is to return the new slice:

This is exactly why the standard library's append signature is func append(s []T, vs...T) []T, returning the (possibly new) slice. The caller has to reassign. There is no "in-place grow" version in the language because there can't be one; the slice header is a value.

Returning the slice is one register copy. There is no overhead worth thinking about. Always return the slice from a function that grows it.

The Pre-1.22 Range Loop Variable Trap

Before Go 1.22, a for... := range loop reused a single variable for the loop value across iterations. Each iteration reassigned the same memory location. That was usually fine, except when you took its address and stashed the pointer somewhere that survived the loop, like a goroutine closure or a slice of pointers.

On Go 1.22+ this prints the three products in some order. On Go 1.21 and earlier, all three goroutines tend to print whichever product p happened to hold when they got scheduled, often three copies of the last one:

The reason is that pre-1.22, p was one variable, reused across iterations. The closure captured p by reference, so all three goroutines saw the same p, and by the time they ran the loop had moved on.

Go 1.22 changed the spec so each iteration gets its own fresh p. The closure now captures the iteration's own copy, and the code prints all three products as you'd expect. If your go.mod declares go 1.22 or higher, the new behavior applies. If it declares an older version, the old behavior applies even on a 1.22 toolchain.

The pre-1.22 workaround that is in older codebases is to shadow the loop variable inside the body:

Or pass it as a parameter so each goroutine gets its own copy:

Both are still valid on 1.22+ and don't hurt anything. If you're reading old code, recognise these patterns as the cure for a bug that the language itself eventually fixed.

The same trap applied to taking addresses:

Pre-1.22, ptrs ended up with three identical pointers, all pointing at the loop variable that, at the end, held the last product. Post-1.22, each iteration's &p is a distinct address.

Returning Slices From Internal Functions

The aliasing problem doesn't disappear when a function "owns" the data. If a helper returns a slice that points into a private array or a cached slice, the caller can mutate the helper's internal state without knowing it.

The caller reached in and modified the catalog's first product. If Catalog were behind a package boundary, this would be invisible to the implementer of Catalog and entirely up to discipline on the caller side.

There are two reasonable answers:

  1. Document it. "The returned slice shares storage with the catalog. Do not modify it." This is what bytes.Buffer.Bytes() does, for example.
  2. Clone before returning. Pays one allocation per call. Safe by construction.

Or use the three-index slice to cap capacity, which doesn't stop element writes but does stop appends from corrupting the internal slice:

Now if the caller does featured = append(featured,...), the append is guaranteed to reallocate, and the catalog's array stays untouched. Element writes through featured[0] still affect the catalog, so this is partial protection, not full.

The right choice depends on how the function is used. A read-only view that callers should not mutate is best served by cloning. A view that callers will read repeatedly and never write to is fine sharing storage. The wrong choice is to share storage and not document it, which is exactly the path most slice bugs walk in on.

Quiz

Slice Gotchas Quiz

10 quizzes