Skip to content
CalliCoder

Golang Maps by Example

Published Updated Golang 12 min read

The comma-ok idiom, why a nil map reads fine and panics on write, deliberately randomised iteration order, why you cannot take the address of an element, and what a concurrent write does to your process.

A map is a hash table with a reference-type header. Two consequences drive everything below: copying a map variable does not copy the map, and the runtime is free to move entries around, which is why a few operations you would expect to work are compile errors.

Written against Go 1.22.

Creating one

var m map[string]int              // nil map — read-only
n := make(map[string]int)         // empty, usable
o := make(map[string]int, 100)    // pre-sized for ~100 entries
p := map[string]int{              // literal
    "go":     2009,
    "kotlin": 2011,
}

The size hint on make is not a limit. It pre-allocates buckets so growth does not rehash on the way to that many entries. Worth passing when you know the rough size.

Reading, and the comma-ok idiom

A missing key returns the value type’s zero value, not an error:

m := map[string]int{"go": 2009}

fmt.Println(m["go"])        // 2009
fmt.Println(m["rust"])      // 0  — no panic, no error

So m[k] == 0 cannot distinguish “absent” from “present and zero”. The two-value form can:

if year, ok := m["rust"]; ok {
    fmt.Println("found", year)
} else {
    fmt.Println("not present")
}

This matters most with bool values, where the zero value is false and indistinguishable from absence:

flags := map[string]bool{"verbose": false}
fmt.Println(flags["verbose"])     // false
fmt.Println(flags["quiet"])       // false — same answer, different meaning

For a set, map[string]struct{} is the idiomatic type. struct{} occupies no space, and membership is unambiguous:

seen := map[string]struct{}{}
seen["go"] = struct{}{}
_, exists := seen["go"]

Writing and deleting

m["rust"] = 2010          // insert or overwrite
delete(m, "rust")         // no-op if absent, never panics
clear(m)                  // Go 1.21+, removes everything, keeps the allocation
fmt.Println(len(m))

delete on a missing key is deliberately silent, no check needed.

A nil map reads and panics

var m map[string]int

fmt.Println(m["go"])      // 0 — reading a nil map is fine
fmt.Println(len(m))       // 0
delete(m, "go")           // fine
for k := range m { }      // fine, zero iterations

m["go"] = 2009            // panic: assignment to entry in nil map

Reads are safe and writes panic, which is an asymmetry worth internalising. It bites most often with a struct field that was never initialised:

type Config struct {
    Labels map[string]string
}

c := Config{}
c.Labels["env"] = "prod"     // panic — the field is nil

c.Labels = make(map[string]string)   // initialise first

A constructor function that initialises maps is the usual defence.

Iteration order is randomised on purpose

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v)     // a different order each run
}

This is not “unspecified but stable in practice”: the runtime picks a random start position deliberately, so code cannot come to depend on an order that hash-table internals might change.

For deterministic output, sort the keys:

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])
}

Modifying a map during iteration is allowed but the result is partly undefined: an entry deleted before it is reached will not be produced, and an entry added during iteration may or may not appear. Collect first, then mutate.

Maps are reference types

func addEntry(m map[string]int) {
    m["added"] = 1        // the caller sees this
}

func replace(m map[string]int) {
    m = map[string]int{}  // the caller does NOT see this
}

The map header is copied on assignment, and it points at the same hash table. So a function can mutate the caller’s map without a pointer, but reassigning the parameter only rebinds the local copy.

This also means a := b gives two variables sharing one map. To copy the contents:

dst := make(map[string]int, len(src))
maps.Copy(dst, src)         // Go 1.21+
// or
cloned := maps.Clone(src)   // shallow: values are shared if they are references

You cannot take the address of an element

m := map[string]Point{"origin": {0, 0}}

p := &m["origin"]           // compile error: cannot take address of m["origin"]
m["origin"].X = 5           // compile error: cannot assign to struct field

