Free Handbook · Every example compiled & verified

Arrays, Slices & Maps

Fixed-size arrays, growable slices that share a backing array, and maps with comma-ok lookups — plus the panics and aliasing bugs each one causes.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 04 · what you'll be able to do

  • Explain why arrays are copied on assignment and why you will almost always use a slice instead
  • Predict len, cap and the effect of append, and spot when two slices share one backing array
  • Use make, copy and the three-index slice to control sharing, and tell a nil slice from an empty one
  • Create, read (with comma-ok), update and delete map entries, and print them in a stable sorted order
  • Recognise and fix the nil-map write, index-out-of-range and unused-append errors
01

Arrays are values

An array has a fixed length that is part of its type: [3]int and [4]int are different types and cannot be assigned to each other. A new array is filled with zero values. Writing [...]int{2, 3, 5} lets the compiler count the elements for you.

The big surprise for people coming from other languages: an array is a value, not a reference. Assigning it to another variable or passing it to a function copies every element. Arrays of comparable elements can also be compared with ==.

gomain.go
package main

import "fmt"

func setFirst(a [3]int) {
	a[0] = 100 // changes the function's copy only
}

func main() {
	var scores [3]int // [0 0 0]
	scores[1] = 7
	fmt.Println(scores, len(scores))

	primes := [...]int{2, 3, 5, 7} // the compiler counts: [4]int
	fmt.Printf("%v %T\n", primes, primes)

	backup := scores // copies all 3 ints
	backup[0] = 1
	fmt.Println(scores, backup)

	setFirst(scores)
	fmt.Println(scores) // unchanged

	fmt.Println(scores == [3]int{0, 7, 0})
}
Outputcompiled & run with real Go
[0 7 0] 3
[2 3 5 7] [4]int
[0 7 0] [1 7 0]
[0 7 0]
true
Your turn

Change setFirst to take a pointer, func setFirst(a *[3]int), and call it with setFirst(&scores). What prints now?

When arrays are actually used
Real Go code rarely uses bare arrays. You see them where the size is truly fixed: a SHA-256 hash is a [32]byte, an RGB colour a [3]uint8, and an array makes a handy map key because it is comparable. Everywhere else, use a slice.
Error you will hit

invalid argument: index 5 out of bounds [0:3]

go
package main

import "fmt"

func main() {
	var scores [3]int
	scores[5] = 10
	fmt.Println(scores)
}
# command-line-arguments
./main.go:7:9: invalid argument: index 5 out of bounds [0:3]
Why the compiler said that

The length of an array is known at compile time, so a constant index outside 0 to len-1 is caught before the program ever runs. The [0:3] is the valid range, with 3 excluded.

The fix

Use an index inside the array, or use a slice and append if the collection needs to grow.

go
package main

import "fmt"

func main() {
	var scores [3]int
	scores[2] = 10
	fmt.Println(scores)
}
02

Slices: len, cap and append

A slice is a window onto an array. Its type has no length — []int — and it is a small three-field value called the slice header: a pointer to the first element, a length (how many elements you can read, len(s)) and a capacity (how many fit before the array runs out, cap(s)).

append(s, x) returns a slice with x added. If there is spare capacity, it writes into the existing array. If not, it allocates a bigger array (roughly double for small slices), copies the old elements across and returns a header pointing at the new array. That is why you must always write s = append(s, x) — the result may be a different array.

gomain.go
package main

import "fmt"

func main() {
	nums := []int{10, 20, 30} // slice literal: no length in the brackets
	nums = append(nums, 40)
	fmt.Println(nums, len(nums), cap(nums))

	var s []int // nil slice: len 0, cap 0
	for i := range 5 {
		s = append(s, i)
		fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
	}

	s = append(s, 100, 200) // several values at once
	extra := []int{7, 8}
	s = append(s, extra...) // another slice, spread
	fmt.Println(s)
}
Outputcompiled & run with real Go
[10 20 30 40] 4 6
len=1 cap=1 [0]
len=2 cap=2 [0 1]
len=3 cap=4 [0 1 2]
len=4 cap=4 [0 1 2 3]
len=5 cap=8 [0 1 2 3 4]
[0 1 2 3 4 100 200 7 8]

