Skip to content
Codeloom
Go

Slices and Maps in Go

A practical guide to Go's two everyday collections — slices and maps. Covers arrays vs slices, make and append, len and cap, the shared-backing-array gotcha, and the comma-ok idiom.

·11 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • How arrays and slices differ, and why you almost always want a slice
  • How append, len, and cap behave as a slice grows
  • The slicing syntax s[i:j] and the shared-backing-array gotcha
  • How to create, read, write, and delete map entries
  • The comma-ok idiom for distinguishing "missing" from "zero"
  • How to iterate maps and why the order is randomised

Prerequisites

Almost every Go program reaches for two collection types: slices for ordered sequences, and maps for keyed lookups. Together they cover the vast majority of “I have a bunch of things” situations. This post covers both, including the one famous gotcha around slices that catches every Go programmer at least once.

Arrays vs slices

Go has two list-like types, and the distinction matters.

An array has a fixed length, baked into the type:

var nums [3]int = [3]int{10, 20, 30}
fmt.Println(nums)        // [10 20 30]
fmt.Println(len(nums))   // 3

An array of length 3 and an array of length 4 are different types. You cannot pass one where the other is expected, and you cannot grow them. Arrays exist in Go mostly as a low-level building block; you’ll use them rarely.

A slice is a dynamically sized view into an underlying array. Its type is []T — no length in the type itself:

nums := []int{10, 20, 30}
fmt.Println(nums)        // [10 20 30]
fmt.Println(len(nums))   // 3

This is what you’ll use 99% of the time. When in doubt, use a slice.

Creating slices

There are several ways to create one.

Literal:

nums := []int{1, 2, 3, 4, 5}
words := []string{"go", "rust", "python"}

make with a length:

buf := make([]int, 5)        // [0 0 0 0 0] — five zero-valued ints

make with a length and capacity:

buf := make([]int, 0, 10)    // length 0, capacity 10

The capacity is the size of the underlying array; the length is how much of it is currently visible. We unpack cap in a moment.

nil slice:

var nums []int               // nil — but usable
fmt.Println(len(nums))       // 0
nums = append(nums, 1)       // works

A nil slice behaves identically to an empty slice for most purposes. You can range it (the loop runs zero times), measure it, and append to it.

append

append adds elements to the end of a slice and returns the (possibly new) slice:

nums := []int{1, 2, 3}
nums = append(nums, 4)
nums = append(nums, 5, 6, 7)
fmt.Println(nums)
// output: [1 2 3 4 5 6 7]

Notice you assign the result back to nums. append may return a slice pointing to a new, larger backing array — so the value you had before may be stale. Always reassign.

You can append a whole slice with ...:

more := []int{8, 9, 10}
nums = append(nums, more...)

len and cap

len(s) is the number of elements currently in the slice. cap(s) is how many elements the underlying array can hold before Go has to grow it:

s := make([]int, 0, 4)
fmt.Println(len(s), cap(s))   // 0 4

s = append(s, 1, 2, 3, 4)
fmt.Println(len(s), cap(s))   // 4 4

s = append(s, 5)
fmt.Println(len(s), cap(s))   // 5 8 (typical) — Go grew the backing array

Each time len exceeds cap, Go allocates a bigger backing array (typically doubling) and copies the elements. This is fast amortised, but if you know how big a slice will get, pre-sizing with make([]int, 0, expected) saves a few allocations.

Slicing syntax

The expression s[i:j] produces a new slice covering elements i (inclusive) to j (exclusive):

nums := []int{10, 20, 30, 40, 50}
fmt.Println(nums[1:4])    // [20 30 40]
fmt.Println(nums[:3])     // [10 20 30]
fmt.Println(nums[2:])     // [30 40 50]
fmt.Println(nums[:])      // [10 20 30 40 50]

Both ends are optional. nums[:] is the whole slice.

You can also reslice with three indices, s[i:j:k], where k controls the capacity of the result. We won’t use the three-index form often in beginner code.

The shared backing array

Here is the gotcha. A slice is a view into an array. Two slices into the same array share memory. Writing through one is visible through the other:

nums := []int{10, 20, 30, 40, 50}
view := nums[1:4]            // [20 30 40]

view[0] = 999
fmt.Println(nums)            // [10 999 30 40 50]
fmt.Println(view)            // [999 30 40]

This is correct, intentional behaviour — slicing is cheap exactly because it doesn’t copy — but it surprises people. The mistakes look like this:

func keepFirstThree(s []int) []int {
    return s[:3]              // result shares memory with caller's slice!
}

If the caller later modifies the returned slice, or if append mutates beyond index 3 of either, both views see the change. When you need an independent copy, use copy:

func keepFirstThree(s []int) []int {
    out := make([]int, 3)
    copy(out, s)
    return out
}

The copy(dst, src) built-in copies up to min(len(dst), len(src)) elements.

The same gotcha bites with append:

a := []int{1, 2, 3, 4, 5}
b := a[:3]                   // [1 2 3], shares backing array

b = append(b, 99)            // overwrites a[3]!
fmt.Println(a)               // [1 2 3 99 5]
fmt.Println(b)               // [1 2 3 99]

The fix is to ensure b has its own backing array, either by copying first or by using the three-index slice form a[:3:3] which limits capacity so append must allocate.

If you remember one rule: slices share memory until they have to grow. That single fact explains every odd slice behaviour you’ll ever debug.

Try this. Write a function removeAt(s []int, i int) []int that returns a new slice with the element at index i removed, without modifying the caller’s slice. Hint: there are two ways — copy into a new slice, or use the trick append(s[:i], s[i+1:]...) (and then think carefully about whether the caller’s slice survives unchanged). Test both versions and observe the difference.

