Go Maps: Key-Value Data Structures

Maps are Go's built-in hash table implementation, providing fast key-value lookups with O(1) average time complexity. They're the go-to data structure when you need to associate unique keys with...

Key Insights

  • Maps in Go are reference types with O(1) average lookup time, but they require comparable key types and aren’t safe for concurrent access without synchronization
  • The “comma ok” idiom (value, ok := map[key]) is essential for distinguishing between a missing key and a zero value, preventing subtle bugs in production code
  • Pre-allocating maps with capacity hints can reduce memory allocations by up to 50% in high-throughput scenarios, but only matters when you know the approximate size upfront

Introduction to Maps in Go

Maps are Go’s built-in hash table implementation, providing fast key-value lookups with O(1) average time complexity. They’re the go-to data structure when you need to associate unique keys with values, whether you’re building a cache, counting occurrences, or implementing lookup tables.

Use maps when you need fast, arbitrary key access. Use slices when you need ordered data or sequential access. Use structs when you have a fixed set of named fields. Maps shine in scenarios like request routing, configuration management, and any situation where you’re asking “does this key exist?” or “what’s the value for this key?”

// Basic map declaration and initialization
var userAges map[string]int              // nil map, can't add elements
scores := make(map[string]int)           // empty map, ready to use
capitals := map[string]string{           // map literal with initial data
    "France": "Paris",
    "Japan":  "Tokyo",
}

Creating and Initializing Maps

Go provides three ways to create maps, each with different behavior and use cases.

package main

import "fmt"

func main() {
    // Method 1: Using make() - creates an empty, initialized map
    users := make(map[int]string)
    users[1] = "Alice"  // Works fine
    
    // Method 2: Using make() with capacity hint
    largeMap := make(map[string]int, 1000)  // Hint: expecting ~1000 entries
    
    // Method 3: Map literal - initialize with data
    config := map[string]string{
        "host": "localhost",
        "port": "8080",
    }
    
    // Method 4: Var declaration - creates a nil map
    var nilMap map[string]int
    // nilMap[1] = 100  // Runtime panic: assignment to entry in nil map
    
    fmt.Println(users, config)
}

The capacity hint in make(map[K]V, capacity) doesn’t limit the map size—it’s an optimization hint. If you know you’ll store 10,000 entries, specifying that capacity upfront reduces memory reallocation as the map grows.

Nil maps vs empty maps: This distinction trips up many developers. A nil map can be read from (always returns zero values) but panics on writes. An empty map created with make() or {} is fully functional.

var nilMap map[string]int
emptyMap := make(map[string]int)

// Reading works for both
fmt.Println(nilMap["key"])    // 0 (zero value)
fmt.Println(emptyMap["key"])  // 0 (zero value)

// Writing only works for initialized maps
// nilMap["key"] = 1    // PANIC!
emptyMap["key"] = 1     // Works

Basic Map Operations

Maps support four fundamental operations: create, read, update, and delete (CRUD).

package main

import "fmt"

func main() {
    inventory := make(map[string]int)
    
    // Create/Update: Same syntax for both
    inventory["apples"] = 50
    inventory["oranges"] = 30
    
    // Read: Direct access
    appleCount := inventory["apples"]
    fmt.Println("Apples:", appleCount)  // 50
    
    // Update: Assign to existing key
    inventory["apples"] = 45
    
    // Delete: Use built-in delete function
    delete(inventory, "oranges")
    
    // Safe deletion: delete() on non-existent key is no-op
    delete(inventory, "bananas")  // Doesn't panic
}

The “comma ok” idiom is critical for production code. When you access a map, Go returns the zero value if the key doesn’t exist. This creates ambiguity: did we get zero because the key is missing, or because the value is actually zero?

package main

import "fmt"

func main() {
    scores := map[string]int{
        "Alice": 100,
        "Bob":   0,  // Explicit zero score
    }
    
    // Ambiguous: Can't tell if Bob exists with score 0, or doesn't exist
    bobScore := scores["Bob"]
    fmt.Println(bobScore)  // 0 - but why?
    
    // Comma ok idiom: Get value AND existence check
    if score, exists := scores["Bob"]; exists {
        fmt.Printf("Bob's score: %d\n", score)
    } else {
        fmt.Println("Bob not found")
    }
    
    // Check for non-existent key
    if score, exists := scores["Charlie"]; exists {
        fmt.Printf("Charlie's score: %d\n", score)
    } else {
        fmt.Println("Charlie not found")  // This executes
    }
}

Iterating Over Maps

Go’s range keyword iterates over maps, but with a critical caveat: iteration order is randomized. This is intentional—it prevents code from depending on iteration order, which isn’t guaranteed in hash tables.

package main

import "fmt"

