DEV Community

Cover image for The maps Stdlib in Go 1.21+: 8 Functions That Replace Half Your Map Helpers
Gabriel Anhaia
Gabriel Anhaia

Posted on

The maps Stdlib in Go 1.21+: 8 Functions That Replace Half Your Map Helpers


Open the same internal/util/ folder you cleaned out when the slices package landed. The map version of that folder is still there: MapKeys[string, int], mergeMaps, a cloneMap someone wrote because they got bitten by reference semantics once, and a mapsEqual that does the obvious thing in a slightly different way each time it appears.

Most of that file can go too. The maps package shipped in Go 1.21 alongside slices, and Go 1.23 reshaped two of its most-used functions into iterators. Eight functions cover what your helpers were doing. The migration is the same shape as the slices one: import "maps", delete the helper, replace the call. One trap catches everyone the first time.

maps.Keys does not return []K anymore. It returns an iter.Seq[K]. If you wrote keys := maps.Keys(m) and tried to range over it like a slice on Go 1.23+, you got a type that does not behave the way you remembered. The other seven functions are clean wins.

Versions, briefly

Keys, Values, Clone, Copy, DeleteFunc, Equal, and EqualFunc shipped in Go 1.21 (August 2023). All, Collect, and Insert arrived in Go 1.23 with range-over-func, and the same release reshaped Keys and Values into iterators. That was a breaking change in return type from the 1.21 signature. If you are still on 1.20, the golang.org/x/exp/maps shim has the 1.21 shapes.

1. maps.Keys — and the iter.Seq trap

The pre-1.21 helper, written once per type:

func MapKeys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}
Enter fullscreen mode Exit fullscreen mode

In Go 1.21, maps.Keys returned []K and was a near drop-in replacement. In Go 1.23, the signature changed to iter.Seq[K]. That signature change is what breaks 1.21 code on 1.23+.

After (Go 1.23+):

import (
    "maps"
    "slices"
)

m := map[string]int{"a": 1, "b": 2}

// What you probably wanted: the slice
keys := slices.Collect(maps.Keys(m))
// keys is []string, order undefined

// What you might use instead: range directly
for k := range maps.Keys(m) {
    use(k)
}
Enter fullscreen mode Exit fullscreen mode

If your old code was keys := maps.Keys(m) followed by for _, k := range keys, it does not compile on 1.23+ with the new signature. You either wrap in slices.Collect to get the slice back, or you switch to ranging over the iterator directly with for k := range maps.Keys(m).

The iterator form is usually cheaper because nothing has to allocate the slice when all you wanted was to iterate. The slice form is what you need when the order matters and you are about to sort, or when you are returning a stable snapshot to a caller.

A common pattern after the change:

keys := slices.Sorted(maps.Keys(m))
Enter fullscreen mode Exit fullscreen mode

slices.Sorted takes the iterator, drains it into a slice, and sorts in one call. That is shorter than the old keys := maps.Keys(m); slices.Sort(keys) pair, and it makes the "I want the keys in a defined order" intent obvious at the call site.

2. maps.Values — same shape, same trap

Values mirrors Keys. Returned []V in 1.21, returns iter.Seq[V] in 1.23+. The replacement helper looked like the MapKeys above with m[k] instead of k.

prices := map[string]float64{"apple": 1.20, "pear": 0.95, "kiwi": 0.40}

// Iterate without allocating a slice
total := 0.0
for v := range maps.Values(prices) {
    total += v
}

// Or collect into a slice
all := slices.Collect(maps.Values(prices))
Enter fullscreen mode Exit fullscreen mode

Same upgrade path: wrap in slices.Collect if you actually need the slice, range directly if you do not. If you find yourself writing slices.Collect(maps.Values(m)) and then immediately ranging the slice, drop the Collect and range the iterator. One allocation gone, identical behavior.

3. maps.Clone — shallow copy without writing it once a year

src := map[string]int{"a": 1, "b": 2}
dst := maps.Clone(src)
dst["a"] = 99
// src["a"] is still 1
Enter fullscreen mode Exit fullscreen mode

Clone returns a new map of the same type with the same keys and values. It is shallow. If your values are pointers, slices, or maps, the clone shares them with the original. That is the same semantic as copy for slices, and for the same reason: a deep clone is a different problem with a different cost.

The hand-rolled version was a four-line loop. Two reasons to delete it: clarity at the call site, and the runtime can size the new map's hash table from len(src) once instead of letting it grow incrementally. On large maps that detail measures.

If you need a deep clone for a map[string]*User, write the per-value copy yourself. maps.Clone will not do it, by design.

4. maps.Copy — merge src into dst

Where Clone builds a new map, Copy mutates an existing one:

dst := map[string]int{"a": 1, "b": 2}
src := map[string]int{"b": 20, "c": 30}

maps.Copy(dst, src)
// dst is {"a": 1, "b": 20, "c": 30}
Enter fullscreen mode Exit fullscreen mode

src overwrites dst on collisions. The signature is Copy[M1 ~map[K]V, M2 ~map[K]V](dst M1, src M2), with the ~map[K]V constraint that lets it accept named map types like type Headers map[string]string without a conversion.

The pre-1.21 version of this was mergeMaps, written by every team, rewritten as a generic version after 1.18 because the old interface{} version had aged badly. maps.Copy is the generic version, written once and tested by the Go team.

Two callers I see often:

// Defaults pattern
config := maps.Clone(defaults)
maps.Copy(config, userOverrides)

