New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jun 19, 2026 Edit on GitHub

Collections

So far you've held one value at a time. Real programs deal in many - a list of users, a table of prices, the words in a sentence. One decision trips up newcomers: arrays versus slices. Clear that up first, and everything else falls into place.

Arrays vs slices - the distinction that matters

An array in Go is a fixed-size sequence of values, all the same type. The size is part of the type: [3]int is "exactly three integers" - not two, not four. You can't grow it, which makes arrays rare in everyday Go.

A slice is a flexible-length view onto a sequence of values. It can grow and shrink, and it's what Go programmers use almost all the time - "a list that can change size." Its type has no number: []int is "a list of integers, however many."

📝 Terminology. The empty brackets are the tell. [3]int (number inside) = array, fixed. []int (nothing inside) = slice, flexible. When in doubt, you want the slice.

Here's a slice in action:

package main

import "fmt"

func main() {
	primes := []int{2, 3, 5, 7}
	fmt.Println(primes)
	fmt.Println(primes[0], primes[3])
}
$ go run main.go
[2 3 5 7]
2 7

[]int{2, 3, 5, 7} created a slice of four integers, printed in brackets. primes[0] reads the first element (Go counts from zero), primes[3] the fourth - so we printed 2 then 7. Indexing past the end (say primes[4]) crashes with an out-of-range error, since there's no fifth element.

Growing a slice with append

A slice's whole point is that it can grow. You do that with the built-in append function:

package main

import "fmt"

func main() {
	names := []string{"Ada", "Alan"}
	names = append(names, "Grace")
	fmt.Println(names)
}
$ go run main.go
[Ada Alan Grace]

append(names, "Grace") produced a slice with "Grace" added on the end. The surprise: you assign the result back to names. append doesn't always change the original in place - it may build a bigger slice and hand it back - so the idiom is always names = append(names, ...). Skip the names = and your addition vanishes.

You can append several at once, or even append one slice onto another:

names = append(names, "Linus", "Margaret")

append takes the slice first, then any number of new values, returning the grown slice. Same rule: capture the result.

len and cap - length vs capacity

Two built-in functions tell you about a slice's size:

package main

import "fmt"

func main() {
	s := []int{10, 20, 30}
	fmt.Println(len(s), cap(s))
}
$ go run main.go
3 3

len(s) is the length - how many elements the slice holds right now (3). cap(s) is the capacity - how many it could hold before Go must allocate a bigger block of memory. len is the one you'll use constantly; cap is under-the-hood detail you'll mostly ignore until optimizing. They start equal here, but after appends they can differ as Go grows backing storage in chunks.

Maps - looking things up by key

A slice is great for an ordered list, accessed by position. A map is for accessing things by name: a lookup table storing key → value pairs. map[string]int reads as "a map from string keys to integer values" - names to ages, say. (Other languages call this a dictionary, hash, or associative array; same idea.)

package main

import "fmt"

func main() {
	ages := map[string]int{
		"Ada":  36,
		"Alan": 41,
	}
	fmt.Println(ages["Ada"])
	ages["Grace"] = 28
	fmt.Println(ages)
}
$ go run main.go
36
map[Ada:36 Alan:41 Grace:28]

We created a map with two entries, looked up "Ada" to get 36, then added an entry by assigning to a fresh key (ages["Grace"] = 28). Go printed them in tidy order here (because fmt sorts map keys when printing), but maps have no guaranteed order - ranging over a map visits keys in randomized order, so don't rely on it.

When you look up a key that might not exist, use the two-value form to ask "did it exist?":

age, ok := ages["Nobody"]
fmt.Println(age, ok)
0 false

Reading a missing key doesn't crash - it returns the value type's zero value (0 for an int, from phase 2) plus a boolean, ok, false when the key was absent. The value, ok := m[key] pattern distinguishes "the value is genuinely 0" from "the key wasn't there at all."

⚠️ Gotcha - writing to a nil map panics. A map variable declared but never made is nil, and writing to a nil map crashes your program at runtime:

var m map[string]int   // declared, but nil - never made
m["x"] = 1             // panic!
panic: assignment to entry in nil map

var m map[string]int gives you a nil map - the zero value for maps. You can read from it (zero values come back), but writing panics, since no table is allocated to store into. Fix: create it first with make: m := make(map[string]int) (or a map literal like above). One of the most common first-week Go panics - now you'll recognize it instantly.

📝 Terminology. A panic is Go's term for a runtime crash - the program stops with an error message and a trace, the runtime equivalent of an exception. Handling failure gracefully (the "errors are values" approach) comes in phase 7.

Looping over collections with range

To visit every element of a slice or every pair in a map, Go gives you range:

package main

import "fmt"

func main() {
	names := []string{"Ada", "Alan", "Grace"}
	for i, name := range names {
		fmt.Println(i, name)
	}
}
$ go run main.go
0 Ada
1 Alan
2 Grace

for i, name := range names walks the slice, handing you i, the index (starting at 0), and name, the value at that position - the standard way to loop a slice. (Don't worry about the for keyword yet - Go's single loop is phase 4; here it's just "do this for each element.")

Often you only want the value, not the index. Use the blank identifier _ to throw the index away:

for _, name := range names {
	fmt.Println(name)
}

_ is Go's "I deliberately don't want this" placeholder. Since Go errors on unused variables (phase 2), _ says "discard the index on purpose." Ranging a map works the same way, giving key, value instead of index, value.

The slice-aliasing surprise

Here's the slice gotcha that bites everyone exactly once. A slice is a view onto an underlying block of data. When you slice a slice, both names can point at the same underlying data:

package main

import "fmt"

func main() {
	original := []int{1, 2, 3, 4}
	part := original[0:2]   // a view of the first two elements
	part[0] = 99
	fmt.Println(original)
}
$ go run main.go
[99 2 3 4]

original[0:2] made part a window onto original's first two elements - not a copy. Changing part[0] also changed original[0]; they share the same backing storage. Efficient (no copying) but surprising the first time a slice changes "by itself."

⚠️ Gotcha. When you need an independent copy rather than a shared view, make one explicitly with the built-in copy:

clone := make([]int, len(original))
copy(clone, original)

make([]int, len(original)) created a new slice of the same length, and copy filled it with original's values. Now clone has its own storage - changing it leaves original untouched. Reach for this whenever "I changed one and the other changed too" would be a bug.

Recap

  1. []T is a slice (flexible, what you'll use); [N]T is an array (fixed size, rare).
  2. append grows a slice - always assign the result back: s = append(s, x).
  3. len is how many elements (you'll use it constantly); cap is the under-the-hood capacity.
  4. A map (map[K]V) stores key→value pairs for instant lookup; use value, ok := m[key] to check if a key exists.
  5. Writing to a nil map panics - create it first with make or a literal.
  6. range loops collections, giving index, value for slices and key, value for maps; use _ to discard a part you don't need.
  7. Slices can share underlying data (aliasing) - use copy when you need an independent copy.

Next: making decisions and organizing logic - Go's one loop, if and switch, and the multiple-return-value functions that give Go its distinctive shape.


← Phase 2: Syntax, Values & Types · Guide overview · Phase 4: Control Flow & Functions →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 2 questions

1. Which do you reach for almost always in Go - an array or a slice?

2. Writing to a nil map...