Go Maps, Properly: From Simple Lookups to Production Trade-offs

Maps are one of the first Go features that feel immediately useful.

You store a value under a key, read it back later, and move on.

That part is simple.

What gets missed is that maps usually show up in code paths that stop being “simple” very quickly. Request deduplication, counters, grouping, caching, membership checks, config overrides, in-memory indexes, connection registries, rate limit state. The syntax stays small, but the consequences do not.

This is where a lot of Go code goes wrong. Not because maps are hard, but because people treat them like generic storage instead of what they really are: a statement about access patterns, ownership, and state.

This article walks through maps from the basics to the production details that actually matter.

A map is usually about access pattern, not just storage

A map is not just a bag of values.

A map says: “I want to find something by key.”

That sounds obvious, but it is an important distinction. If you ignore it, you start using maps where a slice, a struct, or even a database query would express the intent better.

For example, say you import product data from different suppliers and want fast lookup by SKU.

type Product struct {
    SKU   string
    Name  string
    Price int
}

func buildProductIndex(products []Product) map[string]Product {
    index := make(map[string]Product, len(products))
    for _, product := range products {
        index[product.SKU] = product
    }
    return index
}

This is not just “store products somewhere.”

This is a deliberate shift from “iterate over all products” to “lookup product by SKU.”

That is what maps are for.

If your real need is ordered traversal, a slice may be the better structure. If your real need is a fixed schema, a struct may be better. If your real need is persistence and querying, the map may not belong in memory at all.

Maps are powerful because they are specific, not because they are flexible.

Reading missing keys returns the zero value

One of the nicest parts of maps in Go is that reading a missing key does not panic. You get the zero value for the value type.

That makes counting extremely natural:

func countEvents(events []string) map[string]int {
    counts := make(map[string]int)
    for _, event := range events {
        counts[event]++
    }
    return counts
}

If counts[event] does not exist yet, it behaves like 0, so incrementing works without ceremony.

This pattern shows up everywhere in real code:

  • counting failed payments by reason
  • counting requests by route
  • counting retries by job type
  • counting messages by recipient
  • counting inventory movements by warehouse

This is one of those places where Go’s zero-value design is genuinely practical rather than just minimal.

Zero values are convenient, but they can hide ambiguity

The zero value helps, but it can also hide bugs.

Suppose you store account balances in a map:

balances := map[string]int{
    "user-1": 0,
    "user-2": 150,
}

Now this:

balance := balances["user-3"]

returns 0.

But what does that mean?

Did the user exist with zero balance? Or did the user not exist at all?

You do not know.

That is when the comma-ok form matters:

balance, ok := balances["user-3"]
if !ok {
    return errors.New("user not found")
}

This is not a minor style preference. It changes business logic.

In real systems, zero is often meaningful:

  • discount percentage can be zero
  • retry count can be zero
  • stock can be zero
  • credit can be zero
  • timeout override can be zero

If zero is a valid value, you cannot let “missing” collapse into it by accident.

Maps work beautifully for grouping

Another very common use of maps is grouping related values under a shared key.

For example, grouping orders by customer:

type Order struct {
    ID         string
    CustomerID string
    Total      int
}

func groupOrdersByCustomer(orders []Order) map[string][]Order {
    grouped := make(map[string][]Order)
    for _, order := range orders {
        grouped[order.CustomerID] = append(grouped[order.CustomerID], order)
    }
    return grouped
}

This pattern is everywhere:

  • orders by customer
  • logs by request ID
  • errors by service
  • products by category
  • sessions by tenant
  • events by topic

Again, the zero value helps. A missing []Order behaves like a nil slice, and append still works.

That makes the code small, but the behavior is still precise.

map[K]bool versus map[K]struct{} is not just style

When people use maps as sets, the two common choices are:

  • map[string]bool
  • map[string]struct{}

Historically, map[K]struct{} was a natural choice for set-like usage because it expressed presence-only semantics and often came with memory advantages.

That memory assumption is weaker now.

Go 1.24 introduced a new Swiss Table-based map implementation in the runtime, and with that change, old “struct{} is always cheaper” folklore stopped being something you can safely repeat without qualification. There is even an open runtime issue showing cases such as map[int64]struct{} using 16 bytes per slot instead of the expected 8, due to internal layout and alignment behavior. That issue is currently tracked under the Go 1.27 milestone, so the intent is to improve it, but it should not be described as already solved or guaranteed.

So today, the stronger reason to choose map[K]struct{} is semantic clarity, not a blanket memory optimization claim.

