Slices in Go

An overview of slices in Go covering type structure, creation, slicing, length and capacity, append, copy, nested slices, and shared underlying arrays

Arrays work well when you know exactly how many elements you need and that number never changes. Most programs, however, need collections that can grow and shrink as data arrives or leaves. That is the problem slices solve. A slice is a lightweight, flexible view into a contiguous sequence of elements—an abstraction built directly on top of arrays but without the fixed‑size constraint.

This document introduces slices as a whole. Each of the topics below links to a deeper dive, but the explanations here are enough to understand what slices are, why they are the backbone of almost every Go program, and how to use them correctly.

Why Slices Exist

In Go, an array’s length is part of its type. [4]int and [5]int are incompatible; you cannot pass a [4]int to a function that expects a [5]int, and you cannot resize an array after it is created. That makes arrays safe and predictable but rigid.

Slices remove the length from the type. A slice of integers is always []int, regardless of how many elements it currently holds. The magic underneath is that a slice does not store the data itself. It describes a portion of an underlying array and carries three pieces of information: a pointer to the array, a length, and a capacity. Because the data lives in the array and the slice just points to it, operations on slices feel dynamic without copying large amounts of memory.

Slices vs. Arrays:

If you are coming from languages where "arrays" automatically resize, Go’s arrays are different. Think of a Go array as a fixed‑size block of memory and a slice as a dynamic window into that memory—or into a new, automatically managed block when more space is needed.

How Slices Are Structured

A slice value is a tiny struct-like value with three fields:

  1. A pointer to an element of an underlying array.
  2. A length: the number of elements the slice currently exposes.
  3. A capacity: the maximum number of elements the slice can grow to without allocating a new underlying array (starting from the pointer).

The underlying array may be shared among several slices, which is both powerful and dangerous. Multiple slices can look at overlapping regions of the same memory. When you modify an element through one slice, any other slice sharing that region sees the change.

package main
import "fmt"
func main() {
    arr := [5]int{10, 20, 30, 40, 50}
    s1 := arr[0:3] // len 3, cap 5
    s2 := arr[1:4] // len 3, cap 4
    s1[1] = 99
    fmt.Println(s2[0]) // 99
}

The first element of s2 is arr[1], which s1 also points to. Changing that element through s1 is visible through s2. This is the “shared underlying array” behavior that developers must keep in mind.

Creating Slices

You can create a slice in several ways. The choice often depends on whether you know the initial content or just the size.

Using a Slice Literal

A slice literal looks like an array literal without a length inside the brackets.

names := []string{"Alice", "Bob", "Charlie"}

The compiler creates an underlying array of exactly three elements and returns a slice that covers the whole array. Length and capacity are both 3.

Using make

make allocates a new underlying array and returns a slice pointing to it. You can specify the length and, optionally, a separate capacity.

// length 5, capacity 5
nums := make([]int, 5)
// length 0, capacity 10 — useful when you will append many elements
buffer := make([]byte, 0, 10)

Pre‑allocation with make:

When you know roughly how many elements a slice will hold, providing a capacity hint to make avoids repeated allocations and copying as the slice grows. This is a simple performance win in hot paths.

Slicing an Existing Array or Slice

The slice expression s[low:high] creates a new slice header that points into the same backing array, starting at low and stopping before high.

data := [5]int{1, 2, 3, 4, 5}
view := data[1:4] // [2 3 4], len 3, cap 4

Omitting low defaults to 0; omitting high defaults to the length of the original slice or array. Writing data[:] produces a slice covering the entire array—this is how you convert an array value to a slice that references it.

Slice Expressions — Controlling What You See

The general form is s[low:high] or the full three‑index form s[low:high:max]. The two‑index version sets the capacity to the distance from low to the end of the underlying array. The three‑index version restricts the capacity to max - low.

numbers := []int{0, 1, 2, 3, 4, 5, 6, 7}
small := numbers[2:5]    // len 3, cap 6
tight := numbers[2:5:5]  // len 3, cap 3

Reducing the capacity with the third index is valuable when you want to prevent future append calls on a subsliced view from overwriting elements that belong to other slices sharing the same underlying array.

Out‑of‑range panics:

Slice indices must fall within the bounds of the operand. numbers[2:9] would panic because the array/slice being sliced has only 8 elements. The same is true for numbers[2:5:7] if the capacity of the original slice is less than 7.

Length and Capacity — The Two Numbers That Rule Slices

  • Length (len): the number of elements you can access via an index without panicking.
  • Capacity (cap): how far the slice can grow by re‑slicing before it needs a new underlying array.

For a slice created with make([]T, length, capacity), the length tells you how many elements are usable right now (initially zeroed), and the capacity reserves space for future append calls.

