Maps — Optimize the Code¶
Practice optimizing slow, inefficient, or memory-heavy Go map code.
How to Use¶
- Read the slow code and understand what it does
- Identify the performance bottleneck
- Write your optimized version
- Run
go test -bench=. -benchmemto compare
Difficulty Levels¶
| Level | Focus |
|---|---|
| 🟢 | Easy — obvious inefficiencies |
| 🟡 | Medium — algorithmic improvements |
| 🔴 | Hard — zero-allocation, concurrent patterns |
Categories¶
| Category | Icon |
|---|---|
| Memory | 📦 |
| CPU | ⚡ |
| Concurrency | 🔄 |
Exercise 1: Pre-size the Map 🟢 📦¶
What the code does: Builds a word frequency map from a large slice. The problem: Map grows dynamically causing many rehashes and allocations.
package main
func wordFreq(words []string) map[string]int {
freq := make(map[string]int) // no size hint
for _, w := range words {
freq[w]++
}
return freq
}
Benchmark baseline (1 million words):
Hint
`make(map[K]V, n)` accepts an optional capacity hint. Pass the length of the input slice — even if words repeat, you avoid most rehash events because the runtime pre-allocates enough buckets.Optimized Solution
**Why it is faster:** Go maps start with 8 buckets and double when the load factor (~0.8) is exceeded. Without a hint, a 1 million-element map rehashes roughly 17 times. With `len(words)` as the hint, the runtime allocates enough buckets upfront, reducing allocations by 60–80 %. **Benchmark after:**Exercise 2: Use struct{} for Sets 🟢 📦¶
What the code does: Builds a set of unique strings to check membership. The problem: Using map[string]bool wastes memory on the boolean value.
package main
func buildSet(items []string) map[string]bool {
set := make(map[string]bool, len(items))
for _, item := range items {
set[item] = true
}
return set
}
func contains(set map[string]bool, key string) bool {
return set[key]
}
Hint
`bool` takes 1 byte per entry and the runtime still stores it alongside the key. `struct{}` is a zero-size type — it allocates no additional memory.Optimized Solution
**Why it is faster:** `struct{}` has zero size; the compiler stores the "value" without any extra heap allocation. For 10 million entries the `bool` map uses ~80 MB while the `struct{}` map uses ~48 MB — roughly a 40 % reduction in value storage overhead.Exercise 3: Avoid Redundant Map Lookup in a Hot Loop 🟢 ⚡¶
What the code does: Increments a counter for each word in a large document. The problem: Each word performs two hash lookups — one read and one write.
package main
func countWords(words []string) map[string]int {
counts := make(map[string]int, len(words))
for _, w := range words {
if counts[w] > 0 {
counts[w] += 1
} else {
counts[w] = 1
}
}
return counts
}
Hint
The `if/else` branch performs two separate hash computations for the same key. The zero-value behavior of Go maps lets you collapse this into a single operation.Optimized Solution
**Why it is faster:** `counts[w]++` compiles to a single map-address computation followed by an in-place increment, while the `if/else` version forces two full hash computations per word. Over 10 million words this is 20–30 % faster and more idiomatic.Exercise 4: Replace Map with Slice for Dense Integer Keys 🟡 ⚡¶
What the code does: Counts occurrences of byte values (0–255) in a large byte slice. The problem: Map hash overhead dominates when keys are small dense integers.
package main
func byteFreq(data []byte) map[byte]int {
freq := make(map[byte]int, 256)
for _, b := range data {
freq[b]++
}
return freq
}
Hint
When keys are bounded small integers (0–255 for bytes, 0–N for enums), a plain array indexed by the integer gives O(1) access with no hashing, no collision resolution, and no allocator pressure.Optimized Solution
**Why it is faster:** - `[256]int` is 2 KB and lives on the stack — no heap allocation. - Array indexing is a single pointer arithmetic operation; map lookup involves hashing, bucket search, and a memory indirection. - Benchmark: map version ~450 ns/op, array version ~120 ns/op for 1 KB input. **Rule of thumb:** If the key space is at most 1 024 integers and dense, prefer a slice or fixed array over a map.Exercise 5: Cache the Map Lookup Result in a Local Variable 🟡 ⚡¶
What the code does: Updates multiple fields of a struct stored in a map inside a loop. The problem: Every access to registry[key] re-executes the full hash lookup.
package main
import "strings"
type Record struct {
Name string
Count int
Tags []string
}
func process(registry map[string]*Record, keys []string) {
for _, key := range keys {
registry[key].Count++
registry[key].Name = strings.ToUpper(registry[key].Name)
registry[key].Tags = append(registry[key].Tags, "processed")
}
}
Hint
Store the result of `registry[key]` in a local variable once per iteration and reuse it. Because the value is a pointer, mutations through the local variable still update the original struct in the map.Optimized Solution
package main
import "strings"
type Record struct {
Name string
Count int
Tags []string
}
func process(registry map[string]*Record, keys []string) {
for _, key := range keys {
r := registry[key] // one hash lookup per key
if r == nil {
continue
}
r.Count++
r.Name = strings.ToUpper(r.Name)
r.Tags = append(r.Tags, "processed")
}
}
Exercise 6: sync.Map vs RWMutex — Read-Heavy Workload 🔴 🔄¶
What the code does: A cache shared across many goroutines — mostly reads, occasional writes. The problem: Using sync.Mutex (exclusive lock) serializes all reads, killing concurrency.
package main
import "sync"
type Cache struct {
mu sync.Mutex
store map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.store[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = value
}
Hint
Compare two alternatives: `sync.RWMutex` (allows concurrent reads) and `sync.Map` (optimized for stable key sets with many readers). Choose based on your actual read/write ratio and whether you need `len()` or typed values.Optimized Solution
**Option A — RWMutex (best for mostly-read, occasional-write with typed values):**package main
import "sync"
type Cache struct {
mu sync.RWMutex
store map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // multiple goroutines can hold RLock simultaneously
defer c.mu.RUnlock()
v, ok := c.store[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = value
}
Exercise 7: Sharded Map to Reduce Lock Contention 🔴 🔄¶
What the code does: A global counter map updated by many concurrent goroutines. The problem: A single mutex becomes a bottleneck regardless of read/write mode.
package main
import "sync"
type GlobalCounter struct {
mu sync.RWMutex
m map[string]int64
}
func (g *GlobalCounter) Inc(key string) {
g.mu.Lock()
defer g.mu.Unlock()
g.m[key]++
}
func (g *GlobalCounter) Get(key string) int64 {
g.mu.RLock()
defer g.mu.RUnlock()
return g.m[key]
}
Hint
Split the map into N shards where each shard has its own independent mutex. Hash the key to select the shard. Contention drops by a factor of N because goroutines operating on different shards never block each other.Optimized Solution
package main
import (
"hash/fnv"
"sync"
)
const numShards = 32
type shard struct {
mu sync.RWMutex
m map[string]int64
}
type ShardedCounter struct {
shards [numShards]shard
}
func NewShardedCounter() *ShardedCounter {
sc := &ShardedCounter{}
for i := range sc.shards {
sc.shards[i].m = make(map[string]int64)
}
return sc
}
func (sc *ShardedCounter) shardFor(key string) *shard {
h := fnv.New32a()
h.Write([]byte(key))
return &sc.shards[h.Sum32()%numShards]
}
func (sc *ShardedCounter) Inc(key string) {
s := sc.shardFor(key)
s.mu.Lock()
s.m[key]++
s.mu.Unlock()
}
func (sc *ShardedCounter) Get(key string) int64 {
s := sc.shardFor(key)
s.mu.RLock()
defer s.mu.RUnlock()
return s.m[key]
}
Exercise 8: String Interning to Eliminate Key Allocations 🔴 📦¶
What the code does: Parses millions of log lines; each line starts with a repeated service name used as a map key. The problem: Every parsed service name is a new heap allocation even though there are only ~20 unique values.
package main
import "strings"
func parseLogs(lines []string) map[string]int {
counts := make(map[string]int, 256)
for _, line := range lines {
parts := strings.SplitN(line, " ", 2)
if len(parts) == 2 {
service := parts[0] // new string allocation every iteration
counts[service]++
}
}
return counts
}
Hint
Build an intern table: a `map[string]string` storing the canonical copy of each string. Before using a key, look it up in the intern table. If found, reuse the existing backing array (zero new allocation); if not, insert it once.Optimized Solution
package main
import "strings"
type Interner struct {
table map[string]string
}
func NewInterner() *Interner {
return &Interner{table: make(map[string]string, 64)}
}
// Intern returns the canonical copy of s, allocating only on first encounter.
func (in *Interner) Intern(s string) string {
if v, ok := in.table[s]; ok {
return v // reuse existing string, no allocation
}
in.table[s] = s
return s
}
func parseLogs(lines []string) map[string]int {
intern := NewInterner()
counts := make(map[string]int, 256)
for _, line := range lines {
parts := strings.SplitN(line, " ", 2)
if len(parts) == 2 {
service := intern.Intern(parts[0]) // canonical copy reused
counts[service]++
}
}
return counts
}
Exercise 9: Compact the Map After Mass Deletion 🟡 📦¶
What the code does: A session store that evicts expired sessions periodically. The problem: delete removes entries but the underlying bucket array never shrinks, so memory is never released.
package main
import "time"
type Session struct {
UserID string
ExpiresAt time.Time
}
type Store struct {
sessions map[string]Session
}
func (s *Store) Evict() {
now := time.Now()
for id, sess := range s.sessions {
if sess.ExpiresAt.Before(now) {
_ = id // BUG: does nothing; expired sessions stay in the map
}
}
}
Hint
Call `delete(map, key)` for each expired entry — that is safe to do inside a range loop in Go. For bulk evictions, follow up with a compaction step: copy all live entries into a fresh map to release the now-empty bucket array.Optimized Solution
package main
import "time"
type Session struct {
UserID string
ExpiresAt time.Time
}
type Store struct {
sessions map[string]Session
}
// Evict removes expired sessions. Safe to delete during range.
func (s *Store) Evict() {
now := time.Now()
for id, sess := range s.sessions {
if sess.ExpiresAt.Before(now) {
delete(s.sessions, id)
}
}
}
// Compact rebuilds the map to release bucket memory after mass eviction.
func (s *Store) Compact() {
fresh := make(map[string]Session, len(s.sessions))
for k, v := range s.sessions {
fresh[k] = v
}
s.sessions = fresh
}
Exercise 10: Avoid Interface Boxing in Map Values 🟡 📦¶
What the code does: Stores configuration values in a map[string]any to allow mixed types. The problem: Every primitive value is boxed into an interface{}, causing heap allocations.
package main
import "fmt"
func buildConfig() map[string]any {
cfg := map[string]any{
"timeout": 30,
"retries": 3,
"host": "localhost",
"debug": true,
}
return cfg
}
func printTimeout(cfg map[string]any) {
fmt.Println(cfg["timeout"].(int))
}
Hint
Define a concrete struct for your configuration. Struct field access is a direct memory offset — no boxing, no type assertion, no interface indirection. Reserve `map[string]any` for truly dynamic, schema-unknown data.Optimized Solution
**Why it is faster:** `any` boxing allocates each primitive on the heap (8–16 bytes per value plus GC metadata). A struct with the same fields packs everything into one contiguous block with no extra allocation. For hot-path config reads this reduces allocation from O(N fields) to O(1). **Keep `map[string]any` for:** JSON deserialization of unknown schemas, plugin systems where key names are not known at compile time.Exercise 11: O(N×M) Bulk Delete → O(M) Direct Delete 🟡 ⚡¶
What the code does: Removes a list of banned users from an active-users map. The problem: The outer loop iterates the entire users map for every banned name — O(N×M) complexity.
package main
func removeBanned(users map[string]bool, bannedList []string) {
for _, banned := range bannedList {
for user := range users { // scans ALL users for every banned name
if user == banned {
delete(users, user)
}
}
}
}
Hint
Map lookup is O(1). Iterate `bannedList` once and call `delete(users, banned)` directly — no inner loop needed.Optimized Solution
**Complexity improvement:** O(N×M) reduced to O(M), where N = number of active users and M = number of banned names. **Example numbers:** For 100 000 users and 500 banned names, the original performs 50 000 000 string comparisons. The optimized version performs 500 hash lookups. This is a 100 000× reduction in work for that ratio.Exercise 12: Zero-Value Append Trick — Eliminate Existence Check 🟡 ⚡¶
What the code does: Groups strings into a map[string][]string by key. The problem: The code checks for key existence before appending, performing two map lookups per item.
package main
func groupBy(items []string, keyFn func(string) string) map[string][]string {
groups := make(map[string][]string)
for _, item := range items {
key := keyFn(item)
if _, ok := groups[key]; !ok {
groups[key] = []string{} // unnecessary existence check
}
groups[key] = append(groups[key], item)
}
return groups
}
Hint
The zero value of `[]string` is `nil`. `append(nil, item)` works correctly and allocates a new backing array. The existence check is completely unnecessary — `append` handles the nil case automatically.Optimized Solution
package main
func groupBy(items []string, keyFn func(string) string) map[string][]string {
groups := make(map[string][]string, 16) // pre-size for expected groups
for _, item := range items {
key := keyFn(item)
groups[key] = append(groups[key], item) // single lookup; nil slice is fine
}
return groups
}
Summary Cheatsheet¶
| Optimization | Expected Gain | Difficulty |
|---|---|---|
Pre-size with make(map[K]V, n) | 40–80 % fewer allocs | 🟢 |
struct{} values for sets | ~40 % less memory | 🟢 |
Single counts[k]++ vs if/else | 20–30 % CPU | 🟢 |
| Array/slice for small integer keys | 3–4x CPU | 🟡 |
| Cache lookup in local variable | 2–3x for multi-field access | 🟡 |
sync.RWMutex for read-heavy caches | Nx concurrent readers | 🟡 |
| Sharded maps (N shards) | ~Nx write throughput | 🔴 |
| String interning for repeated keys | Eliminates per-key allocs | 🔴 |
Compact() after mass delete | Releases bucket memory | 🟡 |
Typed struct instead of map[string]any | Eliminates boxing allocs | 🟡 |
Direct delete(m, k) vs nested loop | O(N×M) to O(M) | 🟡 |
| Zero-value append trick | Removes existence check | 🟡 |