Skip to main content
Arrays and slices

Arrays and slices

9 minutes read

Filed underGo Programming Languageon

Master Go's two core sequence types — fixed-size arrays and dynamic slices. Learn how slices wrap arrays under the hood, how capacity and growth work, and how to avoid the shared-memory pitfalls.

Arrays

An array is a fixed-size, ordered collection of elements of the same type, stored in contiguous blocks of memory. Each position in the array is called an element, and each element is accessed by its index — a zero-based integer offset from the start.

var temperatures [5]float64

temperatures[0] = 20.1
temperatures[1] = 22.5
temperatures[2] = 19.8
temperatures[3] = 23.4
temperatures[4] = 21.0

fmt.Println(temperatures[0]) // 20.1
fmt.Println(temperatures[4]) // 21.0
fmt.Println(len(temperatures)) // 5

The len built-in returns the number of elements in an array. Since the length is fixed, len always returns the same value for a given array type.

Declaring an array

Go provides several forms for declaring and initializing arrays.

Basic form: all elements are set to the zero value of the type.

var counts [4]int // [0, 0, 0, 0]

With elements: initializes with specific values. The number of values must match the declared length.

primes := [5]int{2, 3, 5, 7, 11}

Implicit length: ... tells the compiler to count the values and set the length for you.

primes := [...]int{2, 3, 5, 7, 11} // length 5, inferred

Sparse initialization: only certain indices are specified — the rest get the zero value.

sparse := [5]int{2: 10, 4: 20} // [0, 0, 10, 0, 20]

Accessing elements

Elements are accessed using bracket notation. Indexes start at zero, so the valid range is 0 to len(a) - 1. Accessing an index outside that range causes a runtime panic:

words := [3]string{"go", "is", "fast"}

fmt.Println(words[0]) // "go"
fmt.Println(words[2]) // "fast"

words[3] // panic: runtime error: index out of range [3] with length 3

Go does not silently return a zero value or wrap around — it panics immediately. This makes out-of-bounds bugs easy to detect.

Benefits of arrays

Because all elements are stored contiguously in memory, accessing any element by index is O(1) — the CPU computes its address directly from the base address and the index. There is no pointer chasing, no indirection. Arrays are as fast as memory access gets.

Common uses include fixed-size buffers (network packets, file headers), lookup tables, and as the foundation for slices.

Limitations

Arrays in Go have strict limitations that push most real-world code toward slices.

Fixed size. Once declared, the length cannot change. You cannot append to an array or remove elements from it.

Size is part of the type. [3]int and [4]int are two distinct, incompatible types. A function that accepts [3]int cannot receive a [4]int.

No variable length. The length must be a constant known at compile time — you cannot use a variable:

n := 5
var a [n]int // compile error: non-constant array bound n

In most cases, slices are the right choice. Arrays are best reserved for situations where you know the exact size at compile time and need predictable memory layout.


Slices

A slice is a lightweight wrapper around an array. It gives you a dynamic view into an underlying array: you can grow it, shrink it, and pass it around without copying the data.

A slice has three components under the hood:

  • A pointer to the start of its window into the underlying array
  • A length — the number of elements currently visible through the slice
  • A capacity — the total number of elements available from the pointer to the end of the underlying array
s := []int{10, 20, 30, 40, 50}

fmt.Println(len(s)) // 5 — number of elements in the slice
fmt.Println(cap(s)) // 5 — size of the underlying array

Declaring a slice

Slices are declared without a length — that is what distinguishes them from arrays syntactically.

Nil slice: The zero value of a slice is nil. It has no underlying array, length 0, and capacity 0.

var s []int // nil slice

Empty slice: Has an underlying array (allocated but with no elements stored), length 0, and capacity 0.

s := []int{} // empty slice

With elements:

s := []int{10, 20, 30}

nil vs empty slice

A nil slice and an empty slice behave identically in almost every situation — len returns 0, cap returns 0, range iterates zero times, and append works on both. The difference surfaces in one specific place: JSON encoding.

var a []int  // nil slice
b := []int{} // empty slice

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(len(a) == len(b)) // true — both 0