func main() {
    prices := map[string]float64{
        "coffee": 2.50,
        "tea":    2.00,
        "juice":  3.50,
    }
    
    // Iterate over keys and values
    for item, price := range prices {
        fmt.Printf("%s: $%.2f\n", item, price)
    }
    // Output order changes between runs!
    
    // Iterate over keys only
    for item := range prices {
        fmt.Println("Item:", item)
    }
    
    // Iterate over values only (less common)
    for _, price := range prices {
        fmt.Printf("$%.2f\n", price)
    }
}

If you need deterministic iteration order, extract keys to a slice and sort them:

import (
    "fmt"
    "sort"
)

func main() {
    data := map[string]int{"c": 3, "a": 1, "b": 2}
    
    keys := make([]string, 0, len(data))
    for k := range data {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    
    for _, k := range keys {
        fmt.Printf("%s: %d\n", k, data[k])
    }
}

Map Constraints and Best Practices

Key types must be comparable: Go can use == to compare them. This includes strings, numeric types, booleans, pointers, arrays (if element type is comparable), and structs (if all fields are comparable). Slices, maps, and functions cannot be keys.

package main

func main() {
    // Valid key types
    var intKeys map[int]string
    var stringKeys map[string]int
    var structKeys map[struct{ x, y int }]bool
    
    // Invalid key types - won't compile
    // var sliceKeys map[[]int]string      // Error: invalid map key type
    // var mapKeys map[map[string]int]bool // Error: invalid map key type
    // var funcKeys map[func()]string      // Error: invalid map key type
}

Maps are reference types: When you assign a map to another variable or pass it to a function, you’re copying a reference, not the data. Modifications through any reference affect the same underlying map.

package main

import "fmt"

func modifyMap(m map[string]int) {
    m["modified"] = 999
}

func main() {
    original := map[string]int{"value": 1}
    copy := original  // Both point to same underlying map
    
    copy["value"] = 2
    fmt.Println(original["value"])  // 2 - original is modified!
    
    modifyMap(original)
    fmt.Println(original["modified"])  // 999 - modified in function
}

Maps are not safe for concurrent access: Reading and writing a map simultaneously from multiple goroutines causes race conditions and panics. Use sync.Mutex or sync.RWMutex for protection, or use sync.Map for specific concurrent scenarios.

Advanced Patterns

Nested maps model hierarchical data like configuration trees or multi-level caches:

package main

import "fmt"

func main() {
    // User preferences by region and user ID
    preferences := map[string]map[int]string{
        "us": {
            1: "dark_mode",
            2: "light_mode",
        },
        "eu": {
            1: "auto_mode",
        },
    }
    
    // Safe nested access
    if regionPrefs, ok := preferences["us"]; ok {
        if pref, ok := regionPrefs[1]; ok {
            fmt.Println("US user 1 prefers:", pref)
        }
    }
    
    // Initialize nested map safely
    region := "asia"
    if preferences[region] == nil {
        preferences[region] = make(map[int]string)
    }
    preferences[region][1] = "compact_mode"
}

Concurrent access requires synchronization. For simple cases, use a mutex:

package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    count map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count[key]
}

func main() {
    counter := SafeCounter{count: make(map[string]int)}
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Inc("requests")
        }()
    }
    
    wg.Wait()
    fmt.Println("Total requests:", counter.Value("requests"))
}

For read-heavy workloads with infrequent writes, sync.RWMutex allows multiple concurrent readers. For specific patterns like append-only or mostly-read scenarios, consider sync.Map, though it has performance tradeoffs.

Performance Considerations

Maps have O(1) average lookup, insert, and delete time, but “average” matters. With poor hash distribution or many collisions, performance degrades toward O(n). Go’s map implementation handles this well for typical use cases.

Pre-allocation helps when you know the size:

package main

import (
    "testing"
)

func BenchmarkMapDynamic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        m := make(map[int]int)
        for j := 0; j < 10000; j++ {
            m[j] = j
        }
    }
}

func BenchmarkMapPreallocated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        m := make(map[int]int, 10000)
        for j := 0; j < 10000; j++ {
            m[j] = j
        }
    }
}

Pre-allocation reduces allocations and memory copying as the map grows. On my machine, pre-allocated maps are ~30-40% faster for bulk inserts. However, don’t prematurely optimize—profile first. For small maps (< 100 entries), the difference is negligible.

Memory considerations: Maps don’t shrink automatically. If you populate a map with millions of entries then delete most of them, the memory remains allocated. For long-running services with fluctuating map sizes, consider recreating maps periodically or using alternative data structures.

Maps are powerful, flexible, and efficient for most use cases. Master the comma-ok idiom, respect their concurrent access constraints, and use them confidently as your primary key-value data structure in Go.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.