Slices and Shared Underlying Arrays

Learn how Go slices share underlying arrays the aliasing pitfalls that can cause bugs and how to avoid them with full slice expressions and the copy function

A slice in Go does not own the data it exposes. It is a lightweight header that points into a backing array. Multiple slices can point to overlapping regions of the same array. This design gives slices their speed and flexibility, but it also creates a class of bugs that only surface when two pieces of code unknowingly share memory.

How a Slice References Its Underlying Array

A slice value is a tiny struct containing three fields: a pointer to an element of an array, a length, and a capacity. When you create a slice from an array or another slice using a slice expression like s[low:high], Go does not copy the elements. It creates a new slice header whose pointer points to the same underlying array, at the offset low.

arr := [6]int{10, 20, 30, 40, 50, 60}
a := arr[1:4] // elements [20, 30, 40]
b := arr[2:5] // elements [30, 40, 50]

After these two lines, a and b are separate slice values, but they both point into the array arr. a sees indices 1 through 3 of arr, while b sees indices 2 through 4. The elements at indices 2 and 3 — the values 30 and 40 — are visible through both slices.

Any modification through one slice instantly affects the other, because there is only one copy of each integer in memory.

a[1] = 999       // a[1] corresponds to arr[2]
fmt.Println(arr) // [10 20 30 999 50 60]
fmt.Println(b)   // [30 999 50]

This behavior is intentional and forms the foundation of efficient slice manipulation. But when code does not expect shared memory, it leads to subtle, hard-to-diagnose bugs.

Aliasing Can Corrupt Data Without Warning:

If two slices share the same underlying array, a change made through one slice will be visible through the other. There is no compiler warning or runtime panic — the data simply changes. Always trace who else might be looking at the same backing array before you mutate a slice.

The Aliasing Problem When Appending

The most dangerous aliasing scenario involves append. When you append to a slice that has spare capacity (its length is less than its capacity), append writes the new elements into the existing underlying array — right after the current end of the slice. If another slice happens to overlap that region, its data gets overwritten silently.

original := make([]int, 3, 6)
for i := range original {
    original[i] = i + 1
} // original is [1,2,3], cap 6
firstHalf := original[0:2]   // [1,2], cap 6
secondHalf := original[2:3] // [3],   cap 4 (starts at index 2 of the same array)
// Append to firstHalf. The underlying array has room.
firstHalf = append(firstHalf, 99)
fmt.Println("firstHalf:", firstHalf) // [1 2 99]
fmt.Println("secondHalf:", secondHalf) // [99] — overwritten!
fmt.Println("original:", original)     // [1 2 99]

Here firstHalf had length 2 but capacity 6. The append call wrote 99 into the slot at index 2 of the underlying array. That slot was exactly where secondHalf got its sole element. So secondHalf[0] changed from 3 to 99.

The code never explicitly assigned to secondHalf. The mutation came through an operation on a different slice. This is what makes shared underlying arrays so treacherous: data changes from a distance, with no obvious trace back to the cause.

Appending to a Slice With Available Capacity Overwrites Adjacent Slices:

append does not check whether the capacity it is about to use belongs to another slice. If the underlying array has room, append reuses it. To prevent this, either ensure the slice has no extra capacity, or use a full slice expression to cap the capacity explicitly.

Controlling Shared Capacity With Full Slice Expressions

A regular slice expression s[low:high] gives the new slice the same capacity as s (minus the offset). This means a slice taken from the front of a large array inherits a huge capacity, even if it only needs a few elements. That leftover capacity is what enables the accidental overwrites shown above.

Go provides a full slice expression that lets you set the capacity of the resulting slice:

s[low : high : max]

The new slice has length high - low and capacity max - low. The value max must be less than or equal to cap(s), and greater than or equal to high. If the capacity is restricted, a future append that exceeds that limit will allocate a fresh underlying array, leaving the original data untouched.