Use map[K]bool when true and false are both meaningful states. Use map[K]struct{} when presence is the whole story.

For example:

func buildBlockedCountrySet(countries []string) map[string]struct{} {
    blocked := make(map[string]struct{}, len(countries))
    for _, country := range countries {
        blocked[country] = struct{}{}
    }
    return blocked
}

Then:

if _, ok := blocked[user.Country]; ok {
    return errors.New("country not allowed")
}

And if memory usage is the deciding factor, measure it on your current Go version instead of relying on old advice. Also note that the idea of making map[T]bool behave like map[T]struct{} for the same set-style use case was discussed and closed as not planned, so these two forms should not be treated as interchangeable implementation details.

In short: choose map[K]struct{} for meaning first, not folklore. If the difference matters, benchmark it.

Nil maps are readable, but not writable

This is one of the first real map footguns.

A nil map can be read from safely. It behaves like an empty map on reads.

But writing to a nil map panics.

var stock map[string]int

fmt.Println(stock["SKU-1"]) // 0

stock["SKU-1"] = 10 // panic

This matters more than people think because nil maps show up naturally in:

  • zero-value structs
  • optional fields
  • lazy initialization
  • decoded payloads
  • partially built configs

If the next operation may write, initialize the map.

stock := make(map[string]int)

Or lazily:

if stock == nil {
    stock = make(map[string]int)
}
stock["SKU-1"] = 10

Nil maps are fine when “empty and read-only” is acceptable. They are not fine when writes may happen later.

Maps are reference-like, so mutation crosses function boundaries

Maps are not deeply copied when passed into functions. If a function mutates the map, the caller sees it.

That is often useful:

func recordFailure(counts map[string]int, reason string) {
    counts[reason]++
}

But it also means maps create shared mutable state very easily.

That becomes risky when:

  • helper functions quietly mutate caller-owned state
  • test fixtures get reused and polluted
  • request-scoped maps are shared more widely than intended
  • multiple goroutines start touching the same map

For example:

func addDefaultHeaders(headers map[string]string) {
    headers["X-Service"] = "billing"
    headers["X-Version"] = "v1"
}

That may be fine if mutation is the contract.

It is a problem if the caller assumed the function was read-only.

If you need isolation, copy first:

func cloneStringMap(src map[string]string) map[string]string {
    dst := make(map[string]string, len(src))
    for k, v := range src {
        dst[k] = v
    }
    return dst
}

This is not clever code. That is exactly why it is good.

Map iteration order is not a contract

If you iterate over a map, do not depend on a stable order.

for sku, qty := range inventory {
    fmt.Println(sku, qty)
}

That is fine for internal processing.

It is not fine for anything where deterministic output matters:

  • API responses
  • tests
  • snapshots
  • audit logs
  • CSV exports
  • hashes and signatures

If order matters, collect keys and sort them:

