Skip to content

06 - Many Things at Once

What this session is

About an hour. You'll learn the two most-used "collection" types in Go: slices (an ordered list of things, like a shopping list) and maps (a lookup table, like a dictionary where you look up a word to find its meaning). Almost every real Go program uses both, constantly.

Why you need these

Until now, every variable has held one thing. One name. One age. One person. Real programs deal with many things. A list of users. A count of how many times each word appears in a document. The temperatures for each day of the week. You need types built for collections.

Go gives you three: arrays, slices, and maps. Slices and maps are what you'll actually use. Arrays exist, but you'll see them rarely - we'll mention them only enough to recognize them.

Slices: ordered lists

package main

import "fmt"

func main() {
    names := []string{"Alice", "Bob", "Chioma"}
    fmt.Println(names)
    fmt.Println(names[0])      // Alice - slices are 0-indexed
    fmt.Println(names[2])      // Chioma
    fmt.Println(len(names))    // 3
}

Type and run.

What's new:

  • []string{...} - a slice of strings. The [] says "a list of"; string says "of strings".
  • names[0] - read the first element. Indexing starts at 0, not 1. (names[2] is the third element.)
  • len(names) - built-in function that gives the number of elements.

Common mistake: going off the end. names[3] would crash the program (Go calls this a "panic" - we'll meet it later). Always know how many elements you have before indexing.

What a slice actually is (the mental model)

Here's what surprises people: a slice in Go is not a list. It's a tiny three-field struct that describes a list living elsewhere in memory.

The three fields:

slice header:
┌─────────┬─────┬─────┐
│ pointer │ len │ cap │
└────┬────┴─────┴─────┘
backing array:  [ A ][ B ][ C ][ D ][ E ]
  • pointer - where the elements actually live in memory.
  • length - how many elements the slice currently uses (what len(s) returns).
  • capacity - how big the underlying array is from the pointer onward (what cap(s) returns).

When you write s := []int{1, 2, 3}, Go allocates an array of 3 ints somewhere, then makes a slice header pointing at it: {ptr → [1 2 3], len=3, cap=3}.

You can see all three with the built-in cap function:

s := []int{1, 2, 3}
fmt.Println(len(s), cap(s))   // 3 3

The big consequence: when you pass a slice to a function, you copy the header - three small fields. You do not copy the elements. The function and the caller end up with two slice headers pointing at the same underlying array. Writes through one header are visible through the other. We'll come back to this in Going deeper.

Try it: 1. Type the program above and add s = append(s, 4). Print len(s) and cap(s) again. What changed?

  1. Try t := make([]int, 3, 10). What do len(t) and cap(t) show? What does t actually contain before you write to it?

Adding to a slice: append

Slices grow:

names := []string{"Alice", "Bob"}
names = append(names, "Chioma")
fmt.Println(names)   // [Alice Bob Chioma]

append(slice, value) returns a new slice with the value added. You almost always reassign back to the same variable: names = append(names, "Chioma").

Two notes that will save you confusion later:

  1. append returns a value; it doesn't change the slice in place. Forgetting the names = part is a common bug - your append does nothing visible.

  2. You can append several values: append(names, "D", "E", "F").

The growing-array story. When you append, Go does one of two things:

  • There's room (cap > len): write into the existing backing array, return a slice header with len bumped by one. Fast.
  • There's no room (cap == len): allocate a new, bigger backing array, copy the old elements in, write the new element, return a slice header pointing at the new array. The old array is left to the garbage collector.

That's why we always reassign: names = append(names, ...). The header you get back may point at a different array than the one you passed in.

Watch it happen:

s := []int{}
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Printf("len=%2d cap=%2d\n", len(s), cap(s))
}

You'll see capacity jump in chunks (e.g., 1 → 2 → 4 → 8 → 16), not grow by one each time. Go roughly doubles capacity when it reallocates, so a million appends don't take a million allocations - they take about twenty. We'll see the exact growth strategy in Going deeper.

Try it: 1. Run the loop above. Note the capacities you see.

  1. Change 10 to 100. Watch the jumps. Does the doubling slow down at any point?

  2. Add fmt.Println("pointer:", &s[0]) just after s = append(...). When the pointer changes, you know a reallocation just happened.

Looping over a slice: range

You've seen for i := 0; i < len(names); i++ style loops on page 03. For slices, there's a friendlier form:

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

Output:

0 Alice
1 Bob
2 Chioma

range names gives you two values per iteration: the index and the value at that index. If you don't need the index, use _:

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

The underscore _ means "I don't care about this." Go won't compile if you create a variable and don't use it (page 02) - _ is the official way to say "throw this away."

