Sitemap
Alexander Obregon

Alexander Obregon

I post daily about programming topics and share what I learn as I go. For recaps, exclusive content, and to support me: https://alexanderobregon.substack.com

Go Slice Copy Mechanics

7 min readSep 18, 2025

--

Press enter or click to view image in full size
Image Source

Slices are one of the most common data types in Go, valued for how flexible and efficient they are. They often look similar to dynamic arrays, but their real distinction comes from the way they connect to backing arrays. Copying them is more involved than it first appears because slices carry references that can point to shared memory. To work with them effectively, it helps to see what happens at the memory level when a slice is copied, how storage can be shared, and the moments where data separates into new space.

I publish free articles like this daily, and I have a Golang section on Substack where you’ll find my whole series. My Substack also includes weekly recaps if you’d like to keep up with everything I’m publishing.

How Go Represents Slices

Slices in Go look light, but there’s a lot happening behind the scenes that explains their behavior when copied or resized. A slice isn’t raw memory on its own, but a small structure that the runtime manages for you. That structure acts like a handle to a block of memory, and it’s what lets slices grow, shrink, and share data without the programmer having to manage arrays directly.

The Slice Header

Every slice in Go has a fixed three-field header. That header is what’s copied whenever you assign a slice variable. The fields are a pointer to the first element of the backing array, the length of the slice, and the capacity. Together those three values describe how the slice behaves when read or extended.

Here’s the closest public representation, provided by the reflect package:

// reflect.SliceHeader (public type in the standard library)
type SliceHeader struct {
Data uintptr
Len int
Cap int
}

This isn’t the real definition. You can construct reflect.SliceHeader, though it’s for low-level work and typically involves unsafe. For a non-nil slice, the pointer targets the first element of the backing array; for a nil slice it is zero. Len reports how many elements are accessible, and Cap bounds how many can be appended before Go allocates a new array.

To make this more concrete, let’s see a simple case:

numbers := []int{10, 20, 30, 40}
fmt.Println(len(numbers)) // 4
fmt.Println(cap(numbers)) // 4

The slice header here points into an array with four integers. The length matches the number of elements, and because the slice fills the entire array, the capacity is also four. Appending another value would force Go to grow the array behind the scenes.

Shrinking the slice doesn’t touch the array itself, only the portion the header reports as visible:

words := []string{"tom", "lisa", "mark", "rachel"}
short := words[:2]
fmt.Println(len(short)) // 2
fmt.Println(cap(short)) // 4

The slice short looks smaller, but the header still knows there are two extra slots available in capacity. That’s why appending to short can reuse existing storage if there’s room. The slice header is just metadata, and copying it copies only that metadata, not the array underneath.

Backing Arrays

Slices always live on top of arrays. When you declare a slice with a literal or make, Go is creating an array in memory and linking the slice header’s pointer to it. That array is where the real values sit, and the slice is simply your view into it.

Take this case:

base := [5]int{1, 2, 3, 4, 5}
s := base[1:4]
fmt.Println(s) // [2 3 4]

The array base has five elements, but the slice s covers only the middle portion. Both variables point to the same storage, but the slice header makes sure you only see elements two through four. The slice doesn’t copy those values; it simply shifts its pointer into the array and records a length of three.

Multiple slices can overlap on the same array, with each slice header maintaining its own length and capacity. That means two slices of the same array can represent very different ranges.

full := []int{100, 200, 300, 400, 500}
front := full[:3]
tail := full[2:]

fmt.Println(front) // [100 200 300]
fmt.Println(tail) // [300 400 500]

Both front and tail point into the same five-element array. One exposes the first three slots, the other covers from the third element onward. If you modify the shared element at index 2, both slices reflect that because the underlying storage is the same.

Capacity is the guardrail that keeps a slice from moving past what’s safe. If you try to reslice beyond it, Go panics because the header would describe invalid memory.

values := make([]int, 2, 3)
fmt.Println(values) // [0 0]
fmt.Println(len(values)) // 2
fmt.Println(cap(values)) // 3

// Expanding to capacity works
expanded := values[:3]
fmt.Println(expanded) // [0 0 0]

// Going past capacity causes a panic
// invalid := values[:4]

This makes it clear that length defines what’s currently visible, while capacity sets the boundary on how far that visibility can extend without forcing allocation of new space.

