- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
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)
}
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))
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))
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
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}
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)
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())
})
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)
}
}
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
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)
})
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}
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:
- Per-type
MapKeys/MapValueshelpers. Replace withmaps.Keys+slices.Collectif you need the slice, or range the iterator directly if you do not. - Hand-rolled
cloneMap. Drop formaps.Clone. Remember it is shallow — if values are pointers or slices, your old helper was probably shallow too, but check. -
mergeMaps/mergeInto. Replace withmaps.Copy, orClone+Copyfor the non-mutating form. - The
for k, v := range; if cond delete(m, k)loop. Replace withmaps.DeleteFuncand the predicate becomes a testable value. -
reflect.DeepEqualon maps with comparable values. Replace withmaps.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".

Top comments (0)