A slice of structs

Combine what you know:

type Person struct {
    Name string
    Age  int
}

people := []Person{
    {Name: "Alice", Age: 30},
    {Name: "Bob", Age: 25},
    {Name: "Chioma", Age: 35},
}

for _, p := range people {
    fmt.Println(p.Name, "is", p.Age)
}

A slice can hold any type - structs you defined, other slices, anything.

Strings are almost slices

A Go string is structurally like a slice header without the capacity: a pointer to bytes plus a length. The pointer-to-bytes points at immutable memory - you can't change a character in place. Everything else works like you'd expect.

s := "hello"
fmt.Println(len(s))    // 5
fmt.Println(s[0])      // 104  -- the byte 'h', not the character!

Two surprises live here.

len(s) is bytes, not characters. A Go string is a sequence of bytes (which may be UTF-8 text). For ASCII text (English, code, numbers), bytes and characters line up - len("hello") is 5. For non-ASCII characters, one character can take multiple bytes:

s := "café"
fmt.Println(len(s))    // 5  -- not 4!  ('é' is two bytes in UTF-8)

s[0] gives you a byte, not a character. That's why fmt.Println(s[0]) printed 104 - the byte value of 'h'. To work with characters (Go calls them runes - Unicode code points stored as int32), use range:

s := "café"
for i, r := range s {
    fmt.Printf("byte %d: rune %c (%U)\n", i, r, r)
}

Output:

byte 0: rune c (U+0063)
byte 1: rune a (U+0061)
byte 2: rune f (U+0066)
byte 3: rune é (U+00E9)