Get Alexander Obregon’s stories in your inbox

Join Medium for free to get updates from this writer.

Backing arrays don’t disappear until nothing points to them anymore. As long as at least one slice header still references an array, the garbage collector keeps it around. That’s why trimming a slice doesn’t instantly free memory, because the backing array still exists and is tied to the slice header’s pointer.

Copying Slices and Shared Storage

Slices carry a small header that can be duplicated, but the backing array behavior changes the outcome depending on how you copy. Assigning one slice to another is different from using the copy function, and slicing creates yet another pattern of shared storage.

Assigning One Slice to Another

When one slice variable is set equal to another, only the slice header is copied. Both variables point to the same backing array. That means two slice variables can look independent but still touch the same memory underneath. Any change through one variable will appear when you inspect the other.

a := []int{5, 10, 15, 20}
b := a
b[1] = 99

fmt.Println(a) // [5 99 15 20]
fmt.Println(b) // [5 99 15 20]

Variable b is its own slice header, but both a and b refer to the same array. Assignments like this are cheap because only the header is duplicated, but it also means they share side effects.

The Copy Function

The built-in copy function writes elements into a destination slice's backing array. copy writes values into the destination's array, and whether the slices share storage after that comes down to what the destination already points to. If the destination refers to its own array, they're separate. If it still ties back to the same array, the slices continue to overlap. The destination slice must have been allocated already, otherwise there's nowhere to place the elements.

a := []int{1, 2, 3, 4}
b := make([]int, len(a))
copy(b, a)

b[0] = 99
fmt.Println(a) // [1 2 3 4]
fmt.Println(b) // [99 2 3 4]

The function takes the minimum of the source and destination lengths to decide how many elements are copied. That means if the destination is smaller, not all elements will fit.

source := []string{"cat", "dog", "fish"}
target := make([]string, 2)
n := copy(target, source)

fmt.Println(target) // [cat dog]
fmt.Println(n) // 2

Here the copy count is limited to two because that’s the size of target. The third element never makes it over, and the return value tells you how many were transferred.

A different case arises when the destination has greater length than the source. Those extra slots remain untouched.

values := []int{8, 9}
holder := make([]int, 5)
copy(holder, values)

fmt.Println(holder) // [8 9 0 0 0]

The untouched elements stay at their zero values. This is useful when you want a slice of fixed size where only part of it is populated with real data.

Slicing Behavior and Shared Data

Creating a subslice produces a new header that points into the same backing array as the original slice. Length and capacity are adjusted, but the array is shared. This is why two slices created from one source can modify the same values.

numbers := []int{11, 22, 33, 44, 55}
part := numbers[1:4]

part[0] = 77
fmt.Println(numbers) // [11 77 33 44 55]
fmt.Println(part) // [77 33 44]

The slice part starts at index 1 of numbers, but because they overlap in storage, an update in one is visible through the other.

It’s possible to have multiple overlapping subslices that all share certain elements while exposing different ranges.

letters := []string{"a", "b", "c", "d", "e"}
first := letters[:3]
second := letters[2:]

first[2] = "x"
fmt.Println(letters) // [a b x d e]
fmt.Println(first) // [a b x]
fmt.Println(second) // [x d e]

Both first and second overlap on index 2, so modifying that element through one slice is reflected in the other.

Capacity also plays into how far a subslice can extend. If you create a subslice that doesn’t consume all available capacity, it’s possible to append without forcing new storage.

values := []int{100, 200, 300, 400}
sub := values[1:3] // length 2, capacity 3
sub = append(sub, 999)

fmt.Println(values) // [100 200 300 999]
fmt.Println(sub) // [200 300 999]

The new element fits inside the existing backing array because there was space left in capacity. Both slices continue to share the same array, which is why values also contains the new element at the end.

Conclusion

Go slices depend on a compact header that decides how data is accessed, and that header always ties back to an array in memory. Copying only the header means two slices still share storage, while copying elements into new space creates separation. Subslices fall in between, sharing data but adjusting length and capacity. These mechanics explain why slices can feel light yet behave in very specific ways once memory and storage come into play.

  1. Go Slices: usage and internals
  2. Slice expressions in Go specification
  3. Built-in functions in Go (copy, make, append)
  4. Effective Go on slices
  5. Go Language Specification on iota

Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.

--

--