json.Marshal encodes a nil slice as null and an empty slice as []. For most other purposes, prefer nil slices — they are the idiomatic zero value.

The make function

make creates a slice with a specified length and optional capacity. It is the preferred way to create a slice when you know the size but do not have initial values yet.

s := make([]int, 5)      // len=5, cap=5
s := make([]int, 3, 10)  // len=3, cap=10

Providing a capacity hint when you know roughly how many elements you will append avoids unnecessary reallocations later.

The append function

append adds one or more elements to the end of a slice and returns the updated slice. You must assign the return value — if append needs to allocate a new underlying array, the original variable is left pointing to the old one.

s := []int{1, 2, 3}
s = append(s, 4)        // [1, 2, 3, 4]
s = append(s, 5, 6, 7)  // [1, 2, 3, 4, 5, 6, 7]

To merge two slices, expand the second with ...:

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

Always assign the return value of append

If you call append without assigning the result, the changes are silently discarded. This is a common mistake that the compiler does not catch because append is a valid expression on its own.

Behind the scenes: growth

When append needs more capacity than available, Go allocates a new, larger underlying array, copies all existing elements into it, and returns a slice pointing to the new array. This is automatic — you never manage it manually.

The growth strategy in Go is roughly:

  • For small slices (under ~256 elements): double the capacity
  • For larger slices: grow by approximately 25%
s := make([]int, 0, 2)
for i := 0; i < 6; i++ {
    s = append(s, i)
    fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1 cap=2
// len=2 cap=2
// len=3 cap=4  ← new array allocated, capacity doubled
// len=4 cap=4
// len=5 cap=8  ← new array allocated, capacity doubled
// len=6 cap=8

Each reallocation is O(n) — but because the capacity doubles each time, the total work across all appends is amortized O(1) per element.

Indexing

Slices use the same bracket notation as arrays and are also zero-indexed. The valid range is 0 to len(s) - 1. Accessing outside that range causes a runtime panic, just like arrays:

s := []int{10, 20, 30}
fmt.Println(s[0]) // 10
fmt.Println(s[2]) // 30

s[-1] // panic: index out of range [-1]
s[3]  // panic: index out of range [3] with length 3

Slicing

You can create a new slice from an existing slice or array using a slice expression s[low:high]. The result is a new slice header pointing into the same underlying array — no data is copied.

a := []int{10, 20, 30, 40, 50}
b := a[1:4] // [20, 30, 40]

The range is half-open: it includes the element at low and excludes the element at high. a[1:4] covers indices 1, 2, and 3 — three elements.

Either bound can be omitted:

a[:3] // first three elements: [10, 20, 30]
a[2:] // from index 2 to end: [30, 40, 50]
a[:]  // entire slice: [10, 20, 30, 40, 50]

The capacity of the new slice extends from low to the end of the underlying array:

a := []int{10, 20, 30, 40, 50}
b := a[1:3] // len=2, cap=4 (from index 1 to end of a's backing array)

The ripple effect

Because slicing does not copy data, modifying elements through one slice modifies the underlying array — and any other slice sharing it:

a := []int{10, 20, 30, 40, 50}
b := a[1:4]

b[0] = 99

fmt.Println(a) // [10, 99, 30, 40, 50] — a[1] changed through b
fmt.Println(b) // [99, 30, 40]

This also applies to append when there is remaining capacity: appending to b can overwrite elements of a that are beyond b's current length.

append on a sub-slice can overwrite the parent

If b := a[1:3] and cap(b) > len(b), then append(b, x) writes x into a[3] — the position just past b's end in the shared array. This is one of the most surprising behaviors in Go slices.

Avoiding the ripple effect with copy

When you need an independent slice that does not share memory with the source, use copy:

src := []int{10, 20, 30, 40, 50}
dst := make([]int, len(src))
copy(dst, src)

dst[0] = 99
fmt.Println(src) // [10, 20, 30, 40, 50] — unchanged
fmt.Println(dst) // [99, 20, 30, 40, 50]

copy(dst, src) copies the minimum of len(dst) and len(src) elements and returns the number of elements copied. The two slices share nothing after the copy.