range over a string decodes UTF-8 for you. Each iteration gives the byte offset of the rune (so the index can jump by 1, 2, or 3 depending on the rune's width), plus the rune itself as an int32. This is the only place in Go where the language treats UTF-8 specially.

You can slice a string just like a slice - s[1:3] - but you're slicing bytes, not characters. "café"[3:] returns the last two bytes (which together encode é); "café"[3:4] returns the first half of é and is not valid UTF-8. String slicing only makes sense at known rune boundaries.

The fast way to count actual characters:

import "unicode/utf8"

s := "café"
fmt.Println(len(s))                       // 5  (bytes)
fmt.Println(utf8.RuneCountInString(s))    // 4  (runes/characters)

Try it: 1. Run the range example with s := "日本語" (Japanese for "Japanese language"). Each rune is 3 bytes - predict the byte offsets before you run it.

  1. Print len("日本語") and the count from a for range loop. They'll differ.

  2. Try fmt.Println("café"[3:4]). You'll see a single byte that's not valid on its own. Now try fmt.Println("café"[3:5]) - that's the full é.

Maps: looking things up by key

A map is a collection where each entry has a key (the thing you look up by) and a value (the thing you get back).

package main

import "fmt"

func main() {
    ages := map[string]int{
        "Alice":  30,
        "Bob":    25,
        "Chioma": 35,
    }

    fmt.Println(ages["Alice"])    // 30
    fmt.Println(ages["Bob"])      // 25
    fmt.Println(len(ages))        // 3
}

What's new:

  • map[string]int{...} - a map whose keys are strings and whose values are ints. Read it as "a map FROM string TO int."
  • ages["Alice"] - look up the value for the key "Alice".

Add or update an entry:

ages["Dimeji"] = 40   // adds a new entry
ages["Alice"] = 31    // updates Alice's age

Delete an entry:

delete(ages, "Bob")

What a map actually is (the mental model)

A map in Go is a pointer to a hash table.

That short sentence packs three useful facts:

  1. It's a pointer. When you pass a map to a function, you're passing a small pointer-sized value. The function operates on the same underlying table. Modifications stick - there's no "pass a copy" the way you'd get with a struct.

  2. It's a hash table. Lookups, inserts, and deletes are amortized O(1) - the cost doesn't grow with how much is in the map. Go hashes the key, finds the right slot, and does the operation.

  3. The variable can be nil. Maps must be initialized before you write to them, or your program panics.

var m map[string]int       // nil map
fmt.Println(m["anything"]) // 0     -- reading a nil map is OK
m["new"] = 1               // PANIC: assignment to entry in nil map

Always create a map with make or a literal:

m := make(map[string]int)              // empty, ready to use
m := map[string]int{"a": 1, "b": 2}    // empty + initial entries

Because a map is a pointer, sharing one between functions is cheap and side effects are visible:

func addOne(scores map[string]int, k string) {
    scores[k] = scores[k] + 1
}

func main() {
    scores := map[string]int{"alice": 0}
    addOne(scores, "alice")
    addOne(scores, "alice")
    fmt.Println(scores["alice"])    // 2
}

No need to return the map - the function and caller see the same hash table.

Try it: 1. Type the addOne example. Confirm it prints 2. Now copy the map first: func addOne(scores map[string]int, ...) { scoresCopy := scores; ... }. Does the change still stick? Why? (Hint: scoresCopy is another pointer to the same table.)

  1. Try var m map[string]int; m["x"] = 1. See the panic. Add m = make(map[string]int) before the write to fix it.

  2. Run any program that uses a map with two goroutines writing to it (you'll meet goroutines on page 09). Use go run -race main.go. Notice the race-detector complaint - maps are not safe for concurrent writes.

The "comma ok" pattern: is the key there?

What if you look up a key that isn't in the map?

score := ages["Zara"]
fmt.Println(score)   // 0

You get the zero value for the value type - 0 for int, "" for string, etc. (We met zero values in page 05.) That's sometimes fine, but often you need to know whether the key was there or not. Use this form:

score, ok := ages["Zara"]
if ok {
    fmt.Println("found:", score)
} else {
    fmt.Println("not in the map")
}

The second return value (we named it ok) is a bool: true if the key was present, false if not. This is called the "comma ok" pattern. You'll see it constantly in Go code.

Looping over a map

for key, value := range ages {
    fmt.Println(key, value)
}

Important

the order is not guaranteed. If you run the program twice, the output order may differ. This is on purpose - Go randomizes map iteration order so you don't accidentally rely on an order that the language doesn't promise.

If you need ordered output, sort the keys first:

import "sort"

keys := make([]string, 0, len(ages))
for k := range ages {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, ages[k])
}

That make([]string, 0, len(ages)) creates a slice of strings with length 0 and capacity for len(ages) elements - a small optimization that avoids the slice growing as we append. You'll see make more in real code; for the basics, []string{} works the same.

Arrays (briefly, so you recognize them)

You'll occasionally see code with a number inside the brackets:

var temps [7]float64

That's an array - fixed size (7, here), declared at compile time. Once made, you can't grow it. In practice, you'll use slices instead. The only time arrays matter for beginner-level Go is when you're reading code that uses them - recognize the shape, then mentally translate to "this is just a fixed-size collection."

Going deeper

The basics above are enough to write production Go. The material below is for when you want to know why things behave the way they do - depth that lets you predict surprises instead of being surprised by them. Most of this is drawn directly from the Go team's own writing; links at the end.

How append's growth strategy actually works

You saw above that append roughly doubles capacity when it has to reallocate. That's true for small slices. For large ones, Go grows more conservatively to avoid wasting memory:

  • For slices below roughly 256 elements: capacity is doubled (×2) on each reallocation.

  • For slices past that threshold: each reallocation grows by progressively less, settling around ×1.25.

The exact thresholds change between Go versions and are not part of the language spec - they're a runtime implementation choice. The principle is constant: small slices grow fast (so the amortized cost of building one stays cheap) and large slices grow slow (so you don't waste megabytes when you only needed kilobytes more).

You don't memorize the numbers. The takeaway: appending one element at a time to a million-element slice is amortized O(1), but each individual append is sometimes O(N) when reallocation happens. If you know the final size up front, pre-size with make([]T, 0, N) - you'll get zero reallocations.

Try it:

s := []int{}
prev := 0
for i := 0; i < 10_000; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("grew at len=%d to cap=%d (ratio %.2f)\n",
            len(s), cap(s), float64(cap(s))/float64(prev|1))
        prev = cap(s)
    }
}
Run it. Watch the ratio start near 2.0 and shrink past ~256 elements.

The aliasing gotcha

Because slices share the underlying array, this surprises people:

a := []int{1, 2, 3, 4, 5}
b := a[:3]              // b is {1, 2, 3}, sharing a's array
b[0] = 99
fmt.Println(a)          // [99 2 3 4 5]  -- a changed too!

b is a new slice header, but its pointer is into a's array. Writes through b modify the same memory.

The same effect bites with append:

a := []int{1, 2, 3, 4, 5}
b := a[:3]              // len=3, cap=5  (b inherits a's spare capacity)
b = append(b, 99)       // append wrote into a's memory because there was room
fmt.Println(a)          // [1 2 3 99 5]  -- the "4" got overwritten

If you ever pass a sub-slice to a function that might append, copy it first:

b := append([]int(nil), a[:3]...)   // independent backing array

The Go standard library does this constantly. Recognize it.

