DEV Community

Cover image for Go Maps Iterate in Random Order on Purpose. Here Is How to Handle It
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Maps Iterate in Random Order on Purpose. Here Is How to Handle It


You've seen this failure. A Go test that passes on your machine,
passes in the pre-commit hook, passes forty times in a row. Then CI
goes red on an unrelated PR. You re-run the job and it goes green.
Nobody changed the code under test. The diff is a README typo.

The usual reflex is to blame flaky infrastructure. Most of the time
the infrastructure is fine. What you hit is Go doing exactly what it
promised: iterating a map in an order you are not allowed to depend
on, and picking a different order this run than last run.

The runtime randomizes on purpose

Range over a map twice and you can get two different orders inside
the same program:

package main

import "fmt"

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3}
    for k := range m {
        fmt.Print(k, " ")
    }
    fmt.Println()
    for k := range m {
        fmt.Print(k, " ")
    }
    fmt.Println()
}
Enter fullscreen mode Exit fullscreen mode

Run that a handful of times. The two lines rarely agree, and the
order shifts between runs. This is not a bug and it is not
undefined behavior in the C sense. It is a deliberate design choice.
The Go spec states the iteration order over maps is not specified
and is not guaranteed to be the same from one iteration to the next.
The Go 1 release notes go
further: the runtime randomizes map iteration order on purpose so
that code cannot quietly grow a dependency on it.

Under the hood, each range over a map starts at a randomly chosen
bucket and a random offset within that bucket. The randomness comes
from the runtime's fast PRNG, not from the hash of your keys. So the
order is not just unspecified in the docs. The runtime actively
scrambles it every time, which turns a latent assumption into a
visible failure fast.

That is the point. An order dependency that fails one run in fifty
is worse than one that fails every run, because the every-run
version gets caught before merge. Go chose the loud, early failure.

The bug this catches

Here is the code the randomization is trying to save you from:

func summary(counts map[string]int) string {
    var b strings.Builder
    for name, n := range counts {
        fmt.Fprintf(&b, "%s=%d;", name, n)
    }
    return b.String()
}
Enter fullscreen mode Exit fullscreen mode

Reasonable-looking function. It builds a string out of a map. The
problem is that the string it builds depends on iteration order, and
iteration order is random. Now the test:

func TestSummary(t *testing.T) {
    got := summary(map[string]int{"cpu": 3, "mem": 7})
    want := "cpu=3;mem=7;"
    if got != want {
        t.Fatalf("got %q want %q", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

Two keys. Two possible orders. The test passes about half the time.
With three keys it passes about a third of the time. This is the CI
flake, and it is not the infrastructure.

The same defect shows up anywhere map order leaks into an output
that something else compares: a cache key built by concatenating map
entries, an ETag, a signature over serialized fields, a golden-file
test. If two runs of the same input can produce two different bytes,
you have an order dependency hiding somewhere, and a map range is the
usual suspect.

Why it passed on your machine

Here is the trap that makes this hard to reason about. This prints
the same thing every single time:

m := map[string]int{"b": 2, "a": 1, "c": 3}
fmt.Println(m)
Enter fullscreen mode Exit fullscreen mode

Since Go 1.12, the fmt package sorts map keys before printing. So
fmt.Println(m), %v, and friends give you a stable, alphabetical
view of a map. encoding/json does the same: json.Marshal of a
map emits keys in sorted order, so marshaled output is deterministic
too.

That is convenient and it is also the reason the bug is confusing.
If you eyeballed the map with fmt.Println and it looked ordered,
you might conclude map iteration is ordered. It is not. The standard
library sorts for you at the print and marshal boundary. The moment
you range the map yourself and build output by hand, that courtesy
disappears and the raw random order comes through.

So the rule is narrow: fmt and json sort for you; your own
for range does not. Any output you assemble by ranging a map has
to sort explicitly.

The fix: sort the keys

Pull the keys out, sort them, then range the sorted slice. On Go
1.23+ this is one line thanks to the iterator-based maps.Keys and
slices.Sorted:

import (
    "fmt"
    "maps"
    "slices"
    "strings"
)

func summary(counts map[string]int) string {
    var b strings.Builder
    for _, name := range slices.Sorted(maps.Keys(counts)) {
        fmt.Fprintf(&b, "%s=%d;", name, counts[name])
    }
    return b.String()
}
Enter fullscreen mode Exit fullscreen mode

maps.Keys(counts) returns an iter.Seq[string]. slices.Sorted
drains that iterator into a slice and sorts it with the type's
natural order. You range the sorted slice and index back into the
map for each value. Output is now stable for any input, and the test
above passes every run.

If you are on an older Go, the pre-iterator version is the same idea
with a couple more lines:

keys := make([]string, 0, len(counts))
for k := range counts {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, name := range keys {
    fmt.Fprintf(&b, "%s=%d;", name, counts[name])
}
Enter fullscreen mode Exit fullscreen mode

Same result. slices.Sorted(maps.Keys(...)) is the modern spelling
and it reads better, so prefer it when your module targets 1.23.

Pin it with a test that cannot flake

A single-shot test can pass by luck. If you want the test to fail
when someone reintroduces the order dependency, run the function
many times and assert every result matches:

func TestSummaryStable(t *testing.T) {
    in := map[string]int{"cpu": 3, "mem": 7, "net": 1}
    want := summary(in)
    for i := 0; i < 100; i++ {
        if got := summary(in); got != want {
            t.Fatalf("run %d: got %q want %q", i, got, want)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Because the runtime re-randomizes each range, a hundred iterations
over a three-key map will hit multiple orderings. If summary still
leaked map order, this test would catch it almost every run instead
of one in three. Determinism becomes a property you can assert, not
a coincidence you hope holds.

When you actually want an order

Sorting keys gives you a deterministic order, alphabetical or
numeric. That is what you want for output, hashes, and tests. It is
not the same as insertion order. A Go map does not remember the
order you inserted keys, and no flag brings that back.

If you need insertion order, keep a slice of keys next to the map
and append on first insert, or reach for one of the ordered-map
generic packages. Either way, treat the map as an unordered lookup
table and store the order you care about explicitly. Do not ask the
map to remember something it was designed to forget.

What to do with this on Monday

Two greps on the codebase you already have.

  1. for ... range over a map whose body writes to a strings.Builder, a bytes.Buffer, a hash, or an append that feeds output. Each one is a candidate order dependency. Sort the keys first.
  2. Golden-file and equality tests that stringify a map by hand. If the expected value has a fixed field order, confirm the code sorts before serializing, or switch to json.Marshal, which sorts for you.

Random map iteration is a guardrail: it converts a silent assumption
into a fast, visible failure. Sort the keys at the boundary where
order matters, and the guardrail never trips.


Map internals, the runtime PRNG behind that randomized start, and
the iterator functions in maps and slices all get proper
treatment in The Complete Guide to Go Programming, if you want the
layer under this post. Hexagonal Architecture in Go is the
companion for keeping this kind of determinism at the right seam, so
sorting lives at your serialization boundary instead of leaking
through your domain code.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)