How Go Maps Work Internally (Hash Table Implementation)

Go maps use a hash table with buckets and concurrent rehashing for fast, non-blocking lookups and resizing.

How Go Maps Work Internally

You are building a cache for user sessions. Every request checks a session ID, stores metadata, and updates timestamps. In Python, you reach for a dictionary. In JavaScript, you use an object or Map. In Go, you write m := make(map[string]int) and start assigning values. The syntax feels familiar, but the engine underneath is different. Go maps are not wrappers around arrays or linked lists. They are a carefully tuned hash table designed to stay fast as data grows, with a resizing strategy that avoids stopping the program.

A map in Go is a hash table backed by an array of buckets. Each bucket holds up to eight key-value pairs. When a bucket fills up, Go attaches an overflow bucket to it. Lookups compute a hash of the key, find the bucket, and scan at most a few entries. This design keeps average time complexity at O(1) for lookups, insertions, and deletions. The runtime also handles growth incrementally, moving data to a larger table in the background so your code doesn't stall during resize operations.

The bucket structure

Think of a warehouse with rows of bins. Each bin has a number on it. When you store a box, you run the label through a machine that spits out a number. That number tells you which bin to use. If two boxes get the same number, they share the bin. Go limits each bin to eight boxes. If a bin fills up, Go attaches a second bin to it, like a trailer on a truck. This keeps lookups fast because you only check one or two bins, not the whole warehouse.

Under the hood, the map is represented by an hmap struct. This struct holds a pointer to the bucket array, the count of elements, and a value called B. The B field is an exponent. The number of buckets is 1 << B. When the map is empty, B is zero and there are no buckets. The first insertion allocates the initial bucket array.

Each bucket is a bmap struct. It contains:

  • An array of eight tophash values. These store the top 8 bits of the hash for each key in the bucket.
  • An array of eight keys.
  • An array of eight values.
  • A pointer to the next overflow bucket.

The tophash array is an optimization. When you look up a key, Go computes the hash and checks the tophash array first. If the top bits don't match, Go skips the full key comparison. This avoids expensive string or struct comparisons when the bucket doesn't contain the key.

package main

import "fmt"

func main() {
    // make allocates the hmap structure on the heap.
    // It starts with zero buckets until the first insertion.
    scores := make(map[string]int)

    // Assignment hashes "alice", finds the bucket index,
    // and inserts the key-value pair.
    // If the bucket is full, Go allocates an overflow bucket.
    scores["alice"] = 95

    // Lookup hashes "alice" again.
    // The second return value uses the ok idiom.
    // It tells you whether the key exists in the map.
    // This prevents silent bugs where a missing key returns the zero value.
    val, ok := scores["alice"]
    fmt.Println(val, ok) // 95 true
}

Maps are reference types. When you pass a map to a function, you are passing a handle to the underlying hmap. Modifications inside the function affect the original map. This convention saves boilerplate. You do not need to return the map or pass a pointer to the map. Just pass the map.

Maps are reference types. Pass them freely.

Growth and concurrent resizing

Maps grow when the number of elements exceeds a load factor threshold. The load factor is 6.5 elements per bucket on average. When the count crosses this threshold, the runtime triggers a resize. It allocates a new bucket array with B+1, which doubles the number of buckets.

Go does not move all entries at once. That would block the program. Instead, it performs incremental evacuation. The hmap struct keeps a pointer to the old bucket array in oldbuckets. A counter called nevacuate tracks how many buckets have been moved. Every time your code accesses the map, the runtime checks if evacuation is in progress. If so, it moves the entries from the current bucket to the new array before proceeding with the lookup or insertion.

This means resizing happens gradually during normal map operations. You never see a spike in latency because the map is rebuilding itself. The old buckets remain valid until all entries are evacuated. Once nevacuate reaches the end of the old array, the runtime discards oldbuckets and the resize is complete.

If you know the approximate size of the map ahead of time, you can reduce resizing overhead by passing a capacity to make. This allocates enough buckets upfront to hold the expected number of elements without immediate growth.

// CountFrequency builds a map of word counts from a slice of strings.
// It demonstrates pre-allocation and the ok idiom for existence checks.
func CountFrequency(words []string) map[string]int {
    // Pre-allocating capacity reduces resizing overhead
    // if you have a rough estimate of the final size.
    // make calculates the initial B value based on the hint.
    counts := make(map[string]int, len(words))

    for _, word := range words {
        // Incrementing a map value is safe.
        // If the key doesn't exist, Go returns the zero value for int, which is 0.
        // The assignment creates the key if needed.
        counts[word]++
    }

    return counts
}

Pre-allocating capacity is a small win that compounds in tight loops.

Keys and comparability

Map keys must be comparable. You cannot use a map, slice, or function as a key. The compiler rejects this with invalid map key type map[string]int. The key type must support equality comparison. Structs are allowed as keys if all their fields are comparable. A struct containing a slice or map field is not comparable and cannot be used as a key.

Go uses a randomized hash function to compute key hashes. The hash seed is randomized at program startup. This prevents attackers from crafting inputs that cause hash collisions and degrade performance. If an attacker can force many keys into the same bucket, lookups degrade from O(1) to O(n). Randomization makes this attack infeasible.

Pitfalls and runtime errors

Nil maps panic on assignment. If you declare a map variable without initializing it, the value is nil. Attempting to assign a value to a nil map causes a runtime panic. The compiler does not catch this because the map might be assigned later in the code. The runtime panics with panic: assignment to entry in nil map. Always initialize maps with make or a literal.

var m map[string]int
m["key"] = 1 // panic: assignment to entry in nil map

Concurrent access panics. Go maps are not thread-safe. If one goroutine writes to a map while another goroutine reads or writes, the runtime detects the race and crashes the program. The error message is fatal error: concurrent map read and map write. This is a hard failure. Go does not allow silent corruption. You must protect concurrent access with a mutex or use sync.Map for specific high-read scenarios.

Iteration order is random. When you range over a map, the order of keys is not guaranteed. The runtime deliberately randomizes the iteration order. This prevents code from relying on stable ordering and encourages correct designs. If you need ordered output, collect the keys into a slice and sort them.

Nil maps panic. Initialize early. Concurrent maps crash. Lock or use sync.Map. Keys must compare. Structs work if fields compare.

When to use maps versus alternatives

Use a map when you need fast lookups by key and the keys are dynamic. Maps excel at associating arbitrary keys with values and checking existence efficiently.

Use a slice when you need ordered data or you are iterating over all elements frequently. Slices preserve insertion order and support indexing. They are faster for sequential access but slower for lookups by value.

Use a struct when you have a fixed set of named fields and type safety matters. Structs provide compile-time checking of field names and better documentation. They are more efficient for small, fixed schemas.

Use sync.Map when you have high read concurrency and distinct keys per goroutine. sync.Map is optimized for cases where each goroutine writes to a unique set of keys and reads are frequent. It avoids mutex contention but has higher overhead for writes.

Use a mutex-protected map when you have mixed read/write access from multiple goroutines. A standard map wrapped in sync.Mutex is the general solution for concurrent access. It provides full safety with predictable performance.

Pick the structure that matches your access pattern. Maps for keys. Slices for order. Structs for fixed shapes.

Where to go next