Maps

A map is an unordered set of key/value pairs. The type is map[K]V:

ages := map[string]int{
    "Alice": 30,
    "Bob":   25,
}

Keys must be a type that supports == — strings, numbers, booleans, pointers, structs of comparable fields. Slices, maps, and functions cannot be map keys.

make is the other way to create a map:

ages := make(map[string]int)
ages["Alice"] = 30
ages["Bob"] = 25

A nil map is not usable for writes — assigning to it panics. Always make or use a literal before writing.

Reading and writing

ages["Carol"] = 40           // set
fmt.Println(ages["Alice"])   // 30 — read
delete(ages, "Bob")          // delete
fmt.Println(len(ages))       // 2

Reading a key that doesn’t exist returns the zero value of the value type — not an error, not a panic:

fmt.Println(ages["Dave"])    // 0

This is convenient but also a trap: how do you tell “Dave is missing” from “Dave’s age is 0”? With the comma-ok idiom.

The comma-ok idiom

A map read can return two values: the value and a bool indicating whether the key was present.

age, ok := ages["Dave"]
if !ok {
    fmt.Println("Dave is not in the map")
} else {
    fmt.Printf("Dave is %d\n", age)
}

ok is true if the key existed and false otherwise. If ok is false, age holds the zero value.

You’ll use this pattern constantly. It’s the only way to distinguish “missing” from “set to zero” — and it works for any value type:

permissions := map[string]bool{"alice": true, "bob": false}

if can, ok := permissions["alice"]; ok && can {
    fmt.Println("alice has permission")
}

The same if init; cond pattern from Control Flow keeps the temporary variables out of the surrounding scope.

Iterating maps

range over a map yields key/value pairs:

ages := map[string]int{"Alice": 30, "Bob": 25, "Carol": 40}
for name, age := range ages {
    fmt.Printf("%s is %d\n", name, age)
}

The iteration order is deliberately randomised. Go shuffles it on every run to discourage code that depends on order. If you need ordered output, collect the keys into a slice and sort:

import "sort"

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

for _, k := range keys {
    fmt.Printf("%s is %d\n", k, ages[k])
}

This is the idiomatic recipe for “iterate a map in a stable order.”

Maps of slices, slices of maps

The composition rules are what you’d expect. A map of slices is a clean way to group:

byLetter := map[rune][]string{}
words := []string{"go", "git", "rust", "ruby", "python"}
for _, w := range words {
    first := rune(w[0])
    byLetter[first] = append(byLetter[first], w)
}
fmt.Println(byLetter['g'])    // [go git]
fmt.Println(byLetter['r'])    // [rust ruby]

Reading a missing key gives you a nil slice — and appending to a nil slice works fine. That’s why the line byLetter[first] = append(byLetter[first], w) is correct even the first time it sees a letter.

A worked example

A word-frequency counter that ties everything together:

package main

import (
    "fmt"
    "sort"
    "strings"
)

func wordCounts(text string) map[string]int {
    counts := make(map[string]int)
    for _, w := range strings.Fields(text) {
        w = strings.ToLower(strings.Trim(w, ".,!?;:"))
        if w == "" {
            continue
        }
        counts[w]++
    }
    return counts
}

func topN(counts map[string]int, n int) []string {
    keys := make([]string, 0, len(counts))
    for k := range counts {
        keys = append(keys, k)
    }
    sort.Slice(keys, func(i, j int) bool {
        return counts[keys[i]] > counts[keys[j]]
    })
    if n > len(keys) {
        n = len(keys)
    }
    return keys[:n]
}

func main() {
    text := "Go is great. Go is fast. Go is simple. Simple is good."
    counts := wordCounts(text)
    for _, w := range topN(counts, 3) {
        fmt.Printf("%-8s %d\n", w, counts[w])
    }
}
// output:
// go       3
// is       4
// simple   2

wordCounts builds the map; topN extracts and sorts the keys; main ties them together. The counts[w]++ line is shorthand for counts[w] = counts[w] + 1 and is safe even when w isn’t yet a key — the zero value of int is 0, so 0 + 1 = 1.

Try this. Extend the worked example: add a function removeStopwords(counts map[string]int, stop []string) that deletes each word in stop from counts and returns the modified map. Then change main to call it before topN. This exercise touches map deletion, slice iteration, and the comma-ok idiom (think about whether you need it here).

A few small habits

  • Reassign the result of append. Always. s = append(s, x), not append(s, x).
  • Pre-size when you can. make([]T, 0, n) avoids needless reallocation if you know the eventual size.
  • Use copy when you need an independent slice, not slicing.
  • Use the comma-ok idiom whenever zero is a valid value for the map.
  • Don’t rely on map iteration order. Ever. Sort the keys when display matters.

Recap

You now know:

  • Arrays have fixed length baked into the type; slices are the dynamically sized view you’ll use everywhere.
  • make([]T, len, cap) creates a slice; append grows it; len and cap show current and underlying sizes.
  • s[i:j] produces a new slice into the same backing array — the source of every famous slice gotcha. Use copy when you need independence.
  • Maps are created with literals or make; nil maps cannot be written to.
  • Reading an absent key returns the zero value; use the comma-ok idiom to distinguish missing from zero.
  • Map iteration order is randomised; sort keys when you need a stable order.

Next steps

Slices and maps are the foundation for almost everything you’ll build next — structs, interfaces, JSON handling, HTTP request bodies, database rows. Once these two collection types feel natural, the rest of Go follows quickly.

→ Back to: What Is Go?

Questions or feedback? Email codeloomdevv@gmail.com.