Slices, Arrays, and Maps
Look back at every code example in the last chapter and a pattern shows
up you probably didn't stop to question: []Rectangle, []T, and — a
few chapters from now — things like map[string]int showing up as
casually as int or string ever did. You've been reading them on
instinct, the way you'd read a sentence in a language you're only
starting to learn: close enough to guess the meaning, not close enough
to notice what's actually happening underneath. That gap is exactly what
this chapter closes, and it's worth closing properly, because slices and
maps are involved in more real-world Go bugs — and more Go job interview
questions — than arguably any other pair of features in the language.
An array is a shelf built to hold exactly twelve boxes, permanently part of the shelf's own blueprint. A slice is a window someone cut into that shelf — it shows you some of the boxes, it can be resized to show more or fewer, and disturbingly, two completely different windows can sometimes be looking at the exact same boxes without either window knowing the other exists.
Arrays: Fixed, Rarely Used Directly
An array's length is part of its type, fixed at compile time:
var nums [5]int
nums[0] = 10
fmt.Println(nums) // [10 0 0 0 0]
primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(len(primes)) // 5
[5]int and [10]int are two entirely different types — you cannot
assign one to the other, and a function written to accept [5]int
rejects a [10]int at compile time, even though both are "arrays of
int." This rigidity is exactly why arrays show up rarely in idiomatic Go
code outside of a few specialized spots (fixed-size buffers, cryptographic
hashes like [32]byte, coordinate types like [3]float64) — almost
everywhere else, you reach for a slice instead.
Arrays are also copied by value, just like the basic types from Chapter 2 and the structs from Chapter 4:
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a, b) // [1 2 3] [99 2 3] — b is a fully independent copy
[10000]int parameter copies ten thousand integers on every call, silently. This surprises people coming from languages where array-like things are always passed by reference. If you want a function to see or modify the caller's original data, pass a slice or a pointer to the array, not the array itself.Arrays of the same length and element type are comparable with ==,
which compares every element — another thing slices cannot do at all, as
you'll see shortly.
Slices: Go's Real Workhorse
A slice looks like an array with the length omitted from its type
([]int instead of [5]int), but it is a fundamentally different kind
of value underneath: a small header holding a pointer to some underlying
array, a length, and a capacity.
nums := []int{10, 20, 30}
fmt.Println(len(nums)) // 3
grid := make([]int, 3) // length 3, all zero: [0 0 0]
buf := make([]int, 0, 10) // length 0, capacity 10 — room to grow, no realloc
Slicing an existing slice (or array) with s[low:high] produces a new
slice header pointing at the same backing array, starting at low and
stopping just before high:
letters := []string{"a", "b", "c", "d", "e"}
middle := letters[1:4]
fmt.Println(middle) // [b c d]
A slice is not a copy of data — it's a window onto data. Two slices can look completely independent while quietly sharing every byte they show you.
Growing a Slice: append and the Aliasing Trap
append adds elements to a slice, returning a (possibly new) slice that
includes them:
nums := []int{1, 2, 3}
nums = append(nums, 4)
fmt.Println(nums) // [1 2 3 4]
Notice the reassignment: nums = append(nums, 4) is required, not
optional style. If the slice's current capacity has room for the new
element, append writes it directly into the existing backing array and
returns a slice with the same pointer, just a larger length. If there
isn't room, append allocates a brand-new, larger backing array, copies
everything over, and returns a slice pointing at that new array instead.
Ignoring the return value silently discards the appended element in the
second case, and is a genuine, easy-to-write bug:
func addOne(s []int) {
s = append(s, 1) // reassigns the LOCAL s, caller's slice is unchanged
}
nums := []int{1, 2, 3}
addOne(nums)
fmt.Println(nums) // [1 2 3] — the 1 never made it back to the caller
That capacity-dependent behavior is the source of one of Go's most famous gotchas: two slices sharing a backing array can start silently interfering with each other the moment one of them appends within its existing capacity.
base := make([]int, 3, 5) // len 3, cap 5 — two spare slots
base[0], base[1], base[2] = 1, 2, 3
a := base[0:2] // [1 2], shares base's backing array
a = append(a, 99) // fits in base's spare capacity — writes in place
fmt.Println(base) // [1 2 99] — base[2] got silently overwritten!
fmt.Println(a) // [1 2 99]
append(a, 99) didn't need to allocate anything new, because a's
underlying array (which is base's array) had a spare slot right after
where a currently ends — so append used it, and that slot happened to
be base[2], which held 3 a moment ago. Nothing in this program is
buggy Go — every rule was followed exactly as documented — and yet
base changed after a call that only mentioned a.
Full Slice Expressions: Capping Capacity on Purpose
A third index, s[low:high:max], sets not just the new slice's length
(high - low, same as the two-index form) but also its capacity
(max - low). Capping capacity this way forces the next append on
that slice to always allocate a fresh backing array instead of possibly
reusing shared memory:
base := make([]int, 3, 5)
base[0], base[1], base[2] = 1, 2, 3
a := base[0:2:2] // len 2, cap 2 — no spare room borrowed from base
a = append(a, 99) // capacity is full, so this allocates a NEW array
fmt.Println(base) // [1 2 3] — completely untouched this time
fmt.Println(a) // [1 2 99] — its own independent backing array
Copying Slices Explicitly with copy
When you need a genuinely independent copy rather than a differently-capped
view, the built-in copy function copies elements from a source slice
into a destination slice, up to the length of whichever is shorter:
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)
dst[0] = 99
fmt.Println(src, dst, n) // [1 2 3] [99 2 3] 3
copy returns the number of elements actually copied, which is worth
checking whenever dst might be shorter than src — a partial copy is
silent otherwise, with no error and no panic.
nil Slices vs. Empty Slices
A declared-but-unassigned slice is nil, not merely empty — but,
unusually for Go, a nil slice is still perfectly safe to read from and
append to:
var s []int
fmt.Println(s == nil, len(s), cap(s)) // true 0 0
s = append(s, 1) // works fine — allocates a backing array on first use
var s []int |
s := []int{} |
s := make([]int, 0) |
|
|---|---|---|---|
Is it nil? |
Yes | No | No |
len(s) |
0 | 0 | 0 |
Safe to append to? |
Yes | Yes | Yes |
| Encodes to JSON as | null |
[] |
[] |
len(s) == 0 is almost always the right check — both a nil and an empty slice pass it identically, and both are safe to range over or append to. The distinction becomes visible (and sometimes load-bearing) the moment a slice crosses a boundary that cares about the difference, most commonly encoding/json: a nil slice marshals to null, while an empty-but-non-nil slice marshals to [] — a real difference for any API consumer expecting an array back.Maps: Key-Value Lookups
A map associates keys of one type with values of another, and — like slices — is built into the language rather than bolted on as a library type:
ages := map[string]int{
"alice": 30,
"bob": 25,
}
ages["carol"] = 40
fmt.Println(ages["alice"]) // 30
Looking up a key that doesn't exist returns the value type's zero value, silently — which is exactly why the two-value form exists, to distinguish "present with a zero value" from "genuinely absent":
count, ok := ages["dave"]
if !ok {
fmt.Println("dave not found")
}
fmt.Println(count) // 0 — the int zero value, whether or not ok was true
delete(m, key) removes a key, and is a no-op (not an error) if the key
was never present:
delete(ages, "bob")
var m map[string]int, is nil. Reading from it works exactly like an empty map (m["x"] returns the zero value, ok is false) — but writing to it, m["x"] = 1, panics immediately with assignment to entry in nil map. Always initialize a map you intend to write to, with either a literal (map[string]int{}) or make(map[string]int), before the first write.Map Iteration Order Is Deliberately Random
Ranging over a map visits its entries, but in an order the language explicitly refuses to guarantee — and, since Go 1, the runtime actively randomizes that order on every single iteration, on purpose:
for key, value := range ages {
fmt.Println(key, value) // order changes between runs, and even
// between two loops in the same run
}
Go didn't leave map order unspecified out of laziness — the runtime actively randomizes it, specifically so no one can accidentally write code that depends on an ordering the spec never promised. A bug that only shows up "sometimes" is far more expensive to find than one that shows up every time; randomizing iteration order turns a rare, environment-dependent bug into one you'll catch in testing almost immediately.
Maps Are Reference-Like, and Not Comparable
Passing a map to a function passes the same header a slice would — a small internal pointer to the map's actual data — so mutations inside the function are visible to the caller, unlike a struct or array argument:
func addEntry(m map[string]int) {
m["z"] = 100 // visible to the caller — no return value needed
}
scores := map[string]int{}
addEntry(scores)
fmt.Println(scores) // map[z:100]
Unlike arrays and most structs, maps cannot be compared with == at all
(except to nil) — the compiler rejects it outright:
a := map[string]int{"x": 1}
b := map[string]int{"x": 1}
// a == b // compile error: invalid operation: a == b (map can only be
// compared to nil)
For genuine content comparison, use maps.Equal (Go 1.21+, from the
standard library's maps package) or reflect.DeepEqual for older code:
import "maps"
fmt.Println(maps.Equal(a, b)) // true — same keys, same values
The slices and maps Standard Library Packages
Go 1.21 added slices and maps packages to the standard library, built
entirely on the generics you met in Chapter 4 — for the everyday
operations you'd otherwise hand-write a loop for, every time:
import "slices"
nums := []int{5, 2, 8, 1}
slices.Sort(nums)
fmt.Println(nums) // [1 2 5 8]
fmt.Println(slices.Contains(nums, 8)) // true
fmt.Println(slices.Max(nums)) // 8
import "maps"
ages := map[string]int{"alice": 30, "bob": 25}
for k := range ages {
fmt.Println(k) // still unordered — range doesn't change that
}
Arrays, Slices, and Maps at a Glance
| Array | Slice | Map | |
|---|---|---|---|
| Size | Fixed, part of the type | Dynamic, grows via append |
Dynamic |
| Zero value | All elements zeroed | nil (safe to read/append) |
nil (safe to read, NOT write) |
| Passed to a function as | A full copy | A small header (shared data) | A small header (shared data) |
Comparable with == |
Yes, element-by-element | No — compile error | No — compile error (except to nil) |
| Iteration order | Index order, guaranteed | Index order, guaranteed | Deliberately randomized |
Try It Yourself
Reproduce the aliasing bug from this chapter, then fix it two different ways:
package main
import "fmt"
func main() {
base := make([]int, 3, 5)
base[0], base[1], base[2] = 1, 2, 3
a := base[0:2]
a = append(a, 99)
fmt.Println("base:", base)
fmt.Println("a:", a)
}
- Run it as-is and confirm
baseshows[1 2 99]— the silent overwrite described earlier in this chapter. - Fix it using a full slice expression: change
a := base[0:2]toa := base[0:2:2], re-run, and confirmbaseis now untouched. - Fix it a second, different way: revert to
a := base[0:2], but buildaas an independent copy instead, usingmakeandcopy. Confirmbaseis untouched with this approach too. - Bonus: write a small
map[string][]int(a map whose values are slices), append to one of its slice values in a loop, and print the map afterward to confirm the mutation is visible — since a map's values are ordinary Go values, but a slice value stored in one still behaves exactly like any other slice.
Frequently Asked Questions
Why does Go even have both arrays and slices instead of just one flexible type? Arrays exist mostly to give slices something to point at, and for the rare cases (fixed-size hashes, small fixed coordinate types) where a guaranteed, compile-time-fixed size is itself the point. In everyday code, if you're not sure which one you want, you want a slice — arrays are the exception, not the default.
Is append ever unsafe to use without reassigning the result?
Not unsafe exactly, but its result is very easy to discard incorrectly. append may or may not return a slice pointing at new memory, and the language gives you no way to tell which happened just by looking at the call — so s = append(s, x) is the only form that's correct in every case, and the shorter-looking append(s, x) alone silently drops the new element whenever growth actually occurred.
Why can't I compare two slices or two maps with ==?
Because there's no single obviously-correct definition of "equal" that the compiler could pick for you automatically — should two slices sharing a backing array but different lengths be equal? Should map key order matter? Go sidesteps the ambiguity by refusing to compile == on these types at all, and hands you explicit tools (slices.Equal, maps.Equal, or a hand-written loop) to define equality on your own terms instead.
If map order is random, how do I print a map's contents in a predictable order for logs or tests?
Copy the keys into a slice, sort that slice, and range over the sorted slice instead of the map directly — keys := make([]string, 0, len(m)), append each key, slices.Sort(keys), then look each one up in m as you print it. This is such a common pattern that reaching for it should become close to automatic once you've been burned by a flaky test that assumed map order once.
Does everything in this chapter apply the same way to strings, since they're sort of like a slice of bytes?
Mostly yes for read access — a string can be sliced with s[low:high] and shares memory with its source the same way a slice does — but strings are immutable, so there's no append-style mutation and no aliasing-write hazard to worry about. The rune-vs-byte indexing subtlety from Chapter 2 is the bigger gotcha with strings specifically; the slicing mechanics here are the same underlying idea, just with the mutation risk removed.
Where This Goes From Here
Every one of these three types has quietly assumed something in the
background that's easy to take for granted once you've internalized it:
that a slice knows its own length, that a map lookup either finds
something or doesn't, that append and delete do exactly what their
names promise with no hidden failure mode to check for. The next chapter
breaks a much bigger assumption open on purpose — the one about what
happens when an operation can fail outright. Go has no exceptions, which
means failure has to be handled some other way, and that "some other
way" turns out to be one of the most opinionated, and most argued-about,
decisions in the entire language.