The exact capacities are a runtime implementation detail and can change between Go versions. Never write code that depends on them — only on len.

Your turn

Start with s := make([]int, 0, 5) instead of var s []int. How many times does the capacity change now?

Error you will hit

append(nums, 4) (value of type []int) is not used

go
package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}
	append(nums, 4)
	fmt.Println(nums)
}
# command-line-arguments
./main.go:7:2: append(nums, 4) (value of type []int) is not used
Why the compiler said that

append does not change the slice header you pass in — it cannot, because the header was passed by value. It returns a new header. Throwing that result away is always a bug, so the compiler rejects it.

The fix

Assign the result back.

go
package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}
	nums = append(nums, 4)
	fmt.Println(nums)
}
Error you will hit

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

go
package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}
	for i := 0; i <= len(nums); i++ {
		fmt.Println(nums[i])
	}
}
panic: runtime error: index out of range [3] with length 3

goroutine 1 [running]:
main.main()
	./main.go:8 +0x9c
exit status 2
Why the compiler said that

Valid indexes are 0 to len-1. The <= makes the loop try index 3 on a 3-element slice. A slice's length is only known at run time, so Go checks every index while the program runs and panics instead of reading someone else's memory. The three valid indexes print 1, 2 and 3 before the panic stops the program.

The fix

Use <, or better, for i, n := range nums, which can never go out of range.

go
package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}
	for _, n := range nums {
		fmt.Println(n)
	}
}
03

Slicing shares the backing array

s[low:high] makes a new slice header over elements low up to (not including) high. It does not copy anything: the new slice points into the same array. Its length is high-low, and its capacity runs to the end of the original array. So a write through one slice is visible through the other — and an append on the smaller slice can quietly overwrite the bigger one.

gomain.go
package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4, 5}
	window := base[1:3] // [2 3], but cap runs to the end of base
	fmt.Println(window, len(window), cap(window))

	window[0] = 99 // writes into base's array
	fmt.Println(base)

	window = append(window, 42) // spare capacity: overwrites base[3]!
	fmt.Println(base)

	safe := base[1:3:3]    // three-index slice: cap is limited to 3-1 = 2
	safe = append(safe, 7) // no room, so append copies to a new array
	fmt.Println(base, safe)
}
Outputcompiled & run with real Go
[2 3] 2 4
[1 99 3 4 5]
[1 99 3 42 5]
[1 99 3 42 5] [99 3 7]
Your turn

Change window := base[1:3] to window := base[1:3:3]. Which lines of output change, and why?

VisualizeTwo slices, one arrayStep 1 / 6
base := []int{1, 2, 3, 4, 5}
window := base[1:3]
window[0] = 99
window = append(window, 42)
safe := base[1:3:3]
safe = append(safe, 7)
Line 1

A 5-element array is allocated; base points at element 0 with len 5, cap 5.

Variables now
array[1 2 3 4 5]
baselen 5 cap 5 → [1 2 3 4 5]
All 6 steps as a table
StepLineWhat happenedVariables now
11A 5-element array is allocated; base points at element 0 with len 5, cap 5.array = [1 2 3 4 5] base = len 5 cap 5 → [1 2 3 4 5]
22window points at element 1 of the same array. len = 3-1 = 2; cap = 5-1 = 4.window = len 2 cap 4 → [2 3]
33window[0] is the array's element 1. Both slices see the change.array = [1 99 3 4 5] base = len 5 cap 5 → [1 99 3 4 5] window = len 2 cap 4 → [99 3]
44len 2 < cap 4, so append writes 42 into the next array slot — which is base[3]. No new array.array = [1 99 3 42 5] base = len 5 cap 5 → [1 99 3 42 5] window = len 3 cap 4 → [99 3 42]
55The third index caps capacity at 3-1 = 2, so safe is full.safe = len 2 cap 2 → [99 3]
66No spare capacity: append allocates a new array, copies 99 and 3, adds 7. base is untouched.array = [1 99 3 42 5] safe = len 3 cap 4 → [99 3 7] (new array)

