Last Updated: May 17, 2026
A Go map is a hash table managed by the runtime. Once you can picture how keys turn into buckets and how the table grows, the behaviour that surprises beginners (randomized iteration, concurrent-write panics, memory that never shrinks after deletes) becomes predictable. This chapter walks through the conceptual model: how a key reaches a bucket, why the map variable is "reference-like", how growth works, and what changed in Go 1.24.
A map answers two questions: "is this key in the table?" and "if so, what's its value?". A hash table answers both in roughly constant time by funneling each key through a hash function that turns it into a number, then using that number to pick a bucket where the key (if present) is supposed to live.
Concretely, when you do prices["Apple"], the runtime computes hash("Apple"), takes the low bits of that hash to pick a bucket out of the table, walks the small list of key-value pairs in that bucket, and compares the stored key against "Apple" with == until it either finds a match or runs out of entries.
Behind that simple lookup is a fixed sequence of work: hash the key, pick a bucket, scan the bucket, compare keys. The hash function is what makes the bucket choice cheap, and the small bucket size is what keeps the per-lookup scan cheap. Together they give the average O(1) cost that maps are known for.
Cost: Map lookups are O(1) on average but not in the worst case. A pathological key set that hashes into the same bucket degrades the scan toward O(n). Go's hash function uses a per-process random seed, so attackers can't easily engineer collisions on purpose.
Here is the picture for one lookup. The key is fed through the hash function, the low bits select a bucket, and the runtime scans the few entries in that bucket looking for an exact key match.
N is the current number of buckets, which is always a power of two. The runtime uses the low bits of the hash to index into the bucket array. Insertions, deletions, and lookups all go through this same routing step. The differences are only what happens once the bucket is in hand.
Each bucket holds a small fixed number of key-value pairs. You don't pick the bucket count, the runtime does, and it grows the bucket array as the map fills up. The exact bucket layout has changed across Go versions, but the model that helps you reason about cost is the same: hash, route to a bucket, scan the bucket.
Two different keys can hash to the same bucket. That's called a collision, and it's expected. With a small bucket count and a large key set, collisions are common. The runtime handles them by storing colliding keys in the same bucket and walking through them at lookup time. When a bucket fills up beyond its small capacity, the runtime chains an overflow bucket onto the end (in the pre-1.24 design) or uses Swiss-table-style probing within an enlarged group (in Go 1.24 and later). Either way, what matters to you is that occasional collisions don't break lookups, they just make the bucket scan a little longer.
You don't get a way to ask "which bucket does this key land in?". The runtime doesn't expose that, and it's deliberately not part of the language. If you could see bucket indices, you'd write code that depends on them, and the runtime couldn't reorganize the table on growth without breaking your code. The only contract is: lookup is fast on average, keys are routed by hash(key), and equal keys land in the same bucket.
The key type has to be comparable with ==. That's why slices, maps, and functions can't be map keys: they don't have a meaningful ==. Structs are usable as keys only if every field is comparable. The hash function is built into the runtime for every comparable type; you don't write it yourself.
Cost: A string key hashes faster than a 200-byte struct key, because the hash has to read every byte that participates in ==. If you have a very large struct that you use as a map key, pre-computing a smaller identity (an int ID) is often worth it.
A map variable in Go holds a pointer to a runtime structure (often called hmap internally). That structure owns the bucket array, the current size, the hash seed, and the bookkeeping fields the runtime needs. When you assign a map to another variable, or pass it to a function, you copy the pointer, not the buckets. Both variables now refer to the same table.
The assignment alias := stock copied the map header (a pointer), so both variables point at the same runtime structure. Writing through alias updates the table that stock is also looking at. The same thing happens when a map is passed to a function: the function receives a copy of the pointer, but the table itself is shared.
markOutOfStock doesn't return anything, yet main sees the change. The function and the caller share the table. This is why most Go code doesn't return maps that were modified in place; the modification is already visible to the caller.
Here is the shape of that sharing. Two variables, one runtime structure, one bucket array.
The two map variables on the left are independent (you could reassign one without touching the other), but they both reference the same hmap header, which in turn owns the bucket array. There is no "copy the map" operation built into the language. If you want a true copy you have to iterate and insert into a fresh map, or use maps.Clone from the standard library.
The zero value of a map is nil. A nil map has no hmap and no buckets behind it. Reading from a nil map returns the zero value for the value type; writing to a nil map panics. This is why make(map[K]V) matters before the first write.
The runtime tracks a load factor: roughly, the average number of entries per bucket. When that number gets high enough, the map grows. Growth means allocating a new bucket array with twice as many buckets and migrating the existing entries into it.
The migration doesn't happen all at once. Doing it in one shot would cause a noticeable pause on a large map. Instead, the runtime grows the map incrementally: each subsequent insertion or deletion does a small slice of the migration work, moving a few buckets at a time from the old array to the new one. By the time the map has had enough operations to amortize the cost, the migration is complete and the old bucket array is reclaimed by the garbage collector.
During the migration window, the runtime has to handle lookups that could be in either array. It checks the new array first, falling back to the old array if the key hasn't been migrated yet. The user doesn't see any of this. From outside, the map just behaves normally; it's a little more expensive per operation during the migration, and a little less afterwards.
Cost: Map operations are amortized O(1). A single insert that triggers growth is still constant work; the expensive part of the migration is spread across the next many operations. Pre-sizing with make(map[K]V, hint) avoids most of the growth events when you know the final size.
You can see the effect of pre-sizing in a quick benchmark sketch. Inserting one million entries into a pre-sized map does noticeably less work than inserting them into a default-sized map, because the default-sized one has to grow many times along the way.
The exact numbers depend on your machine and Go version, but the relationship is consistent: pre-sizing wins. You're not avoiding work the runtime would have done forever, you're just doing it once up front instead of in installments.
Deleting an entry does not shrink the bucket array. The entry's slot is marked empty so future inserts can reuse it, and len(m) goes down, but the table itself keeps the same number of buckets it had before. This is by design: shrinking and re-growing would be wasted work if the map is about to fill up again. The downside is that a map that briefly held millions of entries holds onto its bucket memory until it's garbage collected as a whole.
If you really need to release that memory, the move is to drop the reference and let the garbage collector reclaim the whole map, then assign a fresh one: cart = make(map[string]int). The old map becomes unreachable as soon as nothing points at it.
Iterating a map with for k, v := range m walks the buckets and returns each entry exactly once, but the order is not the insertion order, the sorted order, or any order you can predict. It's deliberately randomized. Each range over the same map starts at a randomly chosen offset.
Run the program again and you'll get a different ordering. The randomization is a feature, not a bug. It exists because earlier versions of Go produced a stable-looking order that was just an accident of the bucket layout, and developers started writing code that depended on it. When the runtime later changed its bucket layout for performance reasons, that code broke in subtle ways.
By starting iteration at a random offset, the runtime makes it impossible to accidentally depend on the order. If you want a sorted iteration, you have to sort the keys yourself, which makes the dependency explicit.
The iteration order is one of the few language-level decisions in Go where the runtime actively prevents you from doing the wrong thing. The point here is that the randomization comes from the runtime picking a random bucket offset at the start of each range.
There is one observable consequence worth noting. If you mutate a map during iteration (adding or deleting keys), the spec says the iteration "may or may not" produce the new keys. Don't rely on either outcome. If you need to delete during iteration, you can delete(m, k) safely (the spec allows it), but adding keys during iteration is risky and unpredictable.
Maps are not safe for concurrent use. The runtime does not lock the bucket array, the hash seed, or the migration state. If two goroutines write to the same map at the same time, the runtime detects the race and crashes the program with a fatal error.
This is what that looks like.
Run it and you'll often see something like:
The runtime's concurrency check is best-effort and racing-on-purpose, so you might get one clean run followed by a crashing one. To make detection deterministic, run the program with the race detector enabled.
go run -race builds the program with an instrumented runtime that catches data races on every access, not just the ones the map's own check happens to notice. Use it whenever you're not sure whether your code has a race.
Cost: The "concurrent map writes" detection is cheap (a few atomics on the map header), but it doesn't catch every race. Treat it as an early-warning system, not a substitute for proper synchronization.
The fix is to serialize access. The two common approaches are a sync.Mutex around the map and the sync.Map type from the standard library.
A plain mutex around a map is the right answer in most cases. It's easy to reason about, performs well for moderate contention, and works with any map operation including range loops. The point here is that the map itself does not synchronize.
The standard library ships a separate type, sync.Map, designed for two specific concurrent workloads: caches where each key is written once and read many times, and maps whose keys are partitioned among goroutines so different goroutines write to disjoint keys. Outside those patterns, a plain map plus a sync.Mutex is usually faster and easier to read.
The API is different from a regular map: methods like Store, Load, LoadOrStore, Delete, and Range, with any-typed keys and values. Type safety is weaker, and benchmarks usually favor a plain map with a mutex for general use. Reach for sync.Map only when its specific workload matches yours, or when a profile shows the mutex is a bottleneck.
Go 1.24 changed the runtime map implementation to use Swiss tables for better performance. The observable semantics are the same: keys are still routed by hash(key), lookups are still amortized O(1), iteration order is still randomized, concurrent writes still panic, and the syntax you write is unchanged. What's different is the internal layout.
Before Go 1.24, each bucket was a small fixed-size struct that held a handful of key-value pairs, with overflow buckets chained on the end when a bucket got too full. The 1.24 implementation organizes buckets into larger groups and uses a metadata table to scan a group's slots in a single SIMD-friendly comparison, which reduces cache misses and speeds up both lookups and inserts on modern CPUs.
You don't write different code for Go 1.24 maps. You don't even compile differently. The runtime swap is invisible from outside. The reason it's worth knowing about is that benchmarks and memory profiles from before and after 1.24 can look different for map-heavy code, and the layout details you might have learned from older blog posts (specifically the eight-pair bucket with overflow chains) describe the pre-1.24 implementation.
If you want a deeper look at the internal layout, the Go team's announcement and the runtime source are the authoritative references. For day-to-day Go programming, the only thing that changed is performance, and the model in this chapter (hash, bucket, scan, grow) still describes how the table behaves.
delete(m, k) does not shrink the bucket array. The slot is marked empty and len(m) drops, but the underlying table keeps its size until the whole map is replaced.range at a random bucket offset. Sort the keys yourself if you need a stable order.fatal error: concurrent map writes. Wrap a map with a sync.Mutex, or use sync.Map for read-heavy or partitioned workloads.The next chapter, Implementing Sets with Maps, uses everything from this section to build a real data structure on top of map[K]struct{}.