Both are refused for the same reason: growing a map moves entries between buckets, so a pointer into one would become invalid without warning. Go rejects the pointer rather than allowing a dangling one.

Two ways forward. Read, modify, write back:

pt := m["origin"]
pt.X = 5
m["origin"] = pt

Or store pointers, which makes the entry itself mutable:

m := map[string]*Point{"origin": {0, 0}}
m["origin"].X = 5           // fine — the map value is a pointer

Pointer values also make “present” and “nil” distinguishable, at the cost of an allocation per entry.

Keys must be comparable

map[string]int          // fine
map[int]bool            // fine
map[[2]int]string       // fine — arrays are comparable
map[Point]string        // fine if Point's fields are all comparable

map[[]string]int        // compile error: slices are not comparable
map[map[string]int]int  // compile error
map[func()]int          // compile error

A struct key works only if every field is comparable. One slice field and the whole struct is disqualified. For a compound key, either use a comparable struct or a formatted string, accepting that string keys allocate.

any as a key type compiles and can panic at runtime if you insert an uncomparable dynamic value.

Also note: NaN as a float key is pathological. Since NaN != NaN, an entry stored under it can never be retrieved, and repeated insertion grows the map without bound.

Concurrency: not a panic, a crash

m := make(map[string]int)

go func() { for { m["a"] = 1 } }()
go func() { for { _ = m["a"] } }()

This produces fatal error: concurrent map read and map write, and a fatal error is not a recoverable panic. No recover catches it, no deferred handler runs, the process dies, the runtime does this on purpose, because the alternative is silent corruption.

Two fixes:

// a mutex, when writes are frequent
type Counter struct {
    mu sync.RWMutex
    m  map[string]int
}

func (c *Counter) Get(k string) int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.m[k]
}

func (c *Counter) Set(k string, v int) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

sync.Map is the other option, and it is specialised rather than general: it is designed for keys written once and read many times, or disjoint key sets per goroutine. For a normal read-write workload a plain map behind an RWMutex is usually faster and always clearer.

The maps package

Go 1.21 added generic helpers:

maps.Clone(m)
maps.Copy(dst, src)
maps.Equal(a, b)
maps.DeleteFunc(m, func(k string, v int) bool { return v < 0 })

Iterator-based maps.Keys and maps.Values arrived with range-over-function in Go 1.23; before that, collect keys with the loop shown earlier.

Frequently asked questions

What does a map return for a missing key?

The zero value of the value type. Use the two-value form v, ok := m[k] to distinguish absent from present-and-zero.

Why does reading a nil map work but writing panic?

A nil map has no hash table to write into. Reads, len, delete and range are all defined to behave as if empty; assignment panics.

Why is iteration order different every run?

The runtime randomises the starting bucket deliberately, so code cannot come to rely on an order that internals might change. Sort the keys for deterministic output.

Are maps copied on assignment?

No. The header is copied and both variables refer to the same table. Use maps.Clone or maps.Copy for an independent copy.

Why can I not write m[k].Field = v?

Map entries are not addressable, because growth relocates them. Read the value, modify it, write it back, or store pointers as values.

Which types can be map keys?

Comparable ones: booleans, numerics, strings, pointers, channels, interfaces, and arrays or structs whose components are comparable. Slices, maps and functions cannot.

Is a map safe for concurrent use?

No. Concurrent read and write produces a fatal error that recover cannot catch. Guard with a mutex, or use sync.Map for its specific access patterns.

When should I use sync.Map?

When keys are written once and read repeatedly, or when goroutines use disjoint key sets. For general read-write traffic, a map behind an RWMutex is usually faster.

How do I implement a set?

map[T]struct{}. The empty struct occupies no space, and membership is tested with the comma-ok form.

How do I delete every entry?

clear(m) from Go 1.21, which keeps the allocated buckets. Otherwise assign a fresh map.

Where should I go next?

Slices is the other reference-type built-in with aliasing behaviour worth knowing, and structs covers the value types that go inside both.