Safe Concurrent Slices and Maps in Go Patterns That Work
Collect results from goroutines into a slice or map safely: an index per worker, a mutex wrapper, a collector channel and copy on write, with a test.

Starting several goroutines and collecting their results into a slice or a map is the first concurrent job most programs do, and it is where the first race appears. results = append(results, r) inside a goroutine looks harmless. It is a data race, and depending on timing it loses results, corrupts the slice header or, for maps, kills the process. The fix is not one thing; it depends on how many results you expect, whether order matters, and who reads the data while it is being built.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article lists four patterns that work, with a runnable example of each, and a test that proves they do. It is a practical sequel to data races and the concurrent map crashes, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.
What goes wrong
For the record, here are the two unsafe forms. Both compile and both are wrong:
var results []Result // shared
for _, job := range jobs {
go func() { results = append(results, run(job)) }() // race: header read-modify-write
}
seen := map[string]bool{} // shared
for _, job := range jobs {
go func() { seen[job.Key] = true }() // race: may throw "concurrent map writes"
}A slice is a three-word header. append reads it, may allocate, writes an element and stores a new header, and two goroutines doing this at once lose elements or overwrite each other. A map has an internal structure that cannot be modified from two places at once. The sections below replace each with something safe.
Pattern 1: one index per goroutine
When you know how many results there will be, allocate the slice up front and let each goroutine write only its own element. Distinct elements of a slice are distinct memory locations, so there is no race, and no synchronization is needed except a wait for completion:
package main
import (
"fmt"
"strings"
"sync"
)
func main() {
words := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
upper := make([]string, len(words)) // preallocated: one slot per input
var wg sync.WaitGroup
for i, w := range words {
wg.Go(func() {
upper[i] = strings.ToUpper(w) // each goroutine owns exactly index i
})
}
wg.Wait()
fmt.Println(upper)
}[ALPHA BETA GAMMA DELTA EPSILON]The output order matches the input order, which is a bonus: results are positional, not in order of completion. This is the pattern to reach for first. It has no lock, no channel and no contention, and it costs one allocation. Its limits are that the count has to be known in advance, and that the slots must really be disjoint. A bug in the index arithmetic is a race the compiler cannot see, so test it under -race (confinement and ownership shows what that failure looks like).
Pattern 2: a mutex behind methods
When goroutines add to a collection whose size is not known, or when a map is shared by many callers, put the lock and the data in one type and make the type the only way in:
package main
import (
"fmt"
"sort"
"sync"
"sync/atomic"
)
// Set is a concurrency-safe set of strings.
type Set struct {
mu sync.Mutex
m map[string]struct{}
}
func NewSet() *Set { return &Set{m: make(map[string]struct{})} }
// Add reports whether key was newly added.
func (s *Set) Add(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return false
}
s.m[key] = struct{}{}
return true
}
// Keys returns a sorted copy of the contents.
func (s *Set) Keys() []string {
s.mu.Lock()
defer s.mu.Unlock()
keys := make([]string, 0, len(s.m))
for k := range s.m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func main() {
seen := NewSet()
urls := []string{"/a", "/b", "/a", "/c", "/b", "/a"}
var wg sync.WaitGroup
var added atomic.Int32
for _, u := range urls {
wg.Go(func() {
if seen.Add(u) {
added.Add(1)
}
})
}
wg.Wait()
fmt.Println("distinct:", seen.Keys(), "newly added:", added.Load())
}distinct: [/a /b /c] newly added: 3Two decisions make this safe in practice. The map field is unexported and every access goes through a method that locks, so no caller can forget. And Keys returns a copy rather than the map or an iterator over it, so the caller cannot touch the internal state without the lock. Add returns whether the key was new, because the check and the insert have to be one atomic step: a separate Has followed by Add would let two goroutines both see "absent" and both insert. Operations that combine a read and a write belong inside one locked method (mutexes).
If readers vastly outnumber writers, sync.RWMutex allows many readers in parallel, but it costs more per operation than a Mutex, so measure before switching (RWMutex).
Pattern 3: one collector goroutine
If results should be gathered in order of completion, or if the collecting logic is more than an append, give the collection to a single goroutine and have the workers send to it. The slice then has one owner and needs no lock:
package main
import (
"fmt"
"slices"
"sync"
)
type result struct {
id int
value int
}
func main() {
results := make(chan result)
var collected []result
collectorDone := make(chan struct{})
go func() { // the only goroutine that touches collected
defer close(collectorDone)
for r := range results {
collected = append(collected, r)
}
}()
var wg sync.WaitGroup
for id := 1; id <= 6; id++ {
wg.Go(func() {
results <- result{id: id, value: id * id}
})
}
wg.Wait()
close(results) // after all senders are done
<-collectorDone
slices.SortFunc(collected, func(a, b result) int { return a.id - b.id })
fmt.Println(collected)
}[{1 1} {2 4} {3 9} {4 16} {5 25} {6 36}]Three details to copy. The channel is closed by the code that knows all the senders have finished, after wg.Wait(), not by a sender. The main goroutine waits on collectorDone before reading collected, which is what establishes the happens-before edge that makes the read safe (happens-before). And results arrive in whatever order the workers finish, so the example sorts at the end; if you need positional order, Pattern 1 is simpler. This is the shape used by fan-in and by worker pools that gather results.
Pattern 4: copy on write for read-heavy data
A table that is read thousands of times per second and replaced rarely, such as routing rules or feature flags, is better served by never mutating the published value. Writers build a new copy and swap a pointer; readers load the pointer and use it without any lock:
package main
import (
"fmt"
"maps"
"sync"
"sync/atomic"
)
type Routes struct {
table atomic.Pointer[map[string]string]
mu sync.Mutex // serializes writers only
}
func (r *Routes) Lookup(path string) (string, bool) {
m := r.table.Load()
if m == nil {
return "", false
}
target, ok := (*m)[path]
return target, ok
}
func (r *Routes) Set(path, target string) {
r.mu.Lock()
defer r.mu.Unlock()
next := map[string]string{}
if cur := r.table.Load(); cur != nil {
next = maps.Clone(*cur)
}
next[path] = target
r.table.Store(&next)
}
func main() {
var routes Routes
var wg sync.WaitGroup
for i := range 5 {
wg.Go(func() { routes.Set(fmt.Sprintf("/svc%d", i), fmt.Sprintf("10.0.0.%d", i)) })
}
wg.Wait()
for i := range 5 {
target, _ := routes.Lookup(fmt.Sprintf("/svc%d", i))
fmt.Printf("/svc%d -> %s\n", i, target)
}
}/svc0 -> 10.0.0.0
/svc1 -> 10.0.0.1
/svc2 -> 10.0.0.2
/svc3 -> 10.0.0.3
/svc4 -> 10.0.0.4All five writes survive because writers are serialized by the mutex and each starts from the latest copy. Readers never block, never see a half-built map, and pay one atomic load. The cost is a full copy on every write, so this pattern suits small tables updated rarely and not a million-entry map updated constantly. The rule that keeps it correct is that a published map is never modified again (copy on write in detail).
A trap that survives all four patterns
Every pattern above can still leak the data it protects. A method that returns the internal slice, or the map itself, hands the caller a reference to memory the lock was guarding, and once the lock is released the caller reads it unprotected while another goroutine writes. Keys in the set example builds a fresh slice for exactly this reason. The same applies to a method that returns s.items[:n], which shares the backing array, and to an iterator that yields from the map while holding no lock. Return copies (slices.Clone, maps.Clone) or take a callback that runs while the lock is held, and say in the doc comment which one you chose. The race detector finds this slip only if the test reads the returned value while another goroutine writes, which is one more reason to write the contention test.
Choosing
| If the situation is | Use |
|---|---|
| Known number of results, order matters | Preallocated slice, one index each |
| Shared collection with mixed operations | Mutex inside a type with methods |
| Results gathered as they complete, or extra logic on collection | Collector goroutine and channel |
| Data read constantly, replaced rarely | Copy on write with atomic.Pointer |
| Disjoint keys, write-once and read-many | sync.Map (when it fits) |
| One lock is the bottleneck | Shard it (sharded maps) |
Test it with the race detector
Every pattern above looks right on first reading, and every one can hide a slip, so run them under -race with real concurrency. A compact test for the Set type, which fails if the locking is removed:
package main
import (
"fmt"
"sync"
"testing"
)
type Set struct {
mu sync.Mutex
m map[string]struct{}
}
func (s *Set) Add(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return false
}
s.m[key] = struct{}{}
return true
}
func TestSetAddUnderContention(t *testing.T) {
s := &Set{m: map[string]struct{}{}}
var wg sync.WaitGroup
var mu sync.Mutex
added := 0
for g := range 16 {
wg.Go(func() {
for i := range 500 {
if s.Add(fmt.Sprintf("key-%d", (g*7+i)%100)) {
mu.Lock()
added++
mu.Unlock()
}
}
})
}
wg.Wait()
if added != 100 || len(s.m) != 100 {
t.Fatalf("added %d, len %d; want exactly 100 distinct keys", added, len(s.m))
}
}=== RUN TestSetAddUnderContention
--- PASS: TestSetAddUnderContention (0.02s)
PASS
ok example.test/lab 1.262sThe assertion is stronger than "no crash": exactly 100 distinct keys must have been added exactly once each, which fails if Add ever lets two goroutines both win the same key. Combine that with -race and -count=20 in CI and a locking mistake has very little room to hide. The Go FAQ explains why built-in maps are not synchronized by default in its entry on maps and atomicity, and the documentation for sync.Map states the cases where it is intended to be used instead.


