Arrays, slices, and maps - plus the slice trap that catches every Python dev
Python's list is a single concept. Go splits it into two: arrays (fixed-size, rarely used directly) and slices (dynamic, the thing you'll use everywhere). The split is important because it exposes something Python hides: what memory do you actually own? That question is the source of Go's most famous gotcha.
A Go array has a fixed size that is baked into its type at compile time. You cannot add or remove elements after creation.
var a [5]int // [0 0 0 0 0] - zero-initialized
b := [3]string{"a", "b", "c"}
c := [...]int{10, 20, 30} // compiler counts: [3]int
a[0] = 42
fmt.Println(a[0], len(a)) // 42 5
This surprises Python developers. [5]int and [6]int are completely different types. You cannot pass a [5]int to a function that expects a [6]int:
func sum(arr [5]int) int { ... }
a := [5]int{1, 2, 3, 4, 5}
b := [6]int{1, 2, 3, 4, 5, 6}
sum(a) // OK
sum(b) // compile error: cannot use [6]int as [5]int
Unlike Python lists, Go arrays are value types. Assigning one to another copies all the data:
a := [3]int{1, 2, 3}
b := a // b is a full copy
b[0] = 99
fmt.Println(a) // [1 2 3] - a is unchanged
fmt.Println(b) // [99 2 3]
# Python lists are always dynamic
# No fixed-size equivalent
x = [1, 2, 3]
x.append(4) # always works
y = x # y is a reference!
y[0] = 99 # x[0] is now 99 too
// Arrays are fixed-size value types
x := [3]int{1, 2, 3}
// x = append(x, 4) -- won't compile
y := x // y is a full copy
y[0] = 99 // x[0] is still 1
Slices look like Python lists on the surface. But under the hood they are a small struct - a header - that points into a backing array. This distinction is everything.
// Three ways to create a slice
s1 := []int{1, 2, 3} // literal
s2 := make([]int, 5) // len=5, cap=5, all zeros
s3 := make([]int, 0, 10) // len=0, cap=10 (pre-allocated)
fmt.Println(len(s1), cap(s1)) // 3 3
fmt.Println(len(s2), cap(s2)) // 5 5
fmt.Println(len(s3), cap(s3)) // 0 10
s1 = append(s1, 4) // grow it
fmt.Println(s1) // [1 2 3 4]
Every slice is exactly three fields: a pointer to the first element, a length, and a capacity. The actual data lives separately in a backing array.
| Property | What it means | How to read it |
|---|---|---|
| length | The number of elements you can currently access via indexing. s[0] through s[len(s)-1]. |
len(s) |
| capacity | The total number of slots in the backing array from the slice's start position. Extra slots exist so append doesn't always need to allocate. |
cap(s) |
When you write s[1:4], Go does not copy the data. It creates a new slice header with a different pointer (into the same backing array) and different len/cap values.
b := a[1:4], there are two slice headers but only one backing array. The orange cells are visible through both windows.a = [10, 20, 30, 40, 50]
b = a[1:4] # new list: [20, 30, 40]
b[0] = 99
print(a) # [10, 20, 30, 40, 50]
# a is unchanged
a := []int{10, 20, 30, 40, 50}
b := a[1:4] // [20 30 40] - shared!
b[0] = 99
fmt.Println(a) // [10 99 30 40 50]
// a[1] changed!
This is the most famous Go trap. It bites almost every Python developer the first time, because Python's slicing always copies and this one doesn't.
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3] -- or so you think
b[0] = 99 // "I'm just modifying b..."
fmt.Println(a) // [1 99 3 4 5] <-- surprise!
fmt.Println(b) // [99 3]
b is writing to shared memory - a sees it immediately.Use the builtin copy() or the append-to-empty-slice idiom:
a := []int{1, 2, 3, 4, 5}
// Option 1: copy() - explicit and readable
b := make([]int, 2)
copy(b, a[1:3])
// Option 2: append to empty slice - idiomatic
b = append([]int{}, a[1:3]...)
// Now b is independent
b[0] = 99
fmt.Println(a) // [1 2 3 4 5] -- safe!
fmt.Println(b) // [99 3]
Here's where it gets subtle. If you append to a sub-slice and the sub-slice's capacity is exceeded, Go allocates a new backing array. After that point, the two slices are truly separate. But before that, any appends that fit in the existing capacity will still modify the shared backing array and may overwrite data that the original slice can see.
a := make([]int, 3, 6) // len=3, cap=6: [0, 0, 0, _, _, _]
a[0], a[1], a[2] = 1, 2, 3
b := a[1:2] // b = [2], len=1, cap=5 (shares backing array!)
// This append FITS within b's capacity (cap=5, len=1 -> len=2)
// So it writes into a's backing array at position [2]
b = append(b, 99)
fmt.Println(a) // [1 2 99] -- a[2] was overwritten!
fmt.Println(b) // [2 99]
// Keep appending past capacity (cap=5) -> new backing array
b = append(b, 10, 20, 30, 40, 50)
b[0] = 777
fmt.Println(a) // [1 2 99] -- a is now unaffected (b moved)
copy(). The moment you want independence, make a copy explicitly - do not rely on capacity overflow to save you.
copy() does.
append is a builtin function that returns a new slice. Notice that word returns - it does not modify the slice in place. This trips up every Go newcomer at least once.
s := []int{1, 2, 3}
// WRONG: the return value is discarded
append(s, 4)
fmt.Println(s) // [1 2 3] - nothing happened!
// CORRECT: always assign the result back
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]
// Append multiple values at once
s = append(s, 5, 6, 7)
// Append a whole slice (note the ... spread)
extra := []int{8, 9}
s = append(s, extra...)
s = append(s, x)Because append returns the (possibly new) slice header. If a reallocation happened, the pointer inside the header changed. If you discard the return value, you might be holding a stale header pointing at old memory.
// Pre-allocate when you know the approximate size
// This avoids repeated reallocation as you fill it
results := make([]string, 0, 100)
for i := 0; i < 100; i++ {
results = append(results, computeItem(i))
}
// Spread operator: append all items from another slice
a := []int{1, 2, 3}
b := []int{4, 5, 6}
c := append(a, b...) // c = [1 2 3 4 5 6]
Go maps are Python dicts with typed keys and typed values. The syntax is a bit different, and there are two important gotchas you won't find in Python.
// Create a map
m := map[string]int{
"alice": 30,
"bob": 25,
}
// Or with make (nil-safe, ready to write)
scores := make(map[string]int)
// Set / update
m["carol"] = 28
// Read
age := m["alice"] // 30
missing := m["nobody"] // 0 -- zero value, no error!
// Two-value form: check existence
v, ok := m["bob"]
if !ok {
fmt.Println("key not found")
}
// Delete
delete(m, "bob")
// Iterate (order is randomized!)
for k, v := range m {
fmt.Printf("%s is %d\n", k, v)
}
In Python, d["missing"] raises KeyError. In Go, it returns the zero value silently. To tell if a key was actually set, you must use the two-value form:
counts := map[string]int{"a": 0}
// Both "a" (set to 0) and "b" (never set) return 0
fmt.Println(counts["a"]) // 0
fmt.Println(counts["b"]) // 0 -- misleading!
// The ok form tells you the truth
_, okA := counts["a"] // okA = true
_, okB := counts["b"] // okB = false
d = {"a": 1}
print(d["a"]) # 1
print(d["x"]) # KeyError!
print(d.get("x")) # None
del d["a"]
for k, v in d.items():
print(k, v)
m := map[string]int{"a": 1}
fmt.Println(m["a"]) // 1
fmt.Println(m["x"]) // 0 (no error!)
v, ok := m["x"] // v=0, ok=false
delete(m, "a")
for k, v := range m {
fmt.Println(k, v)
}
var m map[string]int // nil map
fmt.Println(m["x"]) // 0 - safe
m["x"] = 1 // panic: assignment to entry in nil map
Always initialize maps with a literal or make() before writing.
Go deliberately randomizes map iteration order to prevent code from accidentally relying on it. If you need sorted output, collect the keys, sort them, then iterate:
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { fmt.Println(k, m[k]) }
range is Go's unified iteration syntax. It works on slices, maps, strings, and channels. A quick tour of the patterns you'll use in every program:
nums := []int{10, 20, 30}
// Index and value
for i, v := range nums {
fmt.Printf("[%d] = %d\n", i, v)
}
// Index only
for i := range nums {
nums[i] *= 2 // modifying through index works
}
// Value only (discard index)
total := 0
for _, v := range nums {
total += v
}
// Map iteration
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Println(k, v) // order not guaranteed
}
// String iteration: yields runes (Unicode code points), not bytes
for i, r := range "Hello" {
fmt.Printf("%d: %c\n", i, r)
}
When you write for i, v := range slice, v is a copy of the element. Modifying v does not modify the original slice. This catches Python developers who expect the equivalent of iterating over a list of mutable objects.
type Counter struct{ N int }
counters := []Counter{{1}, {2}, {3}}
// WRONG: v is a copy
for _, v := range counters {
v.N++ // modifies the copy, not the slice element
}
fmt.Println(counters) // [{1} {2} {3}] - unchanged!
// CORRECT: use the index to modify in place
for i := range counters {
counters[i].N++
}
fmt.Println(counters) // [{2} {3} {4}]
range over a string iterates runes (Unicode code points), not bytes. The index is the byte offset of the rune, not a sequential integer. This matters for multi-byte characters. If you need raw bytes, iterate over []byte(s) instead.
r.URL.Query() returns a map[string][]string - every key maps to a slice of values (because a query param can appear multiple times). Here's how you work with it:
func searchHandler(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query() // map[string][]string
// Single value - use Get() for convenience
q := params.Get("q") // "" if missing
// Multi-value: ?tag=go&tag=cloud
tags := params["tag"] // []string{"go", "cloud"}
// Build response lines with append
lines := make([]string, 0, len(tags))
for _, tag := range tags {
lines = append(lines, "tag:"+tag)
}
fmt.Fprintf(w, "query=%s tags=%v\n", q, lines)
}
A common CLI pattern: collect output lines into a slice as you process, then write them all at once. Clean separation between logic and output:
func runStatusCmd(services []string) {
output := make([]string, 0, len(services))
for _, svc := range services {
status, err := checkService(svc)
if err != nil {
output = append(output, svc+": ERROR")
continue
}
output = append(output, svc+": "+status)
}
for _, line := range output {
fmt.Println(line)
}
}
The Kubernetes client returns []corev1.Pod. You often need to filter down to a sub-set. The idiomatic Go way - no lambda, no list comprehension, just a loop and append:
func getRunningPods(pods []corev1.Pod) []corev1.Pod {
running := make([]corev1.Pod, 0)
for _, pod := range pods {
if pod.Status.Phase == corev1.PodRunning {
running = append(running, pod)
}
}
return running
}
// Usage
allPods, _ := clientset.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
runningPods := getRunningPods(allPods.Items)
fmt.Printf("%d of %d pods are running\n",
len(runningPods), len(allPods.Items))
Note: pod is a copy on each iteration, which is fine here since we're only reading it and appending to a different slice.
b := a[1:3] does not copy. b[0] = 99 will change a[1]. Use copy() or append([]T{}, a[1:3]...) when you need independence.
append may or may not allocate - always assign the result back.
Writing append(s, x) without assigning the result is almost always a bug. The result might be a new slice with a new backing array. Always: s = append(s, x).
var a []int // nil slice: a == nil is true
b := []int{} // empty slice: b == nil is false
fmt.Println(len(a), len(b)) // 0 0 - same length
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
Both are safe to append to and range over. The nil check matters when you return a slice and callers check if s == nil to mean "nothing found." Prefer returning nil (not []T{}) for "nothing."
Go has randomized map iteration since version 1 (on purpose, to prevent code accidentally depending on hash ordering). If you need deterministic output, sort the keys first.
var m map[string]int followed by m["x"] returns 0 safely. But m["x"] = 1 panics at runtime. Always use a literal or make(map[K]V) before writing.
v is a copy each iteration - modifying it has no effect on the original slice.
If you need to modify elements, use the index form: for i := range s { s[i].Field = ... }. This becomes especially relevant with slices of structs (more in Lesson 6).
| Term | Meaning |
|---|---|
array | A fixed-size sequence of elements with the size encoded in the type: [5]int. Rarely used directly; arrays are the foundation slices are built on. |
slice | A dynamic-length view of a backing array, described by a three-field header: pointer, length, capacity. The workhorse collection in Go. |
| slice header | The 24-byte struct (on 64-bit) that every slice variable actually stores: a pointer to the backing array, length, and capacity. |
| backing array | The contiguous block of memory that one or more slice headers point into. Multiple slices can share the same backing array. |
length (len) | The number of elements currently accessible through a slice. Returned by len(s). |
capacity (cap) | The total number of slots in the backing array from the slice's start position. Returned by cap(s). Determines when append must reallocate. |
append | Builtin that returns a new slice with extra elements added. May return the same backing array (if capacity allows) or a new one. Always assign the result: s = append(s, x). |
make | Builtin to allocate and initialize slices, maps, and channels. make([]int, 3, 6) creates a slice with len=3 and cap=6. |
copy | Builtin that copies elements from one slice to another. Returns the number of elements copied. Creates an independent backing array. |
map | An unordered collection of key-value pairs with typed keys and values. Equivalent to Python dict, but with randomized iteration order. |
| key / value | The components of a map entry. In map[string]int, the key type is string and the value type is int. |
range | Keyword for iterating over slices (gives index and value), maps (gives key and value), strings (gives byte offset and rune), and channels. |
| zero value (recap) | The default value for any type: 0 for numeric types, "" for strings, nil for pointers/slices/maps. Missing map keys return the zero value. |
| nil slice / nil map | A slice or map variable declared but not initialized. Nil slices are safe to range and append to. Nil maps are safe to read but panic on write. |
Backstory and pitfalls that make this chapter stick.
[5]int. Length is part of the type. Rarely used directly.[]int. Has length (len) and capacity (cap).map[K]V. Iteration order is intentionally randomized.make([]int, 5) creates a length-5 slice. make([]int, 5, 10) has cap=10.new(int) returns *int. Used less than make.s = append(s, x).Will this compile? If it compiles, what happens at runtime?
package main
import "fmt"
func main() {
var counts map[string]int
counts["hello"] = 1
fmt.Println(counts)
}Compiles fine. Panics at runtime: assignment to entry in nil map.
The nil map can be READ (returns zero values, len is 0) but cannot be WRITTEN. You must initialize:
counts := make(map[string]int)
counts["hello"] = 1
// or:
counts := map[string]int{"hello": 1}This is one of Go's most common runtime panics in new code. Static checkers like govet catch some cases but not all.
A fintech audit pipeline aliased a slice without realizing it:
recent := allTransactions[:100]
recent = anonymize(recent) // mutates entries IN-PLACE
saveAudit(allTransactions) // expected ORIGINAL; got anonymizedThe bug: recent := allTransactions[:100] creates a slice that shares the underlying array with allTransactions. Mutating recent mutated allTransactions too. The audit log was saved with anonymized PII fields. SOX compliance violation. The team spent 3 weeks doing forensic recovery and got fined $750K.
Lesson: Slices are views, not copies. To copy defensively: dst := slices.Clone(src) (Go 1.21+), or dst := make([]T, len(src)); copy(dst, src), or dst := append([]T(nil), src...). Always copy before mutating a slice that came from somewhere else.
Don't peek. Think first, then click to reveal each answer.
1. len(s) on a nil slice returns:
0-1nilappend, and check len. You just can't index into them.
2. What is cap() of a slice?
len()cap is how much room is in the underlying array starting from this slice's view. append uses cap to decide whether to grow.
3. append(s, x) always returns a new slice header. The original slice s...
cap > len, append writes in-place and returns a slice header with len+1 pointing to the same array (so the caller's view is unchanged but the array IS mutated). If cap == len, append allocates a new larger array. Idiomatic: always assign result: s = append(s, x).
4. Map literal iteration order is...
keys := slices.Sorted(maps.Keys(m)) (Go 1.23+).
5. To delete a key from a map:
m["key"] = nildelete(m, "key")m["key"] = ""m = remove(m, "key")delete is a built-in. Safe to call on missing keys (no-op). Cannot be called on a nil map.