The High-Score Problem
You are building a leaderboard for a multiplayer game. Players send in their names and scores. You need to look up a player's current score instantly, update it when they beat their best, and check if a new player exists without crashing. Scanning a list of structs every time is slow. You need a data structure that maps a unique identifier directly to a value. Go provides maps for this exact job.
Maps are key-value lookups
A map is a collection of key-value pairs. Each key is unique within the map. You provide a key, and the map returns the associated value. Under the hood, Go implements maps as hash tables. The runtime computes a hash of the key, finds the corresponding bucket, and retrieves the value. You don't manage the hashing or the buckets; you just work with keys and values.
Maps are reference types. When you pass a map to a function, you are passing a reference to the underlying data structure. The function can read and modify the map, and those changes are visible to the caller. This behavior differs from slices, which are also reference-like, but maps are always references. You never pass a copy of the map data unless you explicitly clone it.
Maps are references. Pass them freely; everyone sees the same data.
Minimal syntax
Here is the core syntax: create with make, set with brackets, check with comma-ok, remove with delete.
package main
import "fmt"
func main() {
// make allocates the underlying hash table structure.
// The map is empty but ready to accept keys.
scores := make(map[string]int)
// Bracket notation sets or updates the value for a key.
// If the key exists, the value is overwritten.
scores["alice"] = 95
scores["bob"] = 88
// Accessing a key returns the value.
// If the key does not exist, you get the zero value of the value type.
fmt.Println(scores["alice"]) // prints: 95
// The comma-ok idiom checks if a key exists.
// val gets the value, ok gets true if the key is present.
// This pattern distinguishes between a missing key and a zero value.
if val, ok := scores["charlie"]; ok {
fmt.Println(val)
} else {
fmt.Println("Not found")
}
// delete removes the key-value pair.
// It is safe to call delete with a key that does not exist.
// The operation is a no-op if the key is missing.
delete(scores, "bob")
}
You can also initialize a map with a literal. The literal is syntactic sugar for make followed by assignments. Use literals when you know the initial data at compile time.
// Literal syntax creates and populates the map in one step.
// The type is inferred from the keys and values.
config := map[string]string{
"host": "localhost",
"port": "8080",
}
A nil map is read-only. Initialize with make or a literal before writing.
How maps behave at runtime
When you declare a map variable without initializing it, the variable is nil.
var m map[string]int
A nil map has zero entries. You can read from a nil map; the access returns the zero value of the value type. You cannot write to a nil map. Attempting to assign a value to a nil map causes a runtime panic.
The compiler cannot catch this error because the map might be nil at runtime even if it was declared elsewhere. The panic message is panic: assignment to entry in nil map. Always ensure the map is initialized before writing. Use make or a literal. If you accept a map as a function parameter, check for nil or initialize it inside the function if the caller might pass nil.
Maps grow automatically. When you add entries and the map fills up, the runtime allocates a larger internal structure and moves the data. This resizing is transparent. You can provide a capacity hint to make to optimize the initial allocation.
// The second argument is a hint for the initial capacity.
// The map pre-allocates space for roughly 100 entries.
// This avoids resizing during the initial load phase.
users := make(map[string]User, 100)
The hint does not limit the map size. The map still grows beyond the hint if needed. The hint saves CPU cycles by reducing the number of reallocations during bulk insertion.
You can use len(m) to get the number of entries in the map. You cannot use cap(m). Maps do not have a fixed capacity like slices; they resize dynamically. The compiler rejects cap(m) with cap is not defined for map.
Map iteration order is random. The runtime randomizes the order of iteration to prevent code from depending on a specific sequence. If you need deterministic output, collect the keys into a slice and sort them.
Map iteration order is random. Sort keys if you need deterministic output.
Realistic usage: counting and copying
Maps shine when you need to aggregate data or cache results. Here is a frequency counter that demonstrates iteration, zero-value initialization, and returning a map from a function.
package main
import (
"fmt"
"strings"
)
// CountWords returns a map of word frequencies from the input text.
// It normalizes text to lowercase and splits on whitespace.
func CountWords(text string) map[string]int {
// Initialize the map inside the function.
// The caller receives a reference to this map.
counts := make(map[string]int)
// Split text into words.
// strings.Fields handles multiple spaces and trims edges.
words := strings.Fields(strings.ToLower(text))
for _, word := range words {
// Increment the count.
// If the word is new, the zero value 0 is used, then incremented to 1.
// This relies on the zero-value behavior of map access.
counts[word]++
}
return counts
}
func main() {
text := "Go maps are fast. Go maps are easy. Maps are great."
freq := CountWords(text)
// Iterate over the map.
// Map iteration order is randomized by the runtime.
for word, count := range freq {
fmt.Printf("%s: %d\n", word, count)
}
}
Maps are references, so assignment copies the reference, not the data. If you assign b = a, both variables point to the same underlying map. Changes through b affect a. To duplicate a map, you must iterate and copy the entries, or use maps.Clone from the standard library (Go 1.21+).
package main
import (
"fmt"
"maps"
)
// CopyMap creates a shallow copy of the source map.
// Direct assignment shares the underlying data; this avoids that.
func CopyMap(src map[string]int) map[string]int {
// maps.Clone allocates a new map and copies all entries.
// The values are copied by value, so pointers inside the map
// still point to the same objects. This is a shallow copy.
return maps.Clone(src)
}
func main() {
original := map[string]int{"a": 1, "b": 2}
copy := CopyMap(original)
// Modifying the copy does not affect the original.
copy["a"] = 99
fmt.Println(original["a"]) // prints: 1
fmt.Println(copy["a"]) // prints: 99
}
Assignment copies the reference, not the data. Iterate or clone to duplicate.
Pitfalls and compiler errors
Maps are not thread-safe. Concurrent reads and writes to the same map cause a runtime panic. The panic message is fatal error: concurrent map read and map write. This error is non-deterministic and can be hard to reproduce. If multiple goroutines access a map, protect it with a sync.Mutex or use sync.Map for specific high-contention workloads.
Keys must be comparable. You can use strings, integers, floats, booleans, structs with comparable fields, and pointers as keys. You cannot use slices, maps, or functions as keys because their equality is not defined. The compiler catches this at compile time.
The compiler rejects invalid keys with invalid map key type slice or non-comparable type. If you need to use a slice as a key, convert it to a string or a struct first.
Maps are not thread-safe. Protect shared maps with a mutex or use sync.Map.
Keys must be comparable. The compiler rejects slices, maps, and functions as keys.
Decision: map vs slice vs struct
Use a map when you need fast lookups by a unique key and the dataset changes dynamically. Use a slice when you need ordered data, frequent iteration, or the index is a simple integer. Use a struct when you have a fixed set of named fields and every field is meaningful. Use sync.Map when you have high contention from many goroutines reading and writing distinct keys.