The sync package in Go: every type, with examples
Every type in sync, what it is for, a working example, and the mistake people make with it.
By devskills editorial team · Last updated
The sync package holds Go's low-level synchronisation tools. Channels handle passing data between goroutines; sync handles everything around shared state: locking it, waiting for goroutines, doing setup once, and reusing memory. Its sibling sync/atomic handles single values without a lock.
One rule applies to every type here: do not copy it after first use. A copied Mutex is a second, unrelated lock, and a copied WaitGroup counts nothing. Pass pointers, and let go vet (its copylocks check) catch the mistakes.
| Type | Use it to | Added | The usual mistake |
|---|---|---|---|
Mutex | Let one goroutine at a time touch shared state | Go 1.0 (TryLock 1.18) | Copying it; holding it across slow I/O |
RWMutex | Many readers at once, or one writer | Go 1.0 | Recursive RLock; using it where reads are short |
WaitGroup | Wait for a group of goroutines to finish | Go 1.0 (Go method 1.25) | Add inside the goroutine |
Once, OnceFunc, OnceValue, OnceValues | Run setup exactly once, lazily | Once 1.0, the rest 1.21 | Expecting a failed setup to be retried |
Pool | Reuse temporary objects to cut allocations | Go 1.3 | Assuming objects stay in it; not resetting them |
Map | A concurrent map for two specific access patterns | Go 1.9 | Using it as a general map replacement |
Cond | Wake goroutines waiting for a condition | Go 1.0 | Calling Wait outside a for loop |
sync/atomic types | One counter, flag or pointer without a lock | Typed API 1.19 | Mixing atomic and plain access to one value |
sync.Mutex
Lock and Unlock around every access to the shared data, with defer mu.Unlock() straight after Lock so an early return or a panic cannot leave it locked. Keep the mutex next to the data it protects, in the same struct, and keep the locked section short: never hold a lock while making a network call.
TryLock exists (Go 1.18) but its own documentation warns that correct uses are rare. If you are reaching for it, there is usually a design problem. The Go mutex is also fair under pressure: a waiter that has waited more than 1 ms switches the lock into starvation mode, where it is handed over directly in queue order. The full lesson is Mutexes and races.
sync.RWMutex
RLock lets any number of readers in together; Lock waits until there are none and keeps everyone else out. It only beats a plain Mutex when reads clearly outnumber writes and each read holds the lock long enough for readers to overlap. It has one trap that deadlocks real programs: a goroutine must never take RLock twice. Both are covered with benchmarks in Mutex vs RWMutex.
sync.WaitGroup and WaitGroup.Go
A WaitGroup is a counter. Add(n) raises it, Done() lowers it by one, and Wait() blocks until it reaches zero. Call Add before the go statement; inside the goroutine, Wait can run first and return immediately.
Go 1.25 added wg.Go(f), which does the Add(1), starts f in a goroutine, and calls Done when it returns. Use it whenever your module targets Go 1.25 or later; it removes both classic mistakes. A WaitGroup does not collect errors. For that, use errgroup (Worker pool).
package mainimport ( "fmt" "sync")func main() { squares := make([]int, 5) // Go 1.25+: Go does Add(1), starts the goroutine and calls Done. var wg sync.WaitGroup for i := range squares { wg.Go(func() { squares[i] = i * i // each goroutine writes its own slot }) } wg.Wait() fmt.Println("with wg.Go:", squares) // The same thing on Go 1.24 and older. var old sync.WaitGroup for i := range squares { old.Add(1) // before go, never inside it go func() { defer old.Done() squares[i] = i * i * i }() } old.Wait() fmt.Println("with Add/Done:", squares)}sync.Once, OnceFunc, OnceValue and OnceValues
once.Do(f) runs f the first time it is called from any goroutine. Concurrent callers wait until that first call finishes, then return without running f. Use it for lazy setup: parsing a config, compiling a regular expression, creating a shared client.
Go 1.21 added three helpers that are usually what you want. sync.OnceFunc(f) returns a function that runs f once. sync.OnceValue(f) returns a function that runs f once and then returns its result every time. sync.OnceValues(f) does the same for a (value, error) pair.
The trap: "once" means once, even if it failed. If the setup returns an error, every later call gets the same error, and it is never retried. When the setup can fail for a temporary reason, like a network call, use a mutex and a "done" flag instead, so a later call can try again.
package mainimport ( "errors" "fmt" "strconv" "sync")var calls int// loadPort runs the parse once, however many goroutines call it.var loadPort = sync.OnceValues(func() (int, error) { calls++ return strconv.Atoi("8080")})var loadBroken = sync.OnceValues(func() (int, error) { return 0, errors.New("config server unreachable")})func main() { var wg sync.WaitGroup for range 10 { wg.Go(func() { port, err := loadPort() _, _ = port, err }) } wg.Wait() port, err := loadPort() fmt.Println("port:", port, "err:", err, "setup ran:", calls, "time(s)") // a failure is remembered too: it is not retried _, err1 := loadBroken() _, err2 := loadBroken() fmt.Println(err1, "|", err2)}sync.Pool
A Pool keeps temporary objects for reuse, so a hot path that needs a scratch buffer on every request does not allocate a new one each time. Get returns a pooled object or calls New to make one; Put hands it back.
Two rules. First, the pool may drop anything it holds at any time; in practice objects that sit unused across garbage collections are freed. So it is a cache for memory, never a place to keep state. Second, reset an object before using it again, because it still holds whatever the last user left in it. Do not pool things that need closing, such as connections or files; that is a job for a real pool like *sql.DB.
Measure before adding one. A Pool only pays off when allocation shows up in a profile. The standard library uses it in places like fmt, where buffers are allocated on every call.
package mainimport ( "bytes" "fmt" "sync")var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) },}func render(name string) string { b := bufPool.Get().(*bytes.Buffer) b.Reset() // it may still hold the previous user's bytes defer bufPool.Put(b) b.WriteString("hello, ") b.WriteString(name) return b.String() // String copies, so returning it after Put is safe}func main() { for _, n := range []string{"asha", "ben", "chen"} { fmt.Println(render(n)) }}sync.Map
A map that is safe for concurrent use without your own lock. Its documentation is specific about when it helps: when each key is written once and then read many times (a cache that only grows), or when different goroutines read and write disjoint sets of keys. For everything else, a plain map guarded by a Mutex or RWMutex is simpler, type-safe and often faster.
Its methods are Load, Store, LoadOrStore, LoadAndDelete, Delete, Range, plus Swap, CompareAndSwap and CompareAndDelete (Go 1.20) and Clear (Go 1.23). Values come back as any, so you need a type assertion on every read.
package mainimport ( "fmt" "sync")func main() { var seen sync.Map // zero value is ready to use var wg sync.WaitGroup var firsts sync.Map urls := []string{"/a", "/b", "/a", "/c", "/b", "/a"} for i, u := range urls { wg.Go(func() { // only the first goroutine for each key stores; the rest load if _, loaded := seen.LoadOrStore(u, i); !loaded { firsts.Store(u, true) } }) } wg.Wait() n := 0 firsts.Range(func(k, v any) bool { n++ return true // keep iterating }) fmt.Println("distinct paths:", n) if v, ok := seen.Load("/c"); ok { fmt.Println("/c first seen at index", v.(int)) }}sync.Cond
A condition variable: goroutines Wait until another goroutine changes some state and calls Signal (wake one) or Broadcast (wake all). Wait unlocks the associated mutex while sleeping and locks it again before returning.
Always call Wait inside a for loop that re-checks the condition, because by the time a woken goroutine runs, another may have changed the state again. The package documentation itself says that for many simple cases channels are a better fit. Cond earns its place when many goroutines wait on a condition over shared state, and Broadcast wakes all of them at once, which a channel can only do by being closed (once).
package mainimport ( "fmt" "sync")type queue struct { mu sync.Mutex cond *sync.Cond items []int}func newQueue() *queue { q := &queue{} q.cond = sync.NewCond(&q.mu) return q}func (q *queue) Put(v int) { q.mu.Lock() q.items = append(q.items, v) q.mu.Unlock() q.cond.Signal() // wake one waiting taker}func (q *queue) Take() int { q.mu.Lock() defer q.mu.Unlock() for len(q.items) == 0 { // a loop, not an if q.cond.Wait() // unlocks while asleep, relocks before returning } v := q.items[0] q.items = q.items[1:] return v}func main() { q := newQueue() var wg sync.WaitGroup sum := 0 var mu sync.Mutex for range 3 { wg.Go(func() { v := q.Take() mu.Lock() sum += v mu.Unlock() }) } for v := 1; v <= 3; v++ { q.Put(v * 10) } wg.Wait() fmt.Println("taken:", sum)}sync/atomic
For a single counter, flag or pointer, an atomic operation is cheaper than a lock. Since Go 1.19 the package has typed values (atomic.Int64, atomic.Bool, atomic.Pointer[T] and others) that cannot be accessed non-atomically by accident, which is a real improvement over the older atomic.AddInt64(&n, 1) functions.
Atomics protect one value. As soon as two values must change together (a balance and a history, a count and a total), use a mutex. Every access to an atomic value must be atomic: one plain read next to atomic writes is a data race.
package mainimport ( "fmt" "sync" "sync/atomic")func main() { var hits atomic.Int64 var ready atomic.Bool var wg sync.WaitGroup for range 1000 { wg.Go(func() { hits.Add(1) }) } wg.Wait() ready.Store(true) fmt.Println("hits:", hits.Load(), "ready:", ready.Load()) fmt.Println("swapped 1000 -> 0:", hits.CompareAndSwap(1000, 0), "now:", hits.Load())}When should you use a channel instead of sync?
When the problem is about handing data or work from one goroutine to another: a pipeline, a job queue, a result, a timeout with select. Use sync when goroutines share state in place. The Go wiki's advice is to use whichever is simpler, and Mutex vs channel walks through both versions of the same program.
go test -race. Every type on this page exists to prevent bugs the race detector is built to find.- Pick the right
synctype for a problem: lock, wait, run once, reuse, or signal. - Use
WaitGroup.Go,sync.OnceValueand the typed atomics from recent Go releases. - Name the trap in each type, such as copying a mutex or trusting
sync.Poolto keep an object.
- Never copy a
syncvalue after first use; pass pointers and letgo vetcheck. wg.Go(f)(Go 1.25) replaces theAdd/Donepair;Addbeforegoon older versions.OnceValueandOnceValues(Go 1.21) run setup once and remember the result, including a failure.sync.Poolis a memory cache that can be emptied at any time. Reset what you get from it.sync.Mapis for write-once-read-many keys or disjoint key sets; otherwise use a map and a mutex.- Typed atomics (Go 1.19) for single values; a mutex as soon as two values must change together.
- 1Write a
Counterstruct with async.Mutexand anIncmethod on a value receiver. Rungo vet. What does it say, and what is the fix? - 2Replace the
sync.Condqueue incond.gowith a buffered channel. How many lines did you save? - 3Your service loads a token from a secrets API at startup with
sync.OnceValues. The API was briefly down at boot. What happens for the life of the process, and what would you write instead?