Go to content
Web development

Understanding Maps and Reference Types in Go

When you need to look up a value by a key in Go, a map is the built-in tool. A map is an associative data type, the equivalent of a hash or dictionary in other languages. The standard Go compiler implements it as a hash table. You create one with make, add entries with m[key] = value, and read entries back with m[key].

The behavior that surprises developers from other languages is that maps are reference types. The variable you hold does not contain the data; it holds a reference to the underlying table. When you pass a map to a function, that function can add, change, or delete entries, and the caller sees the changes. This behavior makes maps convenient, but it also creates the problems you will hit in practice. Writing to a nil map panics. Iteration order is randomized, and deleting entries does not release memory. Concurrent writes crash the program. To keep out of trouble, initialize maps before writing and treat iteration order as unspecified. Protect any map that more than one goroutine uses.

What a map reference actually holds

In Go, the map variable you declare is a small descriptor, not the collection itself. The runtime stores the actual entries in a hash table allocated elsewhere, and the variable points to that table. The same design applies to slices and channels, which is why Go’s type system overview groups them as reference types. The Go specification does not formally define the term "reference type". The observable behavior is what you rely on: assignments and function calls copy the descriptor, and both copies refer to the same underlying data.

Three behaviors follow from this design.

Copying a map variable does not copy entries. m2 := m gives you a second variable pointing at the same table; a write through m2 is visible through m.

Passing a map to a function gives the function its own copy of the descriptor. The function can add, change, or delete entries, and the caller sees those changes. This is what people mean when they say maps are reference types.

Assigning a new map to the parameter inside the function does not affect the caller. If the function runs m = make(...), it only rebinds its local copy; the caller’s variable still points at the original table.

func addTag(labels map[string]string, key, value string) {
    labels[key] = value // the caller sees this entry
}

func reset(labels map[string]string) {
    labels = make(map[string]string) // the caller's map is unchanged
}

labels := map[string]string{}
addTag(labels, "env", "prod")
fmt.Println(labels["env"]) // prod
reset(labels)
fmt.Println(len(labels)) // 1

Because a map is a reference, maps are not comparable with ==; you can only compare a map to nil. To compare the contents of two maps, the maps package in the standard library provides maps.Equal.

Map operations: create, read, update, delete

The basic API is small, and Go by Example’s maps page demonstrates it with runnable code:

counts := make(map[string]int)
counts["apple"] = 2
counts["banana"] = 1
fmt.Println(counts["apple"]) // 2

delete(counts, "banana")
fmt.Println(len(counts)) // 1

You can also declare and populate a map in one step with a composite literal: map[string]int{"foo": 1}. The Tour of Go covers map literals and the same basic operations.

If you read a key that is not present, the map returns the zero value for the value type, so a key absent from a map[string]int reads as 0. When 0 is a valid stored value, use the two-value form to tell a missing key from a present one:

value, ok := counts["cherry"]
if !ok {
    // the key is not in the map
}

The built-in delete removes a pair, clear removes every pair at once, and len reports how many pairs remain.

The key type must be comparable, because the runtime hashes and compares keys to locate entries. The compiler accepts strings, integers, booleans, and structs whose fields are all comparable as key types; it rejects slices, maps, and functions because they are not comparable. The Go specification defines comparable types and the rules for map keys.

Why nil maps panic and how to handle them

The zero value of a map type is nil. A nil map behaves like an empty map for reads: m[key] returns the zero value, and len(m) returns 0. The difference appears on the first write. If you assign to a nil map, the program panics at runtime with "assignment to entry in nil map", and the compiler cannot catch it because the nil state is a runtime value, not a type error.

var counts map[string]int
fmt.Println(counts == nil) // true
counts["apple"] = 1        // panic: assignment to entry in nil map

