Go Maps: Semantics Before Internals
Learn when to use Go maps, how keys and zero values behave, why iteration needs care, and how to handle shared state and concurrent updates safely.
A stock lookup returns zero. Does that mean the product is sold out, or that the product does not exist? A report changes order between runs. Two requests increment the same counter, but the final total is wrong.
These are map-semantics problems. Understanding a hash-table layout will not resolve them until the application defines presence, ordering, ownership, and coordination.
This is Part 5 of Go Under the Hood, following Strings, Bytes, and Runes. You should recognise functions, structs, and loops. The examples use the standard library and are verified with Go 1.27.1 on macOS ARM64. The concurrent example introduces a mutex and a wait group; later parts cover them in depth.
When a map is the right representation
A map associates unique keys with values. Assigning another value to an existing key replaces that entry. Choose it when the question is “What value belongs to this key?” rather than “What comes next?”
| Requirement | Starting point | Why it fits |
|---|---|---|
| Find a product by ID | map[string]Product | Lookup follows the product’s identity |
| Count events by category | map[string]int | Missing entries naturally start at zero |
| Track distinct IDs | map[string]struct{} | Presence represents membership |
| Preserve a sequence, including duplicates | Slice | Position and repetition are meaningful |
| Index dense integer positions | Slice or array | Position itself identifies the element |
| Produce sorted output from keyed data | Map plus explicitly sorted keys | Lookup and presentation have separate needs |
These are design starting points, not performance rankings. A tiny slice may be perfectly adequate for a handful of entries. A map also does not provide persistence, eviction, or coordination across service instances merely because it is used as a cache.
Experiment 1: zero is a value, not proof of presence
Create a module with a Go 1.27 toolchain:
go version
mkdir map-demo
cd map-demo
go mod init example.com/map-demo
go mod edit -go=1.27.0
Save this as main.go:
package main
import "fmt"
func main() {
var stock map[string]int
quantity, exists := stock["tea"]
fmt.Println("nil lookup:", quantity, exists, len(stock))
delete(stock, "tea")
stock = make(map[string]int)
stock["tea"] = 0
quantity, exists = stock["tea"]
fmt.Println("stored zero:", quantity, exists)
stock["coffee"]++
fmt.Println("increment:", stock["coffee"])
delete(stock, "tea")
_, exists = stock["tea"]
fmt.Println("after delete:", exists, len(stock))
}
Run each complete program with:
go run .
go vet ./...
Expected output:
nil lookup: 0 false 0
stored zero: 0 true
increment: 1
after delete: false 1
The zero-value map is nil. Reading it returns the element type’s zero value, and deleting a missing key is harmless. Writing requires an initialised map. The two-result lookup distinguishes absence from a stored zero. Go maps in action
For a deliberate failure, move stock["tea"] = 0 above make. Execution panics because it writes to a nil map. Restore the original order afterwards.
In a stock API, exists == false might mean “unknown product,” while quantity == 0 && exists means “known product with no stock.” In a counter, that distinction may be unnecessary: stock["coffee"]++ starts from zero. Choose the meaning before choosing the lookup form.
A similar trap appears with map[string]bool: false can mean absent or explicitly disabled. Use the presence result when those are different states.
Choose keys that express identity
Map keys must be comparable. Strings, integers, pointers, and arrays or structs of comparable components qualify; slices, maps, and functions do not. An interface key can still panic at runtime if its dynamic value is not comparable. Go specification: map types
For example, a proposed inventory system can use a composite key:
type StockKey struct {
Warehouse string
SKU string
}
A map[StockKey]int makes both identity fields explicit. Concatenating fields with a separator requires an escaping policy if the separator can occur inside a field.
Prefer a concrete key type when the domain permits it. map[any]int admits values that the map cannot actually use as keys: assigning an entry with []int{1} as its key panics. A map[[]int]int is rejected at compile time instead.
For text keys, byte equality matters. As Part 4 explained, visually equivalent Unicode spellings can have different bytes. Define any normalisation policy before inserting and looking up keys. Do not add normalisation silently to identifiers whose exact bytes carry meaning.
Experiment 2: assignment shares the map
Replace main.go with:
package main
import (
"fmt"
"maps"
)
type Product struct {
Stock int
}
func main() {
original := map[string]Product{"tea": {Stock: 2}}
alias := original
product := alias["tea"]
product.Stock++
fmt.Println("local edit:", original["tea"].Stock)
alias["tea"] = product
fmt.Println("written back:", original["tea"].Stock)
independent := maps.Clone(original)
independent["tea"] = Product{Stock: 9}
fmt.Println("clone:", original["tea"].Stock, independent["tea"].Stock)
shared := map[string]*Product{"tea": {Stock: 2}}
shallow := maps.Clone(shared)
shallow["tea"].Stock = 7
fmt.Println("shared target:", shared["tea"].Stock)
}
Expected output:
local edit: 2
written back: 3
clone: 3 9
shared target: 7
The local struct edit changes the retrieved value. Writing it back updates the map, visible through either map variable. maps.Clone creates a shallow copy: keys and values are copied by assignment. With pointer values, both maps still reach the same products. maps.Clone
Try replacing the retrieval and write-back with alias["tea"].Stock++. It does not compile: a map element is not addressable for this field update. Retrieve, modify, and assign back, or intentionally store pointers. Go specification: address operators and assignments
Pointer values suit shared object identity, but introduce ownership questions. Who may mutate the product? Can it be nil? Does a lock protecting the map also protect its pointees? Choosing pointers solely to shorten one assignment can make the rest of the API harder to reason about.
Iteration order is not a presentation contract
Map iteration order is unspecified. For stable output, collect and sort keys. maps.Keys supplies a key iterator, and slices.Sorted collects and sorts it. maps.Keys, slices.Sorted
Replace main.go with:
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
stock := map[string]int{"tea": 2, "coffee": 1, "water": 0}
for _, key := range slices.Sorted(maps.Keys(stock)) {
fmt.Printf("%s=%d\n", key, stock[key])
}
}
It prints:
coffee=1
tea=2
water=0
Use this pattern for a human-readable report or deterministic application output. Tests should compare map contents directly unless ordering is part of the result being tested. Do not rely on a few repeated runs happening to show the same order.
During a single-goroutine range, an entry deleted before it is reached will not be visited. An entry inserted during iteration may or may not be visited. Those rules do not permit unsynchronised concurrent mutation. Go specification: range clauses
If every newly added item must be processed, use an explicit work queue. A map range is a poor substitute for that requirement.
Growth and size hints are implementation concerns
make(map[string]int, 1000) supplies an initial size hint; it does not create 1,000 entries or impose a limit. The map grows as entries are added. There is no map cap operation, and an element’s address cannot be retained with &m[key]. Go specification: map types
Choose a hint when a reasonable entry estimate is already available, such as indexing an existing batch. Do not reserve for an imagined maximum without considering the workload. This article makes no allocation or latency claim about a particular hint.
The Go runtime’s map representation can change across releases. Bucket diagrams, growth thresholds, and allocation behaviour belong to a version-specific investigation; they are not substitutes for the language rules above. A later series part will examine the implementation with a pinned toolchain.
Concurrent access needs a shared coordination policy
A built-in map can be read concurrently when it is safely published and nobody modifies it. Overlapping writes, or reads overlapping writes, need synchronisation—even when callers use different keys. A runtime failure is not a dependable race-detection strategy. Go maps: concurrency
For a shared counter, lock the complete read-modify-write operation. Replace main.go with:
package main
import (
"fmt"
"sync"
)
func main() {
counts := make(map[string]int)
var mu sync.Mutex
var wg sync.WaitGroup
for worker := 0; worker < 4; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
counts["requests"]++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println(counts["requests"])
}
Run it with the race detector:
go run -race .
go vet ./...
It prints 4000. The mutex covers each complete increment; the wait group joins all workers before the final read. No worker can still be modifying the map at that point. The mutex’s synchronisation and the wait group’s completion ordering are documented guarantees. sync package
Protecting the lookup and assignment separately would still permit lost updates: two workers could both read the same count, then both write the same incremented value. That is a logical race even if each individual map access is protected.
The race detector only observes executed paths. A clean run adds evidence; it does not prove every possible execution correct. Go race detector
Choose shared-state tools by the required operation
| Situation | Reasonable choice | What to decide explicitly |
|---|---|---|
| Request-local lookup | Ordinary typed map | Keep it within one request’s ownership |
| Shared counters or multi-field invariants | Typed map with a mutex | Lock the whole operation, including related state |
| Many readers of fixed data | Safely published, immutable map | No mutation after publication; protect nested mutable values too |
| Work serialised through one owner | Owner goroutine with messages | Queue capacity, cancellation, and shutdown |
| Specialised concurrent key access | Consider sync.Map | Confirm its workload and operation semantics fit |
sync.Map is specialised, including for entries written once and read many times, or concurrent operations on disjoint key sets. Its documentation recommends an ordinary map with coordination for most code. A separate Load followed by Store does not make an increment atomic. sync.Map
For a service cache, also define expiry, size bounds, and what happens on a miss. For a cross-instance uniqueness rule, a process-local map is insufficient: the coordination must cover all participants, often through a database constraint or transaction. Idempotency: Designing APIs That Survive Retries explores that broader boundary.
Exercise: change the contract, then predict the result
In Experiment 1, remove stock["tea"] = 0. The next lookup becomes 0 false. Explain why a single-result lookup would conceal the change.
In Experiment 2, change the clone’s pointer assignment to shallow["tea"] = &Product{Stock: 7}. The final output becomes shared target: 2: the clone replaces its own entry rather than mutating the shared product.
In the counter program, change to eight workers and 250 increments each. Verify 2000 with -race. Explain why the final read is safe without holding the mutex, then explain why moving that read before wg.Wait() would invalidate that reasoning.
The next planned part, Struct Layout and Memory Alignment, examines how fields occupy memory. For maps, first make presence, identity, ordering, and ownership explicit; those decisions determine whether the application behaves correctly regardless of its runtime layout.