When you need an independent copy, make one explicitly. copy(dst, src) copies min(len(dst), len(src)) elements and returns how many it copied; slices.Clone(s) does the allocate-and-copy in one call.

gomain.go
package main

import (
	"fmt"
	"slices"
)

func main() {
	base := []int{1, 2, 3}

	dst := make([]int, len(base))
	n := copy(dst, base)
	dst[0] = -1
	fmt.Println(n, base, dst)

	short := make([]int, 2)
	fmt.Println(copy(short, base), short) // copies only 2

	clone := slices.Clone(base)
	clone[2] = 30
	fmt.Println(base, clone)
}
Outputcompiled & run with real Go
3 [1 2 3] [-1 2 3]
2 [1 2]
[1 2 3] [1 2 30]
Your turn

A function receives items []string and must return a sorted version without changing the caller's slice. Write it with slices.Clone and slices.Sort.

The bug this causes in real code
A function returns data[:n] from a big buffer, the caller appends to it, and a different part of the program sees its data change. Or a small sub-slice of a 100 MB file keeps the whole 100 MB array alive because it still points into it. Copy when a slice escapes to code you do not control.
04

make, nil vs empty, and 2-D slices

make([]T, len, cap) creates a slice of len zero values with room for cap. Use it when you know the size in advance so append never has to reallocate. A classic mistake is make([]int, 3) followed by append: the three zeros are already elements, and the new values go after them.

A slice declared with var s []int is nil. A nil slice has len 0 and cap 0, range over it does nothing and append works on it — so in most code nil and empty ([]int{}) behave the same. The difference shows up when you compare with nil and when you encode to JSON.

gomain.go
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	wrong := make([]int, 3)
	wrong = append(wrong, 7)
	fmt.Println(wrong) // the 7 lands after three zeros

	right := make([]int, 0, 3) // len 0, room for 3
	right = append(right, 7)
	fmt.Println(right, len(right), cap(right))

	var nilSlice []string
	empty := []string{}
	fmt.Println(nilSlice == nil, empty == nil, len(nilSlice), len(empty))

	a, _ := json.Marshal(nilSlice)
	b, _ := json.Marshal(empty)
	fmt.Println(string(a), string(b))
}
Outputcompiled & run with real Go
[0 0 0 7]
[7] 1 3
true false 0 0
null []
Your turn

An API client expects "tags": [], never null. Where in your code would you make sure the slice is non-nil?

A 2-D slice is a slice of slices, [][]T. Allocate the outer slice, then each row — rows are independent slices and can even have different lengths. The slices package (Go 1.21+) covers the everyday operations: Contains, Index, Sort, Reverse, Equal, Max.

gomain.go
package main

import (
	"fmt"
	"slices"
)

func main() {
	rows, cols := 3, 4
	grid := make([][]rune, rows)
	for r := range grid {
		grid[r] = make([]rune, cols)
		for c := range grid[r] {
			grid[r][c] = '.'
		}
	}
	grid[1][2] = '#'
	for _, row := range grid {
		fmt.Println(string(row))
	}

	nums := []int{5, 2, 8, 2}
	fmt.Println(slices.Contains(nums, 8), slices.Index(nums, 2), slices.Max(nums))
	slices.Sort(nums)
	fmt.Println(nums, slices.Equal(nums, []int{2, 2, 5, 8}))
}
Outputcompiled & run with real Go
....
..#.
....
true 1 8
[2 2 5 8] true
Your turn

Build a triangle: row i has i+1 elements. Print len(row) for each row.

Error you will hit

invalid operation: a == b (slice can only be compared to nil)

go
package main

import "fmt"

func main() {
	a := []int{1, 2}
	b := []int{1, 2}
	fmt.Println(a == b)
}
# command-line-arguments
./main.go:8:14: invalid operation: a == b (slice can only be compared to nil)
Why the compiler said that

Two slices could share an array, overlap, or have different capacities, so Go refuses to guess what "equal" should mean. The only comparison allowed on a slice is against nil. The same rule is why a slice cannot be a map key.

The fix

Compare element by element with slices.Equal.

go
package main

import (
	"fmt"
	"slices"
)

func main() {
	a := []int{1, 2}
	b := []int{1, 2}
	fmt.Println(slices.Equal(a, b))
}
05