An empty map is a different thing. It is initialized, holds no entries, and accepts writes. make(map[string]int) and the literal map[string]int{} both produce empty, non-nil maps. To tell nil from empty, compare with nil; len(m) == 0 is true for both and cannot distinguish them. The nil map guide covers this distinction and the safe-handling patterns below.

Nil maps show up in three places in real code:

  • A function that receives a map and writes to it panics if the caller passed nil. Check if m == nil before writing and assign an empty map.
  • A function returns a map that has no entries. Returning nil forces every caller to check for nil. Return an empty map instead so callers can write without a guard.
  • JSON unmarshalling leaves a map field nil when the JSON payload omits that field. After json.Unmarshal, check the field and initialize it before your code writes to it.
func addUser(users map[string]User, id string, u User) {
    if users == nil {
        users = make(map[string]User)
    }
    users[id] = u
}

var cfg struct {
    Labels map[string]string `json:"labels"`
}
json.Unmarshal(data, &cfg)
if cfg.Labels == nil {
    cfg.Labels = make(map[string]string)
}

The nil check costs almost nothing. One author’s benchmark measured under a nanosecond per check, so this defensive check is not a performance trade-off; it is just safer code. The exact cost varies by Go version and hardware, but a single comparison against nil is not where your program will spend time.

How maps grow, hold memory, and iterate

The standard Go compiler implements maps as hash tables. The runtime stores entries in buckets, and each bucket holds up to 8 key-value pairs. When a bucket fills, the runtime links an overflow bucket to it, so the busy bucket can hold more than 8 pairs, but the runtime looks up entries in it more slowly. The map internals writeup describes this layout in detail.

Maps grow when the load factor, the average number of entries per bucket, reaches 6.5. At that point the runtime doubles the number of buckets and rehashes entries incrementally, spreading the work across later operations instead of pausing for one large rehash. That is why add, lookup, and delete are average O(1). If many keys collide into the same buckets, lookups degrade toward O(n).

Two practical consequences follow.

Pre-allocate when you know the size. make(map[string]int, 1000) tells the runtime how many buckets to create up front, so the runtime does not trigger repeated map growth and rehashing while you populate it. For a map that will hold thousands of entries, the hint avoids unnecessary allocation and copying during construction.

Maps never shrink. delete removes entries from the table, but the allocated buckets stay in memory, and the map does not release them. If you delete most of the entries from a large map, the memory footprint stays roughly the same. To free that memory, build a new map, copy the entries you still need into it, and let the old map go out of scope. The map internals writeup notes this behavior as well.

Iteration order is deliberately unspecified. The runtime randomizes where iteration starts, so the order of for k, v := range m can differ between runs of the same program, and you cannot rely on it being insertion order. Code that assumes a stable order produces intermittent bugs. When you need deterministic order, collect the keys into a slice, sort the slice, then iterate over the sorted keys, as shown in this guide to iterating maps:

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

Concurrent access: mutex or sync.Map

A map is not safe for concurrent use. If two goroutines write to the same map at the same time, the runtime detects the race and panics with "concurrent map writes". A read that runs while another goroutine writes can trigger the same panic. The fix is to protect the map with a mutex. A sync.RWMutex is the usual choice for read-heavy access because it lets many goroutines read at once while still serializing writers:

var mu sync.RWMutex
cache := make(map[string]string)

func get(key string) string {
    mu.RLock()
    defer mu.RUnlock()
    return cache[key]
}

func set(key, value string) {
    mu.Lock()
    defer mu.Unlock()
    cache[key] = value
}

The standard library also provides sync.Map, a concurrent map that needs no external mutex. It is built for two specific access patterns: keys written once and read many times, and keys mostly accessed by different goroutines so there is little contention on any single key. It is not a drop-in replacement for a map guarded by a mutex. If the same keys are written and read frequently in a general-purpose cache, you can guard a regular map with sync.RWMutex; it is often just as fast and easier to reason about. A comparison of the two approaches shows that sync.Map is faster for those two access patterns and slower for other workloads.

More in Web development Back to top ↑