Common Slice Gotchas in Go

Shared Backing Arrays and Memory Leaks

Fix Go slice memory leaks by always assigning the return value of functions like slices.Delete to update the slice header.

The slice that won't let go

You write a function to filter out expired tokens from a cache. The logic works perfectly. The test suite passes. You deploy to production and watch the memory profiler climb steadily over forty-eight hours. The heap is full of old token structs that should have been garbage collected. The culprit is not a missing channel close or a runaway goroutine. It is a single slice variable that still points to a backing array full of stale pointers.

Go slices are famously fast because they share memory. That same speed creates a silent trap. When you shrink a slice or delete elements from it, the underlying array does not automatically forget the removed items. If you hold onto the original slice header, the garbage collector treats those old pointers as live references. The memory stays allocated. The leak is invisible to the compiler and quiet at runtime.

How slices actually store data

A slice in Go is not a contiguous block of data. It is a lightweight descriptor, officially called a slice header. The header contains exactly three words: a pointer to the first element, the current length, and the total capacity. The actual data lives in a separate backing array allocated on the heap.

Think of the backing array as a physical bookshelf. The slice header is a library card that tells you where the shelf starts, how many books you are currently allowed to read, and how many slots the shelf has in total. When you create a subslice with s[2:5], Go does not copy the books. It hands you a new card pointing to the same shelf. The original card still points to the full shelf. This design makes slicing incredibly cheap. It also means every slice variable that references the same array shares ownership of the memory.

When you call a function that modifies the slice, like slices.Delete, the function shifts the remaining elements to the left and returns a new slice header with an updated length. The backing array itself still contains the old pointers in the slots beyond the new length. Those pointers are now logically dead, but physically present.

The minimal trap

The mistake happens in one line. You call a mutating function and forget to capture the result.

// FilterSlice removes elements at the given indices.
func FilterSlice(items []string, start, end int) []string {
    // The slices package returns a new slice header.
    // We must assign it back to update our local view.
    items = slices.Delete(items, start, end)
    return items
}

If you write slices.Delete(items, 2, 5) without assigning the result, the items variable keeps its original header. The length stays the same. The capacity stays the same. The backing array still holds the old pointers in positions 2 through 4. The garbage collector walks the heap, sees those pointers, and assumes they are still in use. The deleted strings are never freed.

The fix is a single equals sign. Assign the return value. The new header tells the runtime exactly where the live data ends. Everything past the new length becomes eligible for collection.

Why the garbage collector stays silent

Go uses a tracing garbage collector. It starts from root variables and follows pointers to mark live objects. It does not track logical ownership. It only sees what the slice headers declare. If a slice header says the length is ten, the collector examines ten pointers. It does not care that you conceptually deleted five of them. It trusts the header.

This behavior is consistent across the standard library. slices.Compact works the same way. It removes consecutive duplicates, shifts elements left, and returns a shorter slice. Ignoring the return value leaves the original slice pointing at the unshifted array. The duplicates remain in memory.

You will not get a compiler warning here. Go allows you to ignore return values by default. The language trusts you to read the documentation. If you really want the compiler to force you to handle a return value, you can use the blank identifier to explicitly discard it, but that defeats the purpose. The convention is simple: slice operations that change length return a new slice. Always reassign.

// PruneLogs removes empty entries from a log buffer.
func PruneLogs(buffer []string) []string {
    // slices.Compact returns a new header with updated length.
    // Reassigning updates our view and drops the old pointers.
    buffer = slices.Compact(buffer)
    return buffer
}

Some developers try to clear the backing array manually by looping over the old indices and setting them to nil. That works, but it adds CPU overhead. The idiomatic approach is to let the slice header do the work. When the original variable goes out of scope, or when you reassign it, the old header is dropped. The backing array becomes unreachable if no other slice references it. The collector reclaims the memory in the next cycle.

Real-world slice surgery

Production systems often process batches of data. A common pattern reads a chunk of records, filters out invalid ones, and passes the clean batch to a downstream worker. The leak appears when the batch variable is reused across loop iterations without proper reassignment.

// ProcessBatch filters and returns valid records from a raw input.
func ProcessBatch(raw []Record) []Record {
    // We start with a slice that shares the raw backing array.
    // This avoids an immediate allocation.
    valid := raw

    // Filter out records with missing IDs.
    // The loop shifts elements and tracks the new length.
    j := 0
    for i := range raw {
        if raw[i].ID != "" {
            valid[j] = raw[i]
            j++
        }
    }

    // Truncate the slice to the new length.
    // This updates the header and drops references to invalid records.
    valid = valid[:j]
    return valid
}

The manual loop above shows the underlying mechanics. The valid slice shares the backing array with raw. As we copy valid records to the front, we track the new length with j. The final valid[:j] creates a new header with the correct length. The old pointers beyond index j are logically severed. If raw is a local variable that goes out of scope after this function returns, the entire backing array becomes eligible for collection.

If you keep raw alive elsewhere, the leak returns. The original slice still points to the full array. The solution is to either let the original variable drop, or explicitly nil out the old elements if you must keep the array alive for other reasons. In most cases, reassigning the slice header is enough. The garbage collector handles the rest.

Pitfalls and silent failures

The compiler will not save you from slice leaks. Go treats slices as first-class values. You can pass them around, copy them, and ignore function returns without triggering a diagnostic. The error manifests as gradual memory growth. You might see runtime: GC triggered messages in logs, or you might watch your container hit the OOM killer after a few days.

Another trap involves capacity. When you append to a slice, Go may allocate a larger backing array if the current capacity is exhausted. The old array is freed. But if you created a subslice before the append, that subslice still points to the old array. The old array stays alive. This is why the standard library documentation warns about sharing slices before appending. The rule is straightforward: do not share a slice with other variables if you plan to append to it later.

You might also encounter panics if you accidentally read past the new length. The runtime checks bounds on every slice access. If you forget to reassign after a delete and then try to iterate over the slice, you will read the stale pointers. The program does not crash immediately. It just processes garbage data. The panic only happens if you index out of the declared length, which the header still reports as the old size. The mismatch between logical length and physical data is the core of the problem.

When to cut, when to copy, when to leave it

Use slices.Delete when you need to remove a contiguous range and reassign the result to update the slice header. Use slices.Compact when you want to deduplicate a sorted slice and reassign the returned header. Use a manual index shift when you are writing a tight loop and want to avoid the overhead of a function call, but always truncate the slice with s[:j] at the end. Use a new slice allocation with make when you need to guarantee the old backing array gets collected immediately and you cannot control the lifetime of the original variable. Keep the original slice when you only need a temporary read-only view and the parent scope will drop it before the next garbage collection cycle.

Slices are views, not containers. Treat them like windows into a larger room. Close the window when you are done, or the room stays lit.

Where to go next