Maps: create, look up, delete

A map stores key/value pairs with fast lookup: map[string]int maps strings to ints. Create one with a literal or make(map[K]V). Keys must be comparable (strings, numbers, booleans, arrays, structs of those — not slices, maps or functions).

  • Reading a missing key returns the value type's zero value, not an error. ages["nobody"] is 0.
  • Comma-ok tells a missing key from a stored zero: age, ok := ages["bo"] — ok is false when the key is absent.
  • delete(m, k) removes a key; deleting a missing key is a no-op.
  • Iteration order is random — deliberately, and different on every run. Sort the keys when output order matters. (fmt.Println(m) is the exception: it prints maps sorted by key.)
gomain.go
package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	ages := map[string]int{"ana": 31, "bo": 25}
	ages["cy"] = 40 // insert
	ages["bo"]++    // update in place
	fmt.Println(len(ages), ages["bo"])

	fmt.Println(ages["nobody"]) // missing key: zero value
	if _, ok := ages["nobody"]; !ok {
		fmt.Println("nobody is not in the map")
	}

	delete(ages, "ana")
	delete(ages, "ghost") // no-op, no error

	for _, name := range slices.Sorted(maps.Keys(ages)) {
		fmt.Println(name, ages[name])
	}
	fmt.Println(ages) // fmt sorts map keys when printing
}
Outputcompiled & run with real Go
3 26
0
nobody is not in the map
bo 26
cy 40
map[bo:26 cy:40]
Your turn

Store "dee": 0 in the map. Show that ages["dee"] and ages["nobody"] look the same, but comma-ok tells them apart.

Error you will hit

panic: assignment to entry in nil map

go
package main

import "fmt"

func main() {
	var counts map[string]int
	fmt.Println(counts["go"]) // reading a nil map is fine
	counts["go"] = 1
}
panic: assignment to entry in nil map

goroutine 1 [running]:
main.main()
	./main.go:8 +0x88
exit status 2
Why the compiler said that

var counts map[string]int declares a nil map — there is no hash table behind it yet. Reading returns the zero value (the first Println prints 0), but writing needs somewhere to store the entry, so it panics. This often hides in a struct field that nobody initialised.

The fix

Create the map with make or a literal before writing to it.

go
package main

import "fmt"

func main() {
	counts := make(map[string]int)
	counts["go"] = 1
	fmt.Println(counts)
}
06

Map patterns: counting, grouping and sets

Three shapes cover most map code you will write. A counter (map[string]int) relies on missing keys reading as 0, so counts[w]++ just works. A grouping (map[string][]string) relies on a missing key reading as a nil slice, and append accepting nil. A set is a map whose value you ignore, usually map[string]struct{} (the empty struct takes no memory) or map[string]bool.

gomain.go
package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

func main() {
	text := "go is fun and go is fast and simple"
	words := strings.Fields(text)

	counts := map[string]int{}
	for _, w := range words {
		counts[w]++ // missing key reads as 0
	}

	byLetter := map[byte][]string{}
	seen := map[string]struct{}{}
	for _, w := range words {
		if _, dup := seen[w]; dup {
			continue
		}
		seen[w] = struct{}{}
		byLetter[w[0]] = append(byLetter[w[0]], w) // nil slice + append is fine
	}

	for _, w := range slices.Sorted(maps.Keys(counts)) {
		fmt.Printf("%s=%d ", w, counts[w])
	}
	fmt.Println()
	for _, letter := range slices.Sorted(maps.Keys(byLetter)) {
		fmt.Printf("%c: %v\n", letter, byLetter[letter])
	}
	fmt.Println(len(seen), "unique words")
}
Outputcompiled & run with real Go
and=2 fast=1 fun=1 go=2 is=2 simple=1 
a: [and]
f: [fun fast]
g: [go]
i: [is]
s: [simple]
6 unique words
Your turn

Print the words ordered by count, highest first, breaking ties alphabetically. (You will sort a slice of keys with a custom comparison — slices.SortFunc, covered in Module 06.)