func sortedKeys(m map[string]int) []string {
    keys := make([]string, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    return keys
}

Then:

for _, k := range sortedKeys(inventory) {
    fmt.Println(k, inventory[k])
}

A map gives you lookup, not order.

If order matters, model it explicitly.

Composite keys are often better than nested maps

Nested maps are tempting:

views := map[string]map[string]int{}

Maybe the outer key is tenant ID and the inner key is page ID.

This works, but nested maps add friction:

if views[tenantID] == nil {
    views[tenantID] = make(map[string]int)
}
views[tenantID][pageID]++

Sometimes that is the right structure.

But often a composite key is simpler:

type ViewKey struct {
    TenantID string
    PageID   string
}

func countViews(events []struct {
    TenantID string
    PageID   string
}) map[ViewKey]int {
    counts := make(map[ViewKey]int)
    for _, event := range events {
        key := ViewKey{
            TenantID: event.TenantID,
            PageID:   event.PageID,
        }
        counts[key]++
    }
    return counts
}

Why is this often better?

Because it keeps the state flat:

  • one map instead of many
  • no inner-map initialization
  • fewer nil checks
  • simpler reads
  • simpler tests
  • easier reasoning

Nested maps are not wrong. They are just often heavier than the problem needs.

Not every type can be a map key

Map keys must be comparable.

That includes:

  • strings
  • numbers
  • booleans
  • pointers
  • arrays of comparable elements
  • structs whose fields are all comparable

It does not include:

  • slices
  • maps
  • functions

That matters when people try to use dynamic structures as keys and then fight the type system instead of reading the signal.

For example, this is invalid:

map[[]string]int

If you want a key made from multiple pieces, a struct is often the clean answer:

type FlagKey struct {
    TenantID    string
    Environment string
    FlagName    string
}

That is usually much better than trying to serialize ad hoc values into a string key too early.

Maps are not safe for concurrent writes

This is one of the most important production realities.

A normal Go map is not safe for unsynchronized concurrent read/write access.

That makes naive code like this dangerous:

var requestCounts = make(map[string]int)

func recordRequest(route string) {
    requestCounts[route]++
}

Looks innocent. It is not.

This kind of thing appears in:

  • in-memory rate limiters
  • websocket connection registries
  • background caches
  • per-route counters
  • tenant session maps

If multiple goroutines touch the same map and writes are involved, add synchronization or redesign the ownership model.

A plain mutex is often enough:

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

func NewSafeCounter() *SafeCounter {
    return &SafeCounter{
        counts: make(map[string]int),
    }
}

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

This is where many engineers overcomplicate too early.

Start with correctness. Fancy comes later.

sync.Map is not the default answer

Once people learn that normal maps are unsafe for concurrent writes, they often jump too far in the other direction and reach for sync.Map everywhere.

That is usually a mistake.

sync.Map has valid use cases, but it is not “regular map, but better.”

For ordinary application state, a normal map with a mutex is often:

  • clearer
  • easier to type-check around
  • easier to reason about
  • easier to evolve with business logic

Use sync.Map when its access pattern genuinely matches the workload. Do not use it as a reflex.

Map of values versus map of pointers

This is another choice that looks small but changes semantics a lot.

Suppose you store users in a map.

type User struct {
    ID    string
    Email string
    Admin bool
}

You can store values:

users := map[string]User{}

Or pointers:

users := map[string]*User{}

These are not interchangeable.

With map[string]User, reading gives you a copy of the struct value.

user := users["u-1"]
user.Admin = true

That only changes the local copy. If you want the map entry changed, you must write it back:

user := users["u-1"]
user.Admin = true
users["u-1"] = user

With map[string]*User, you mutate through the pointer:

users["u-1"].Admin = true

That is convenient, but it also expands the mutation surface. More code can now mutate shared state indirectly.

Use values when you want tighter ownership and less accidental sharing. Use pointers when shared mutation is intentional or copying is undesirable.

Do not choose based on habit. Choose based on mutation model.

Deleting from a map is easy, but meaning still matters

Deleting a key is simple:

delete(cache, key)

The language part is easy.

The system meaning is harder.

In real systems, deletion can mean different things:

  • invalidate cached value
  • remove disconnected client
  • forget retry marker
  • clear session state
  • drop stale configuration
  • force recomputation later

Those are not the same operation semantically, even if the code is identical.

The map API is small. Your system semantics still need to be precise.

The deeper point: maps usually mark the start of stateful code

This is the real takeaway.

Maps look lightweight because the syntax is lightweight.

But once a map enters the picture, one or more of these usually become true:

  • state is accumulating
  • identity now matters
  • missing versus zero matters
  • mutation matters
  • ownership matters
  • concurrency may matter
  • ordering may be gone
  • performance trade-offs may be encoded into the structure itself

That is why map-heavy code deserves more thought than it often gets.

A map is rarely “just storage.”

It is usually a decision about how the program thinks.

Practical rules that hold up in real code

A few rules age well:

Use a map when lookup by key is truly the point. Do not use a map just because it feels flexible.

Use comma-ok when zero is a meaningful value. Do not let missing and zero collapse into each other.

Use map[K]struct{} when you mean membership only. Use map[K]bool when true and false both carry meaning.

Do not rely on map iteration order. Sort keys when stable output matters.

Prefer composite struct keys over nested maps when the state can stay flat. Flat state is often easier to reason about.

Do not share writable maps across goroutines without synchronization. Correctness first.

Be explicit about whether you are storing values or pointers. That is not a cosmetic choice.

If you care about memory or performance, measure on your Go version. Do not build confidence on folklore.

Closing

Maps are one of Go’s simplest features syntactically.

They are also one of the easiest places to smuggle in ambiguity, shared mutable state, concurrency bugs, and weak assumptions.

Used well, maps make code direct and efficient.

Used lazily, they make code look simple while hiding important behavior.

That is why the right question is not just “can I use a map here?”

It is:

  • What is the access pattern?
  • What are the mutation rules?
  • What does missing mean?
  • Does order matter?
  • Who owns this state?
  • Will multiple goroutines touch it?
  • Am I choosing this because it fits, or because it is easy?

That is the difference between map usage that stays clean and map usage that becomes a quiet source of bugs.