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.
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
Prerequisites
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 []intArrays 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] — unaffectedBecause 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 5Assigning 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 writeappend: 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 exists —
appendwrites the new element directly into the backing array's next free slot and returns a slice withlen+1. The original slice's backing array is now shared and modified. - No spare capacity —
appendallocates 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 ofappendpoint 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 midThis 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 mapAlways 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 existThe 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
appendalways mutates in place, or never does. It does either, based on spare capacity — don't rely on the original variable after callingappendon it. - Writing to a
nilmap. Declaringvar m map[K]Vwithoutmakecompiles 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 — usev, 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"
Go Fundamentals Series
Article 2 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Pointers in Go: When to Use *T and Why
What a pointer actually is, why Go passes everything by value by default, and the concrete rule for when a function or method should take a pointer instead of a copy — with diagrams.
Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
What actually happens when you call http.ListenAndServe: TCP listen and accept, the goroutine-per-connection model, the Handler interface, routing, and the timeouts a real server needs — with diagrams and a runnable example.
Defer, Panic, and Recover in Go
How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.