Maps are not safe for concurrent writes
Two goroutines writing to the same map at once crash the program with fatal error: concurrent map writes — it cannot be recovered. Guard shared maps with a sync.Mutex (Module 08) or keep each map owned by one goroutine.
Maps are references too
Like a slice, a map value is a small handle to shared data. Passing a map to a function and adding keys inside is visible to the caller — no pointer needed. To copy a map, use maps.Clone(m).
07

Strings, bytes and runes

A Go string is an immutable, read-only slice of bytes, usually UTF-8 text. len(s) counts bytes, and s[i] gives a byte (uint8). A rune is an int32 holding one Unicode code point. English letters take one byte, but é takes two and 世 takes three — which is why range over a string (Module 02) jumped indexes.

gomain.go
package main

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

func main() {
	s := "héllo, 世界"
	fmt.Println(len(s), utf8.RuneCountInString(s)) // bytes vs characters

	fmt.Println(s[0], string(s[0])) // a byte: 104 is 'h'

	runes := []rune(s) // decode into code points
	fmt.Println(len(runes), string(runes[1]), string(runes[7:]))

	b := []byte("gopher") // a mutable copy of the bytes
	b[0] = 'G'
	fmt.Println(string(b))

	var sb strings.Builder // efficient string building
	for i := range 3 {
		fmt.Fprintf(&sb, "[%d]", i)
	}
	fmt.Println(sb.String())
}
Outputcompiled & run with real Go
14 9
104 h
9 é 世界
Gopher
[0][1][2]
Your turn

Write func reverse(s string) string that works on "héllo". Reverse a []rune, not the bytes — reversing bytes would break the two-byte é.

Error you will hit

cannot assign to s[0] (neither addressable nor a map index expression)

go
package main

import "fmt"

func main() {
	s := "gopher"
	s[0] = 'G'
	fmt.Println(s)
}
# command-line-arguments
./main.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)
Why the compiler said that

Strings are immutable: many strings can share the same bytes, so changing one in place would change the others. Indexing a string gives you a copy of a byte, not a slot you can assign to.

The fix

Build a new string — convert to []byte or []rune, change it, convert back — or use strings functions.

go
package main

import (
	"fmt"
	"strings"
)

func main() {
	s := "gopher"
	s = strings.ToUpper(s[:1]) + s[1:]
	fmt.Println(s)
}
Build strings with strings.Builder, not +=
s += piece in a loop copies the whole string every time, so building a 10,000-piece string does millions of byte copies. strings.Builder appends into a growing buffer and turns it into a string once. For joining an existing slice, strings.Join(parts, ", ") is simpler still.
Array
A fixed-length sequence whose length is part of its type ([3]int). Assigning or passing it copies every element.
Slice
A growable view onto an array: a header of pointer, length and capacity. Type []T.
Capacity
cap(s): how many elements fit in the backing array from the slice's start before append must allocate a new one.
Backing array
The array a slice points into. Slices made by slicing the same slice share it, so writes through one are visible through the others.
Three-index slice
s[low:high:max] — a slice whose capacity is limited to max-low, so a later append must copy instead of overwriting.
nil slice
A slice with no backing array (var s []int). len 0, safe to range over and append to; encodes to JSON as null.
Comma-ok
The two-value form v, ok := m[k] that reports whether the key was present.
rune
An alias for int32 holding one Unicode code point. range over a string yields runes.
Quick check

a := []int{1, 2, 3, 4}, b := a[:2], b = append(b, 9). What is a now?

Quick check

What happens when you run var m map[string]int; m["x"] = 1?

Frequently asked questions

What is the difference between an array and a slice in Go?
An array has a fixed length that is part of its type and is copied when assigned or passed. A slice is a small header (pointer, length, capacity) over an array; it can grow with append, and copies of a slice share the same elements.
Why does Go map iteration order change every run?
The runtime randomises it on purpose so programs cannot depend on an order the map never promised. When order matters, collect the keys, sort them (slices.Sorted(maps.Keys(m))) and iterate over the sorted keys.
Should I return a nil slice or an empty slice?
Inside Go they behave the same for len, range and append, so nil is the usual choice. Return an empty slice when the value is encoded to JSON and the consumer expects [] rather than null.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.