Try it: 1. Run the second example. Confirm a is mutated.

  1. Change b := a[:3] to b := a[:3:3] (the three-index slice - explicitly limits b's capacity to 3). Now what happens when you append? Why? (Answer: with cap == len, append has to reallocate; the new array is independent.)

The retained-array gotcha

Slicing keeps the entire backing array alive, even if you only kept a tiny window. This bites in real production code:

import (
    "bytes"
    "io"
)

// Bad: keeps the entire file in memory forever
func firstLine(r io.Reader) []byte {
    all, _ := io.ReadAll(r)         // could be megabytes
    i := bytes.IndexByte(all, '\n')
    if i < 0 {
        return all
    }
    return all[:i]                  // looks like 100 bytes, pins the whole array
}

The returned slice is a 100-byte view, but its pointer still aims at the 10 MB array. The garbage collector cannot reclaim the array while any slice references it. If you keep millions of these "small" return values, you actually hold millions of full file buffers.

The fix: copy the bytes you actually want, so the original array can be freed:

func firstLine(r io.Reader) []byte {
    all, _ := io.ReadAll(r)
    i := bytes.IndexByte(all, '\n')
    if i < 0 {
        return all
    }
    out := make([]byte, i)
    copy(out, all[:i])
    return out                      // independent buffer; `all` can be GC'd
}

This pattern is straight from the Go blog. The standard library uses it everywhere a small slice is returned from a large buffer.

Try it: Write a program that reads a 1 MB file into b := os.ReadFile(...), takes head := b[:10], and stores 10,000 of these head values in a slice. Check runtime.MemStats after. Switch to the copy version and compare. The difference is dramatic.

Strings: conversion costs (sometimes)

Because strings are immutable and []byte is mutable, converting between them requires a copy:

b := []byte("hello")    // allocates 5 bytes, copies "hello" in
s := string(b)          // allocates 5 bytes, copies them back
b[0] = 'H'              // changes b. s still says "hello".

For small strings this is invisible. For large ones in a hot loop, it can dominate your CPU profile.

The compiler optimizes one specific case: using string(b) as a map key.

b := []byte("key")
v := m[string(b)]       // recognized pattern, no allocation

In all other cases, treat string(b) and []byte(s) as real allocations.

Try it: Benchmark with testing.B:

func BenchmarkStringConv(b *testing.B) {
    data := []byte("the quick brown fox jumps over the lazy dog")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = string(data)
    }
}
Run go test -bench=. -benchmem. You'll see one allocation per op for the conversion. Now change the inner line to _ = data. The allocs drop to zero.

Maps in Go 1.24: Swiss tables

In Go 1.24 (released February 2025), the map implementation was rewritten to use a design called Swiss tables, borrowed from Google's C++ Abseil library.

The mental model shifted:

  • Before Go 1.24: a hash table of "buckets" each holding 8 key/value slots, with overflow buckets chained when a bucket fills up.
  • Go 1.24 and later: a hash table of "groups" of 8 slots, each group accompanied by a 64-bit "control word" that lets one CPU instruction check all 8 slots for a key match. When a group is full, the lookup uses quadratic probing to find the next group rather than walking an overflow chain.

The result, per the Go team's microbenchmarks: map operations up to 60% faster, memory usage down up to 70% on dense workloads. Datadog publicly reported saving hundreds of gigabytes of RAM after upgrading.

From your code's perspective, nothing changed - same make, same m[k], same delete, same iteration semantics. But when you read older blog posts that describe "Go map buckets" and "overflow chains," know that's the pre-1.24 model.

Try it: 1. Run go version. If it's go1.24 or later, your maps are Swiss tables.

  1. Build a small benchmark that fills a map[int]int with a million entries, then measures lookup time. If you have access to Go 1.23 and Go 1.24, run on each and compare. You should see Go 1.24 measurably faster.

Map iteration: random order, plus what's defined about modification

You already know iteration order is randomized. There are two more rules worth knowing.

Modifying a map during iteration is defined behavior, with caveats spelled out in the Go spec:

  • Deleted entries are not produced.
  • Updated values are produced (if the entry hasn't been visited yet, you'll see the new value).
  • New entries added during iteration may or may not be produced.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k := range m {
    if k == "a" {
        delete(m, "b")          // b might not be visited (it isn't yet)
        m["d"] = 4              // d may or may not be visited
    }
}

Most hash-table implementations forbid this entirely - Python and Java throw runtime exceptions; the C++ Swiss table refuses it. Go's runtime keeps the iterator anchored to the original table even as the table grows, then re-checks the (possibly new) table before yielding each entry. That's why the pattern is legal.