s := make([]int, 3, 8)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 8

You can “grow” a slice’s length by re‑slicing up to its capacity: s = s[:cap(s)]. But if you need to go beyond capacity, you must use append, which may allocate a new array.

Think of capacity as a buffer zone. As long as you stay inside it, append is cheap—it just writes to the next unused slot. The moment you exceed it, Go allocates a larger array and copies everything over, which is more expensive.

The append Built‑in — Growing a Slice Dynamically

append adds elements to the end of a slice. Its signature is func append(slice []T, elems ...T) []T. Because the underlying array might need to grow, append returns a possibly new slice; you must always reassign the result to the original variable.

letters := []string{"a", "b"}
letters = append(letters, "c", "d")
// letters is now ["a" "b" "c" "d"], len 4, cap ≥ 4

When there is enough capacity, append places the new elements in the existing backing array, increments the length, and returns the same slice header (with updated length). When capacity is exhausted, append allocates a new array—usually around twice the old capacity for small slices—copies the old elements, adds the new ones, and returns a slice pointing to the fresh memory.

Append may or may not detach:

If you take a subslice with a wide capacity, an append on that subslice can overwrite elements that belong to the original slice because they share the same backing array. Use the three‑index slice form to limit capacity when you intend for a subslice to become independent after future appends.

You can also use the ... operator to spread one slice into another:

a := []int{1, 2}
b := []int{3, 4, 5}
a = append(a, b...) // equivalent to append(a, 3, 4, 5)

The copy Built‑in — Duplicating Elements Safely

copy(dst, src) copies elements from a source slice into a destination slice. It returns the number of elements copied, which is the minimum of len(dst) and len(src). It never changes the length or capacity of either slice; it only writes into existing positions of dst.

src := []string{"x", "y", "z"}
dst := make([]string, 2)
n := copy(dst, src) // n == 2, dst is ["x", "y"]

copy is the correct way to create a completely independent duplicate of a slice’s data:

original := []int{10, 20, 30}
clone := make([]int, len(original))
copy(clone, original)

Now modifying clone does not affect original because the underlying arrays are different. copy handles overlapping slices correctly even when the source and destination share memory, so it can be used to shift elements within the same slice.

Two‑Dimensional and Nested Slices

A slice of slices ([][]T) is a common way to represent grids, matrices, or collections whose rows have varying lengths. Unlike a two‑dimensional array, each inner slice can have a different size and can be resized independently.

grid := make([][]int, 3)
for i := range grid {
    grid[i] = make([]int, i+1)
}
// grid is [[0] [0 0] [0 0 0]]

Because each inner slice is a separate value with its own length and capacity, you get a “jagged” structure by default. This is flexible but requires more allocations than a single contiguous block. If you need a dense rectangular matrix, a flat slice with manual index arithmetic is often faster—but the slice‑of‑slices form is far more readable and idiomatic for general purposes.

Slices and Shared Underlying Arrays — The Subtle Trap

The most common source of confusion with slices is the fact that slicing does not copy data. It creates a new slice header pointing to the same backing array. Modifying elements through one slice modifies them for all slices that share that memory.

data := []int{1, 2, 3, 4, 5}
part := data[1:3] // [2 3]
part[0] = 200
fmt.Println(data) // [1 200 3 4 5]

This is by design and makes slicing extremely efficient. However, it also means a large underlying array cannot be garbage collected if a tiny slice still references any part of it.

func firstDigits(filename string) []byte {
    bigSlice := readWholeFile(filename) // e.g., 2 GB
    // We only need the first 10 bytes.
    return bigSlice[:10]
}

The returned slice keeps the entire 2 GB array alive because it still points into it. The fix is to copy the needed portion:

func firstDigits(filename string) []byte {
    bigSlice := readWholeFile(filename)
    result := make([]byte, 10)
    copy(result, bigSlice[:10])
    return result
}

Memory leak from small slice windows:

A small slice taken from a huge underlying array will pin the whole array in memory until no references exist. When you extract a small result from a large temporary buffer, always copy into a new slice if the large buffer should be released.

Summary

Slices feel like dynamic arrays but are really controlled views over static memory. Their design gives you the speed of arrays with the flexibility of resizable collections, at the cost of needing to understand the pointer, length, and capacity that live inside every slice header. The key takeaways:

  • A slice describes a contiguous segment of an array; it does not own the array.
  • len tells you what you can read right now; cap tells you how far you can grow without a reallocation.
  • Slicing creates new headers, not new data. Use copy when you need independence.
  • append grows the slice; always capture its return value.
  • Pre‑allocation with make and capacity hints reduces allocations in performance‑sensitive code.