The Shared Slice Backing Array Gotcha in Go
You are building a high-throughput parser. You allocate a 4KB buffer to read data from a network socket. You slice off a message header and pass it to a handler function. The handler normalizes the header by modifying the first byte. Your parser crashes on the next message because the buffer is corrupted. You never touched the buffer directly. The slice did it for you.
This is the shared backing array gotcha. It happens when a slice is created from a larger buffer and modified, causing changes to reflect in the original buffer because they share the same underlying memory. Slices in Go are views, not containers. They point to an array. Modifying the slice modifies the array.
Slices are windows, not boxes
A slice is not a standalone collection of data. It is a lightweight descriptor that points to an underlying array. The slice header contains three fields: a pointer to the array, a length, and a capacity.
Think of a slice like a window frame on a wall. The window doesn't hold the scenery; it just shows you a part of what's behind the wall. If you paint the wall through the window, the paint appears on the wall. If you move the window frame, you see a different part of the same wall. The slice holds the frame. The array holds the wall.
When you create a slice from a buffer, you are carving out a window into that buffer. The new slice shares the pointer to the array. It has its own length and capacity, but the data lives in the shared array. Any write through the slice writes to the array. Any read through the buffer reads the same bytes.
Minimal example
Here's the mechanics in isolation. Create a buffer, slice it, mutate the slice, watch the buffer change.
package main
import "fmt"
func main() {
// Buffer holds 10 bytes. Index 0 is 'A'.
buf := make([]byte, 10)
buf[0] = 'A'
// Slice points to the same array. It covers indices 0 through 4.
// The slice header has ptr=buf.ptr, len=5, cap=10.
slice := buf[0:5]
// Writing to the slice writes to the shared backing array.
slice[0] = 'B'
// The buffer sees the change because they share memory.
fmt.Println(string(buf[0])) // Prints B
}
The slice buf[0:5] creates a new slice header. The pointer field is copied from buf. The length is set to 5. The capacity is set to 10. When you write slice[0] = 'B', the runtime dereferences the pointer to index 0 and stores 'B'. When you read buf[0], the runtime dereferences the same pointer to index 0 and reads 'B'. They are the same memory location.
Walkthrough: pointer, length, capacity
Understanding the three fields explains every slice behavior.
The pointer is the address of the underlying array. Two slices share memory if and only if their pointers are equal. You can check this with unsafe.Pointer, but you rarely need to. If you sliced one slice from another, they share the pointer.
The length is the number of elements the slice exposes. Accessing an index >= length causes a panic. The length does not affect the backing array. You can have a slice of length 0 pointing to a large array.
The capacity is the number of elements available from the start of the slice to the end of the array. Capacity determines whether append can reuse the array. If you append and the new length exceeds capacity, Go allocates a new array.
Convention aside: len and cap are built-in functions. They run in constant time. They read the slice header fields. Use them to inspect slice state.
Realistic example: returning data from a buffer
A common pattern is reading data into a reusable buffer and returning a slice of that data. If the caller modifies the returned slice, they corrupt the buffer for the next read.
Here's a parser that extracts a header. It returns a slice pointing into the packet. The caller modifies the header. The packet is corrupted.
package main
import "fmt"
// parseHeader extracts the first 4 bytes as a header.
// It returns a slice pointing into the original packet.
func parseHeader(packet []byte) []byte {
// Slice the first 4 bytes.
// Returns a view, not a copy.
return packet[:4]
}
func main() {
// Packet arrives from the network.
packet := []byte("HEADBODY")
// Extract header.
header := parseHeader(packet)
// Caller normalizes the header.
// This mutates the original packet.
header[0] = 'X'
// Packet is now corrupted.
// If packet was reused or logged, the data is wrong.
fmt.Println(string(packet)) // Prints XEADBOD
}
The fix is to copy the data. copy is a built-in function that copies elements from a source slice to a destination slice. It returns the number of elements copied. It is optimized and safe.
package main
import "fmt"
// parseHeaderSafe extracts the header and returns a copy.
// The caller owns the returned slice.
func parseHeaderSafe(packet []byte) []byte {
// Allocate a new slice for the header.
result := make([]byte, 4)
// Copy data from packet to result.
copy(result, packet[:4])
return result
}
func main() {
packet := []byte("HEADBODY")
header := parseHeaderSafe(packet)
// Caller modifies the copy.
header[0] = 'X'
// Packet remains unchanged.
fmt.Println(string(packet)) // Prints HEADBODY
fmt.Println(string(header)) // Prints XEAD
}
Convention aside: copy copies min(len(dst), len(src)) elements. It never panics on length mismatch. It is the idiomatic way to duplicate slice data. Don't write a loop. Use copy.
Append and capacity divergence
append is tricky. It can reuse the backing array or allocate a new one. This causes subtle divergence when multiple slices share an array.
If you have two slices sharing an array, and you append to one, Go checks capacity. If there is room, it writes into the shared array. The other slice sees the change. If there is no room, Go allocates a new array, copies data, and updates the first slice's pointer. The second slice still points to the old array. Now they diverge.
Here's the divergence in action.
package main
import "fmt"
func main() {
// Base slice with capacity 10.
base := make([]int, 3, 10)
base[0] = 1
// Slice shares backing array.
view := base[0:2]
// Append to view. Capacity is 10, length is 2.
// Room exists. No allocation.
view = append(view, 99)
// base sees the change because they share memory.
fmt.Println(base[2]) // Prints 99
// Fill up the capacity.
view = append(view, 2, 3, 4, 5, 6, 7) // Length becomes 9, Cap 10.
// Append one more. Capacity exhausted.
// Go allocates a new array for view.
view = append(view, 8)
// base still points to the old array.
// view points to the new array.
fmt.Println(base[2]) // Still 99
fmt.Println(view[2]) // 99 (copied over)
// They have diverged.
}
Go's runtime grows slices by doubling when small, and by 25% when large. This amortizes the cost of reallocation. But it means you can't predict exactly when divergence happens. If you rely on sharing, check capacity. If you rely on independence, copy.
Advanced pitfall: sync.Pool and dangling references
Real systems use buffer pools to avoid allocation. sync.Pool is the standard tool. You get a buffer, use it, and return it. The pool recycles the buffer for other goroutines.
The bug happens when you return a slice of a pooled buffer. The slice points to the pooled array. You return the buffer to the pool. The pool gives the buffer to another goroutine. That goroutine writes to the buffer. Your slice now points to garbage or someone else's data. This is a race condition and data corruption.
Here's the pattern and the bug.
package main
import (
"fmt"
"sync"
)
var bufPool = &sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}
// getBuffer retrieves a buffer from the pool.
func getBuffer() []byte {
return bufPool.Get().([]byte)
}
// putBuffer returns a buffer to the pool.
func putBuffer(b []byte) {
// Reset length to zero for reuse, but keep capacity.
b = b[:0]
bufPool.Put(b)
}
func main() {
// Get a buffer and slice it.
buf := getBuffer()
data := buf[:5]
copy(data, []byte("Hello"))
// Return buffer to pool.
putBuffer(buf)
// data still points to the pooled buffer.
// Another goroutine could get this buffer and overwrite it.
// Even in single-threaded code, the data is unsafe.
fmt.Println(string(data)) // Might print Hello, but it's a dangling reference.
}
The fix is to copy the data before returning the buffer to the pool. Or copy immediately when you extract the slice. Never hold a slice into a pooled buffer after the buffer is returned.
Pitfalls and compiler errors
The compiler catches some mistakes. It stays silent on aliasing.
If you try to return a slice of a local array, the compiler rejects the program with returns address of local variable. This prevents dangling pointers to stack memory.
If you slice past the end of the array, the program panics at runtime with panic: runtime error: slice bounds out of range. This is a bounds check failure.
The compiler does not warn you about shared memory. It trusts you. If you slice and mutate, the program runs, but the data may be wrong. This is a runtime logic bug. The compiler assumes you know what you're doing.
Convention aside: gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run it on save. Focus on logic, not formatting.
Decision: when to use slices vs copies
Use a slice when you need a read-only view or temporary processing within the same scope. Slices are cheap. They avoid allocation. Use them for substring operations, buffer views, and passing data to functions that don't retain the slice.
Use copy when the data must outlive the buffer or be modified independently. Copies allocate memory. They break the sharing link. Use them when returning data from a function that manages a buffer, or when the caller expects ownership.
Use make with copy when returning data from a function that manages a buffer pool. The pool recycles the buffer. Your slice must not point into it. Allocate a new slice and copy the data.
Use append when building a sequence, but be aware that intermediate slices may share the backing array until capacity forces a reallocation. If you need independent slices, copy before appending.
Use a separate allocation when passing data across API boundaries where the caller expects ownership. If the API contract says "returns a slice", the caller assumes they own the data. Honor that contract with a copy.
Slices are views. Copies are ownership. Choose based on lifetime and mutation.