It's still a code smell. If you find yourself modifying a map during iteration, collect the changes into a slice first, then apply them after the loop. Easier to reason about and immune to the "may or may not" rule.

Try it: Run the example above ten times. Does "d" show up? Sometimes - that's the "may or may not" from the spec, working as designed.

Pre-sizing: when you know the size

If you know roughly how many items you'll have, say so up front:

// Bad: ~9 reallocations as the slice grows from 0 to 1000
s := []int{}
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

// Good: 1 allocation, no growth
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

Same for maps:

m := make(map[string]int, 1000)   // pre-size internal table for ~1000 entries

In hot paths this is a measurable performance win (fewer reallocs, less garbage). In cold paths it's a discipline that telegraphs intent to readers.

Try it: Benchmark both versions:

func BenchmarkAppend(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := []int{}
        for j := 0; j < 1000; j++ { s = append(s, j) }
    }
}
func BenchmarkAppendPresized(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 0, 1000)
        for j := 0; j < 1000; j++ { s = append(s, j) }
    }
}
Run go test -bench=. -benchmem. Compare allocs/op: typically ~9 for the first, exactly 1 for the second.

nil slices behave (mostly) like empty slices

var s []int           // nil slice: ptr=nil, len=0, cap=0
fmt.Println(len(s))   // 0
s = append(s, 1)      // works! append handles nil - allocates a fresh array
for v := range s {    // works on empty too
    fmt.Println(v)
}

A nil slice has no backing array. An empty slice ([]int{}) has a header pointing at a (possibly shared) zero-length array. For most purposes they're interchangeable - len, range, and append all work the same on both.

The one place it bites: JSON encoding. nil slices marshal to null; empty slices marshal to [].

import "encoding/json"

var a []int                       // nil
b := []int{}                      // empty
j1, _ := json.Marshal(a)          // []byte("null")
j2, _ := json.Marshal(b)          // []byte("[]")

If your API consumer expects [] (most JavaScript code does), return []int{}, not nil. The same applies to nil maps and map[K]V{}.

Try it: Write a quick HTTP handler that returns json.NewEncoder(w).Encode(struct{ Items []int }{items}). Call it twice - once with items = nil, once with items = []int{}. curl both. Notice the wire difference: {"Items":null} vs {"Items":[]}. Front-end code that does data.Items.map(...) will crash on the first and succeed on the second.

Where this material came from

The depth in this section is drawn from these authoritative sources. Bookmark them for when you want to go further:

Exercise

In a new file wordcount.go:

Write a program that counts how many times each word appears in a sentence.

  1. Start with this sentence: "the quick brown fox jumps over the lazy dog the end". Hardcode it as a string.

  2. Split it into words. Use:

    import "strings"
    words := strings.Fields(sentence)
    
    strings.Fields splits a string on whitespace and returns a []string.

  3. Build a map[string]int where each key is a word and each value is how many times it appeared.

  4. Print each word and its count, one per line.

Expected output (order may differ):

the 3
quick 1
brown 1
fox 1
jumps 1
over 1
lazy 1
dog 1
end 1

Stretch: print the words sorted alphabetically. (Use the sorted-keys pattern above.)

What you might wonder

"Why is indexing zero-based?" Historical and mathematical reasons that took root in C and spread. Slightly painful at first, then automatic. You'll stop thinking about it in two weeks.

"What's the difference between a slice and an array, really?" A slice is a view into a backing array, with its own length and capacity. The slice can grow (via append); the underlying array doesn't (it gets replaced with a bigger one when needed). For now: use slices, ignore arrays. The full picture lands when you read more Go code.

"Can a map's keys be any type?" Almost. Keys must be types that can be compared with ==. Strings, numbers, booleans, structs of those - yes. Slices, maps, functions - no (they can't be compared). 95% of the time the answer is "string" or "int."

"What if I append to a slice that other code is also looking at?" You'll meet a subtle bug eventually. Don't worry about it now. Rule of thumb: don't hold onto a slice across an append you didn't do yourself.

Done

You can now: - Build ordered lists with slices: create, index, len, append, range. - Use the i, v := range slice and _, v := range slice patterns. - Build lookup tables with maps: create, read, write, delete. - Check whether a key exists with the comma-ok pattern. - Iterate a map, sort keys when order matters. - Recognize an array when you see one (and convert it mentally to "fixed-size slice").

Most of the data shape work in Go is done with slices and maps. You now have the basic vocabulary that every Go program uses.

Next page: how Go handles things going wrong - the "errors as values" pattern.

Next: Errors07-errors.md

Comments