// Per-request header merge
headers := maps.Clone(globalHeaders)
maps.Copy(headers, requestHeaders)
Enter fullscreen mode Exit fullscreen mode

Clone then Copy is the "merge without mutating either input" idiom. It is two lines and it allocates once.

5. maps.DeleteFunc — predicate-based pruning

The delete builtin removes one key. DeleteFunc removes every key the predicate returns true for, in one pass:

sessions := map[string]Session{...}

maps.DeleteFunc(sessions, func(k string, v Session) bool {
    return v.ExpiresAt.Before(time.Now())
})
Enter fullscreen mode Exit fullscreen mode

The hand-rolled version was the small mistake everyone made first:

// Wrong: modifying a map during range is allowed for delete,
// but the loop is verbose and easy to break by adding a condition.
for k, v := range sessions {
    if v.ExpiresAt.Before(time.Now()) {
        delete(sessions, k)
    }
}
Enter fullscreen mode Exit fullscreen mode

The for k, v := range; delete loop is technically correct. Go's spec allows deleting the current key during a range. DeleteFunc reads better, and the predicate is a value you can test in isolation.

Note the predicate signature takes both k and v. If you only care about the key, ignore the value. If you only care about the value (the common case for cache eviction), ignore the key.

6. maps.Equal — deep-ish equality for comparable values

a := map[string]int{"x": 1, "y": 2}
b := map[string]int{"y": 2, "x": 1}

maps.Equal(a, b) // true — order does not matter
Enter fullscreen mode Exit fullscreen mode

Same length, same keys, same values for each key. Values must be comparable (so int, string, bool, time.Time, comparable structs; not slices, maps, or functions).

The pre-maps version was either a hand-rolled loop, a reflect.DeepEqual, or a test helper that diffed two maps for nicer error messages. reflect.DeepEqual works on maps but it pays the reflection tax: slow, allocating, no compile-time type guarantee. maps.Equal is generic and type-checked. The version in your test assertions is now the same one in your production code.

For non-comparable values, the next function picks up.

7. maps.EqualFunc — when the values are not directly comparable

type Order struct {
    Items []string // a slice — Order is not comparable
    Total float64
}

a := map[string]Order{...}
b := map[string]Order{...}

equal := maps.EqualFunc(a, b, func(x, y Order) bool {
    return x.Total == y.Total && slices.Equal(x.Items, y.Items)
})
Enter fullscreen mode Exit fullscreen mode

Same shape as slices.EqualFunc. You supply the value-equality predicate; the function handles the keyset comparison and the structural part. If your values are pointers and you want pointer-identity equality, the predicate is func(a, b *T) bool { return a == b }. If you want structural equality, it is whatever your type's Equal method returns.

Worth knowing: EqualFunc calls your predicate once per key, only when both maps have the key. If one map is missing a key the other has, it short-circuits to false without calling the predicate at all.

8. maps.All and maps.Collect — the iter.Seq2 round trip (1.23+)

All turns a map into iter.Seq2[K, V]. Collect turns iter.Seq2[K, V] back into a map. Together they make maps a first-class citizen of the iterator pipelines that 1.23 introduced.

src := map[string]int{"a": 1, "b": 2, "c": 3}

// Pipeline: filter, then collect into a new map
filtered := maps.Collect(
    func(yield func(string, int) bool) {
        for k, v := range maps.All(src) {
            if v > 1 {
                if !yield(k, v) {
                    return
                }
            }
        }
    },
)
// filtered is {"b": 2, "c": 3}
Enter fullscreen mode Exit fullscreen mode

In practice, you wrap that closure in a small Filter helper and the call site reads as one line. The standard library does not ship a generic map filter, for the same reason it does not ship a slice filter. Most teams write a tiny xmaps.Filter once and reuse it. The point of All/Collect is that the map sits inside the same iterator algebra as everything else: slices.Collect, maps.Keys, custom producers, bufio.Scanner-style consumers. One shape, one set of helpers.

maps.Insert, also new in 1.23, is the mutating sibling of Collect: it drains an iter.Seq2[K, V] into an existing map, the way maps.Copy drains another map. Use it when you have an iterator from somewhere (a paginated API, a parsed CSV) and you want the result merged into a config map you already own.

What to delete from your codebase tomorrow

In rough order of how often it pays off:

  1. Per-type MapKeys/MapValues helpers. Replace with maps.Keys + slices.Collect if you need the slice, or range the iterator directly if you do not.
  2. Hand-rolled cloneMap. Drop for maps.Clone. Remember it is shallow — if values are pointers or slices, your old helper was probably shallow too, but check.
  3. mergeMaps / mergeInto. Replace with maps.Copy, or Clone+Copy for the non-mutating form.
  4. The for k, v := range; if cond delete(m, k) loop. Replace with maps.DeleteFunc and the predicate becomes a testable value.
  5. reflect.DeepEqual on maps with comparable values. Replace with maps.Equal.

Run gopls after the migration; it flags most of these.

The 1.23 reshaping of Keys and Values into iterators is the one upgrade that needs care. It is a return-type break: your old code stops compiling, which is the loudest possible warning. Wrap in slices.Collect when you need the slice, range directly when you do not, and the package does the rest.


If this was useful

If the map patterns above were the kind of thing you wished someone had walked you through in your first year of Go, The Complete Guide to Go Programming covers the language at the same level — what the spec actually says, what the runtime actually does, and which habits from other languages do not translate. The hexagonal-architecture book is the next step, for when "where does this map helper live" stops being a small question and becomes "which side of the port owns this".

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

Top comments (0)