Accepted proposal: grow an existing map with maps.Grow
Liz Fong-Jones’s proposal to add maps.Grow was accepted on October 29. It gives you a
way to make room in a map that already exists, much like make(M, n) does for a new one.
A Go map grows on its own as you add entries. Each time it runs out of room, the runtime
allocates a larger hash table, rehashes the entries into it, and leaves the old table for
the garbage collector. A loop that adds thousands of entries can trigger this many times,
and each time costs CPU and creates more garbage. If you know the final size when you create
the map, you can pass it to make and skip all that extra work.
But that only works if you’re the one creating the map. If someone else created it and you’re only adding entries, you’re stuck with however much room it has.
net/http/httputil.ReverseProxy hits this on every request. It copies the upstream response
headers into the client’s response:
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}The proxy calls this as copyHeader(rw.Header(), res.Header). rw is the
http.ResponseWriter, and rw.Header() returns the header map that the server will write
back to the client. The server created that map before the proxy ever saw it. The proxy
knows how many headers are coming but can’t pass that number to the map, so the map may grow
several times inside this loop.
The usual workaround is to build a bigger map yourself and swap it in. libhoney-go does
this. It keeps an event’s fields in a map and hands that map out through a Fields()
method. A simplified version looks like this:
type fieldHolder struct {
data map[string]any
}
// Fields returns a reference to the event's field map.
func (f *fieldHolder) Fields() map[string]any {
return f.data
}
func (f *fieldHolder) AddFields(src map[string]any) {
if len(src) > len(f.data) {
m := make(map[string]any, len(f.data)+len(src))
maps.Copy(m, f.data) // (1)
maps.Copy(m, src)
f.data = m // (2)
return
}
maps.Copy(f.data, src)
}Here:
- (1) every existing field gets copied again just to move it into the bigger map
- (2)
f.datanow points to a new map, so anyone who calledFields()earlier still holds the old one and won’t see the new fields
Even with the extra copy, that change cut CPU use by about 5% in production. Copying everything once was cheaper than letting the map grow step by step.
With maps.Grow, you can skip all those shenanigans:
// Grow increases m's capacity, if necessary, so that at least n more
// entries can be added without further growth in the typical case, as if
// m had been created with make(M, len(m)+n). Grow never shrinks m.
func Grow[M ~map[K]V, K comparable, V any](m M, n int)The proxy can make room before the loop starts:
func copyHeader(dst, src http.Header) {
maps.Grow(dst, len(src))
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}And libhoney can grow f.data in place. Callers who got the map from Fields() still hold
the same map, so they see the new fields:
func (f *fieldHolder) AddFields(src map[string]any) {
maps.Grow(f.data, len(src))
maps.Copy(f.data, src)
}If you’ve used slices.Grow, you might expect maps.Grow to return the grown map. It
doesn’t, and the reason is how Go stores slices and maps:
- A slice is a small slice struct. It holds a pointer to an array, a length, and a
capacity. When
slices.Growneeds more room, it makes a bigger array and copies the elements into it. Your old slice still points to the old array. That’s whyslices.Growreturns a new slice and you writes = slices.Grow(s, n). - A map is a pointer to a maps.Map struct in the runtime. When
maps.Growneeds more room, it puts a larger hash table behind that pointer. The pointer itself doesn’t change, so every variable holding the map sees the new table. There’s nothing to return.
A nil map is a nil pointer with no table behind it, so Grow panics if you ask it to add
room:
var m map[string]int
maps.Grow(m, 0) // fine, nothing to add
maps.Grow(m, 10) // panic: assignment to entry in nil map
maps.Grow(m, -1) // panics, n is negativeIf you don’t have a map yet, use make.
The n you pass is a hint, just like the size you give make. The doc comment calls Grow
best effort, so the map may still grow later. You also can’t ask a map for its capacity. An
earlier proposal to allow cap on maps was declined.
Note
With a slice, cap(s) tells you exactly how far you can append before a new array gets
allocated. A map can’t tell you that. Where an entry lands depends on its hash, and the
runtime grows the map before every slot is full. So cap(m) could only be a rough guess.
That’s why Grow takes a hint but never reports a number back.
The proposal also changes maps.Copy.
Important
maps.Copy(dst, src) will now make room in dst for len(src) entries before it copies
anything. That room always gets used, because dst holds at least len(src) entries
after the copy. Code that already calls maps.Copy gets faster without any change. In the
benchmarks, copying into an empty or small map took about half the time on an AMD 5600X
and allocated about half as much memory.
That only covers copying one map into another. The header loop above calls Header.Add
instead, so it still needs its own maps.Grow call. So does code that fills a map from a
slice:
func index(dst map[string]User, users []User) {
maps.Grow(dst, len(users))
for _, u := range users {
dst[u.ID] = u
}
}If most of those users are already in dst, the extra room goes unused and you’ve spent
memory for nothing.
The implementation, the Copy change, and a follow-up that uses maps.Grow in
httputil and cgi are all still under review. The change is expected to land in Go 1.28.