original := make([]int, 5, 10)
for i := range original {
    original[i] = (i + 1) * 10
} // [10,20,30,40,50], cap 10
// Safe snapshot: we want elements 1 and 2, but no capacity to grow.
safe := original[1:3:3] // len 2, cap 2 (3 - 1)
// safe is [20,30], cap 2
safe = append(safe, 999) // exceeds capacity, allocates new array
fmt.Println("safe:", safe)       // [20 30 999]
fmt.Println("original:", original) // [10 20 30 40 50] — untouched

By setting the max index to 3 (the same as the high bound), we made safe a slice with zero spare capacity. The append triggered a reallocation and copy, so the original array remained unaffected.

This technique is essential when you extract a slice from a larger buffer and plan to grow it independently. It also prevents the memory leak described next.

Full Slice Expressions Prevent Accidental Aliasing:

When you write s[low:high:max], you are explicitly stating “I want this slice to see no more than max - low elements from the original array.” This is the most reliable way to ensure that future appends do not corrupt shared data. Use it any time you create a slice that will outlive its parent or might be appended to later.

The Memory Retention Trap

A slice that points into a large array keeps the entire array alive in memory, even if the slice only exposes a small portion. The garbage collector sees the slice’s pointer to the backing array and cannot free the array while any slice references it.

Consider a function that reads a file, finds a short segment of interest, and returns a slice of that segment:

func findFirstNumber(data []byte) []byte {
    // data is a large byte slice (maybe the entire file contents)
    for i := 0; i < len(data); i++ {
        if data[i] >= '0' && data[i] <= '9' {
            return data[i : i+1] // a slice of one byte
        }
    }
    return nil
}

The returned slice has a pointer into the original huge byte array. Even though the caller only needed a single byte, the entire file stays in memory for as long as the returned slice exists. In a long-running server, this can cause memory usage to climb far beyond expectations.

The fix is to copy the needed elements into a new, independent slice:

func findFirstNumberSafe(data []byte) []byte {
    for i := 0; i < len(data); i++ {
        if data[i] >= '0' && data[i] <= '9' {
            result := make([]byte, 1)
            copy(result, data[i:i+1])
            return result
        }
    }
    return nil
}

Now the returned slice owns its own tiny backing array. The original large buffer becomes eligible for garbage collection as soon as no other references to it exist.

Always Copy When You Need Only a Fragment of a Large Buffer:

If a slice you return from a function is meant to be a small window into a much larger input, and the input will not be needed afterward, use copy to create an independent slice. This releases the large backing array and prevents memory bloat.

When Sharing Is Intentional

Despite the dangers, shared underlying arrays are not a design flaw. They are what make slicing efficient and what allow append to avoid reallocation when capacity is available. A function that takes a slice as input and returns a sub-slice after some transformation often expects the caller to be aware of the sharing — and may even rely on it for performance.

The real skill is knowing when sharing is safe and when it is not. If you own the entire data pipeline and you control who accesses the backing array, sharing can be fine. When slices cross API boundaries, or when they are stored in structs that outlive the scope where they were created, sharing becomes a risk.

A common safe pattern: treat the return value of append as the only reference to the resulting data. The original slice header should be considered stale after an append call — you have no guarantee whether the underlying array was reused or a new one was allocated, so you cannot rely on the original header’s relationship to the data.

Summary

Slices do not copy data. They point into arrays that may be shared by many other slices. That sharing is the source of both performance and pain.

When you create a slice via s[low:high], you inherit the capacity of the parent. If you later append to that slice and the capacity is available, you overwrite data that may be visible elsewhere. Using a full slice expression s[low:high:max] caps the capacity, forcing future appends to allocate a new array and isolating the slice from its parent.

A slice that peeks into a massive array keeps the entire array alive. If you only need the visible elements, copy them into a new slice and let the big array be collected.

Understanding that a slice is just a pointer, a length, and a capacity — not an independent container — is the key to writing correct, leak-free Go code. This mental model is what separates working with slices from fighting with them.