Skip to content
GoBeginner6 min read

Slices, Arrays, and Maps in Go: How They Actually Work

Why a slice is a small header pointing at a backing array, what append actually does when it grows, why slicing can alias memory you didn't mean to share, and how Go's maps behave — with diagrams.

GoBackend.dev
GoSlicesArraysMapsData Structures

TL;DR

An array is a fixed-size value; a slice is a small header (pointer, length, capacity) pointing at a backing array, so slices sharing that array can see each other's writes — and append only breaks that link when it has to grow.

What You'll Learn

  • Why arrays and slices are different types with very different behavior
  • The three fields inside a slice header, and why that matters for how slices are passed around
  • What append actually does, and why it sometimes stops sharing memory with the original slice
  • How re-slicing (a[1:3]) aliases the same backing array — and the bug that causes
  • How Go's maps behave: nil maps, zero values, and the comma-ok idiom
  • Why map iteration order is deliberately randomized

The problem

This looks like it should print [1 2 99], and does. This one looks almost identical, and doesn't do what most people expect the first time:

original := []int{1, 2, 3}
modified := append(original, 4)
modified[0] = 99
fmt.Println(original) // [1 2 3] or [99 2 3] — depends on capacity!

Whether original changes depends entirely on whether append had to allocate new memory or not — information you can't see just by reading this snippet. Understanding slices as a small header over a backing array is what makes that outcome predictable instead of surprising.

Arrays: fixed size, value type

An array's size is part of its type:

var a [3]int        // an array of exactly 3 ints, type [3]int
b := [3]int{1, 2, 3} // [3]int, not [4]int, not []int

Arrays are value types — assigning one, or passing one to a function, copies every element:

a := [3]int{1, 2, 3}
c := a       // c is a full, independent copy
c[0] = 99
fmt.Println(a) // [1 2 3] — unaffected

Because the size is fixed and copying is expensive for anything large, plain arrays are rare in everyday Go code. Slices are what you actually use.

Slices: a header over a backing array

A slice looks like an array ([]int instead of [3]int) but is a completely different kind of value under the hood — three fields:

Rendering diagram…

  • pointer — where the first element of the visible slice lives in the backing array
  • length — how many elements the slice currently exposes (len(s))
  • capacity — how many elements the backing array has room for, starting from that pointer (cap(s))
s := make([]int, 3, 5) // len 3, cap 5 — 2 spare slots in the backing array
fmt.Println(len(s), cap(s)) // 3 5

Assigning or passing a slice copies the header, not the data. Two slices with the same pointer are looking at the same backing array — writes through one are visible through the other:

s1 := []int{1, 2, 3}
s2 := s1        // copies the header — same backing array
s2[0] = 99
fmt.Println(s1) // [99 2 3] — s1 sees s2's write

append: grows in place, until it can't

append adds an element and returns a (possibly new) slice. What it does internally depends on capacity:

  • Spare capacity existsappend writes the new element directly into the backing array's next free slot and returns a slice with len+1. The original slice's backing array is now shared and modified.
  • No spare capacityappend allocates a brand-new, larger backing array, copies every existing element into it, then adds the new one. From this point on, the original slice and the result of append point at different arrays — writes to one no longer affect the other.
s := make([]int, 3, 3)   // len 3, cap 3 — no spare room
s2 := append(s, 4)       // must reallocate — s and s2 now point at different arrays
s2[0] = 99
fmt.Println(s)  // [0 0 0] — untouched
fmt.Println(s2) // [99 0 0 4]

That's the exact ambiguity from the opening example: whether modified[0] = 99 affects original depends entirely on whether original had spare capacity when you called append. The fix, when you don't want that ambiguity, is to always reassign and never rely on the original variable afterward: original = append(original, 4).

Re-slicing shares memory too

Slicing a slice (s[1:3]) doesn't copy anything — it produces a new header pointing into the same backing array, just starting at a different offset:

s := []int{10, 20, 30, 40, 50}
mid := s[1:3]  // [20 30] — same backing array as s
mid[0] = 99
fmt.Println(s) // [10 99 30 40 50] — s sees the write through mid

This is intentional and efficient (no copying), but it means passing a sub-slice to a function that mutates it can surprise the caller who still holds the original. When you genuinely need an independent copy, use copy():

independent := make([]int, len(mid))
copy(independent, mid)

Maps: hash tables with a few sharp edges

A map is Go's hash table type: map[KeyType]ValueType. Three behaviors are worth knowing before they surprise you in production:

1. A nil map can be read, but not written:

var m map[string]int // nil — no make() called
fmt.Println(m["missing"]) // 0 — safe, returns the zero value
m["key"] = 1               // panic: assignment to entry in nil map

Always make(map[K]V) (or a map literal) before writing.

2. The zero value and "not present" look identical unless you ask twice:

m := map[string]int{"a": 0}
 
v := m["a"]       // 0 — but is that a real 0, or a missing key?
v, ok := m["a"]   // v=0, ok=true  — key exists, value really is 0
v, ok = m["z"]    // v=0, ok=false — key doesn't exist

The two-value ("comma-ok") form is the only reliable way to distinguish "the key maps to the zero value" from "the key isn't there at all."

3. Iteration order is deliberately randomized. Ranging over a map with for k, v := range m visits entries in a different, unpredictable order each run — a deliberate language design choice so code never accidentally comes to depend on an ordering the map was never meant to guarantee. Sort the keys yourself if you need a stable order:

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)

Common mistakes

  • Assuming append always mutates in place, or never does. It does either, based on spare capacity — don't rely on the original variable after calling append on it.
  • Writing to a nil map. Declaring var m map[K]V without make compiles fine and panics only the first time you write to it.
  • Reading m[key] with the single-value form to check existence. It can't distinguish a real zero value from a missing key — use v, ok := m[key].
  • Expecting map iteration order to be stable across runs. It isn't, on purpose.

Summary

An array's size is part of its type and copying it copies every element; a slice is a three-field header — pointer, length, capacity — over a backing array, so copies of the header share the underlying data until append forces a reallocation. Re-slicing shares memory the same way. Maps are safe to read when nil but panic on write, and only the comma-ok form reliably distinguishes a missing key from one mapped to a zero value. These aren't edge cases — they're the actual mechanics behind nearly every slice and map bug you'll hit in real Go code.

Key Takeaways

  • A slice is a header — {pointer, length, capacity} — not the data itself; copying a slice copies the header, not the underlying array
  • append grows in place (mutating the shared backing array) when there's spare capacity, and allocates a new array (breaking the sharing) only when there isn't
  • Re-slicing shares the same backing array — writing into a[1:3] can silently change what a[0:5] sees
  • Reading a nil map returns the zero value safely; writing to a nil map panics — always make(map[K]V) before writing
  • Use the comma-ok form (v, ok := m[key]) to tell "key present with zero value" apart from "key absent"