You launched some goroutines and now main needs to wait for all of them before it moves on. sync.WaitGroup is how you do that correctly. This guide shows the counter model, the two mistakes that turn it into a silent bug or a deadlock, how to collect results safely, and the Go 1.25 shortcut. Every example was run on Go 1.24.7; the output is real. Works with Go 1.21+.
If goroutines themselves are still new, read the goroutines concurrency guide first, and the complete Go tutorial if the syntax is shaky. This article assumes you can start a goroutine and just need to coordinate several of them.
WaitGroup is a counter, not a container
A sync.WaitGroup is a single integer counter with three operations. The sync package docs define it as “a counting semaphore typically used to wait for a group of goroutines to finish,” and the mechanics are exactly that literal:
Add(delta)addsdeltato the counter. “If the counter goes negative, Add panics.”Done()decrements the counter by one. It “is equivalent to Add(-1).”Wait()“blocks until the WaitGroup task counter is zero.”
That is the whole model. You set the counter to the number of goroutines you started, each goroutine drops it by one as it finishes, and Wait unblocks when it reaches zero. The one thing to internalize early: a WaitGroup counts, it does not carry data. It tells you when the work is done, never what the work produced. Getting values back out is a separate job, covered further down.
The minimal correct pattern
Here is the shape you will write ninety percent of the time: fan out one goroutine per item, then wait for the batch.
package main
import (
"fmt"
"sync"
"time"
)
func checkService(name string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(50 * time.Millisecond) // simulate a network call
fmt.Printf("%s: ok\n", name)
}
func main() {
var wg sync.WaitGroup
services := []string{"inventory", "payments", "shipping"}
for _, name := range services {
wg.Add(1)
go checkService(name, &wg)
}
wg.Wait()
fmt.Println("all services checked")
}
Output (the first three lines vary in order between runs):
shipping: ok
inventory: ok
payments: ok
all services checked
Three details carry the whole pattern. wg.Add(1) runs in the loop, before go. defer wg.Done() is the first line of the worker, so the counter comes back down even if the function returns early or panics (if defer is fuzzy, see defer, panic and recover). And the WaitGroup is passed as a pointer, *sync.WaitGroup. The next two sections show what happens when you break the first and third of those.
When you already know the count up front, Add takes any delta, so you can set the counter once before the loop instead of incrementing each iteration:
wg.Add(len(services)) // set the counter once
for _, name := range services {
go checkService(name, &wg)
}
wg.Wait()
Both forms are correct. Add(1) per iteration is safer to refactor (add a continue that skips a go and the count still matches), so it is the more common default; the single Add(len(...)) is marginally clearer when the loop body is unconditional. What you must never do is compute the count inside the goroutines, which is the race the next section dissects.
Add must run before go, not inside the goroutine
It looks tidier to let each goroutine register itself. It is also a race. Move the Add inside:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
go func() {
wg.Add(1) // too late: Wait may already have returned
defer wg.Done()
time.Sleep(10 * time.Millisecond)
fmt.Println("worker", i, "done")
}()
}
wg.Wait() // counter may still be 0 here
fmt.Println("main: everything finished")
}
Output:
main: everything finished
No worker printed. The go statement returns instantly, so main reaches wg.Wait() before the scheduler has run a single goroutine. The counter is still zero, Wait returns immediately, main exits, and the goroutines are killed mid-flight. On a busy machine the timing sometimes lets a worker or two sneak in, which is worse: the bug is now intermittent.
This is a genuine data race between Add and Wait, and the race detector catches it. Run the same program with -race and, on the runs where the timing lines up, you get:
$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000012148 by main goroutine:
main.main()
/tmp/wgtest/ex2/main.go:21 +0x111
Previous read at 0x00c000012148 by goroutine 7:
main.main.func1()
/tmp/wgtest/ex2/main.go:14 +0x4d
Goroutine 7 (running) created at:
main.main()
/tmp/wgtest/ex2/main.go:13 +0x77
==================
Found 1 data race(s)
exit status 66
Line 21 is wg.Wait(), line 14 is wg.Add(1): the detector is telling you the wait and the add touched the counter with no ordering between them. This is why go test -race ./... belongs in your CI. The fix is the minimal pattern above: pull Add(1) out into the launching goroutine, before go, so the counter is already at 3 by the time Wait runs.
Passing a WaitGroup by value copies the counter and deadlocks
A sync.WaitGroup must be shared, not copied. Every goroutine has to increment and decrement the same counter. Pass it by value and each copy gets its own counter, so the copies you decrement are not the one main is waiting on.
package main
import (
"fmt"
"sync"
)
// wg is passed by value: each call gets its own copy of the counter.
func startWorker(id int, wg sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id, "done")
}
func main() {
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1)
go startWorker(id, wg)
}
wg.Wait()
fmt.Println("main: all workers finished")
}
Output:
worker 3 done
worker 2 done
worker 1 done
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(...)
/usr/local/go1.24.7/src/runtime/sema.go:110 +0x25
sync.(*WaitGroup).Wait(...)
/usr/local/go1.24.7/src/sync/waitgroup.go:118 +0x48
main.main()
/tmp/wgtest/ex3/main.go:22 +0x9d
exit status 2
The workers ran and called Done, but each decremented its own copy. main’s original counter never dropped below 3, so Wait blocks forever and the runtime kills the process. You do not even have to hit this at runtime, because go vet flags it statically:
$ go vet .
./main.go:9:29: startWorker passes lock by value: sync.WaitGroup contains sync.noCopy
./main.go:19:22: call of startWorker copies lock value: sync.WaitGroup contains sync.noCopy
The docs put it plainly: “A WaitGroup must not be copied after first use.” The fix is a pointer, wg *sync.WaitGroup, exactly as the first example did it. The same rule bites when a WaitGroup is a struct field and you pass the struct by value, so keep WaitGroups behind pointers everywhere.
Collecting results: WaitGroup coordinates, something else carries the data
Because a WaitGroup only counts, you need a second mechanism to get values back. Two patterns cover almost everything.
Pre-sized slice, one index per goroutine. If each goroutine writes to its own index and nothing else, there is no shared write, so no lock is needed. This also preserves input order for free.
package main
import (
"crypto/sha256"
"fmt"
"sync"
)
func main() {
documents := []string{
"invoice-2026-001",
"invoice-2026-002",
"invoice-2026-003",
"invoice-2026-004",
}
// One slot per input. Each goroutine owns exactly one index,
// so no two goroutines ever touch the same element: no lock needed.
checksums := make([]string, len(documents))
var wg sync.WaitGroup
for i, doc := range documents {
wg.Add(1)
go func() {
defer wg.Done()
sum := sha256.Sum256([]byte(doc))
checksums[i] = fmt.Sprintf("%x", sum[:4])
}()
}
wg.Wait()
for i, doc := range documents {
fmt.Printf("%s -> %s\n", doc, checksums[i])
}
}
Output (run with -race, no warnings, order preserved):
invoice-2026-001 -> 128e7e86
invoice-2026-002 -> fb2d3668
invoice-2026-003 -> cad28780
invoice-2026-004 -> b25ba041
Writing to distinct indices of a slice from different goroutines is safe because the slice backing array is fixed in size and no element is shared. Do not confuse this with appending: append from multiple goroutines races and can corrupt the slice.
A channel plus a closer goroutine. When results arrive in no fixed count or you want to stream them, send each result on a channel. The trick is closing the channel once every sender is done, which is precisely what a WaitGroup tells you.
package main
import (
"fmt"
"strings"
"sync"
)
type wordCount struct {
source string
count int
}
func main() {
pages := map[string]string{
"home": "go is fast go is simple",
"about": "we build tools in go",
"pricing": "free tier and paid tier",
}
results := make(chan wordCount)
var wg sync.WaitGroup
for name, body := range pages {
wg.Add(1)
go func() {
defer wg.Done()
results <- wordCount{source: name, count: len(strings.Fields(body))}
}()
}
// Close results once every sender is done, so the range below ends.
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
fmt.Printf("%-8s %d words\n", r.source, r.count)
total += r.count
}
fmt.Println("total words:", total)
}
Output (order varies):
about 5 words
home 6 words
pricing 5 words
total words: 16
The wg.Wait(); close(results) runs in its own goroutine on purpose. If you called it inline before the range, main would block on Wait while the senders block trying to send into an unbuffered channel nobody is receiving from: a deadlock. The separate goroutine lets main start draining immediately. Channels are the right tool the moment results outnumber a fixed slice or need to flow as they complete; the channels guide covers buffering and direction in depth.
WaitGroup and errors: there is no error return, so collect them
Done() takes no arguments and Wait() returns nothing. A WaitGroup has no error path at all. When goroutines can fail, you collect the failures yourself. The honest bare-WaitGroup version guards a shared error slice with a mutex:
package main
import (
"fmt"
"strconv"
"sync"
)
// parsePort fails on anything that is not a valid TCP port.
func parsePort(raw string) (int, error) {
n, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("port %q: %w", raw, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %q: out of range", raw)
}
return n, nil
}
func main() {
inputs := []string{"8080", "443", "not-a-port", "70000", "22"}
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
for _, raw := range inputs {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := parsePort(raw); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Printf("%d input(s) failed:\n", len(errs))
for _, err := range errs {
fmt.Println(" -", err)
}
}
Output (which two errors appear is deterministic; their order is not):
2 input(s) failed:
- port "not-a-port": strconv.Atoi: parsing "not-a-port": invalid syntax
- port "70000": out of range
The mutex is mandatory: append to a shared slice from multiple goroutines is a data race, and -race will flag it if you drop the lock. Errors are wrapped with %w so callers can still errors.Is/errors.As them, a habit the error handling guide explains.
This works, but in production you rarely hand-roll it. The Go team’s golang.org/x/sync/errgroup wraps exactly this pattern: g.Go(func() error {...}) starts a tracked goroutine, and g.Wait() returns the first non-nil error, with optional context cancellation so siblings stop once one fails. It is the standard tool for fan-out-with-errors and is covered in the goroutines guide. This sandbox blocks external modules, so the block above stays stdlib-only; reach for errgroup in real code instead of the mutex-and-slice dance.
Go 1.25 adds WaitGroup.Go, which removes the Add/Done boilerplate
As of Go 1.25, WaitGroup has a Go method that does the Add, starts the goroutine, and calls Done when the function returns, all in one call:
// Requires Go 1.25+. Not runnable on the Go 1.24.7 used to test this article.
var wg sync.WaitGroup
for _, name := range services {
wg.Go(func() {
checkService(name)
})
}
wg.Wait()
The docs describe it as: “Go calls f in a new goroutine and adds that task to the WaitGroup. When f returns, the task is removed from the WaitGroup.” Because you never write Add or Done by hand, the two worst bugs above become unwritable: there is no Add to misplace inside the goroutine, and no Done to forget. If your go.mod declares go 1.25 or later, prefer wg.Go. On 1.24.7 the method does not exist yet (go build reports wg.Go undefined), so the rest of this article stays on explicit Add/Done to keep every block runnable today.
A realistic parallel task, with measured speedup
Here is the shape that justifies reaching for a WaitGroup: N independent CPU-bound jobs you want to run at once. This counts keyword hits across a batch of in-memory documents, sequentially and then in parallel, and times both. The parallel version uses the pre-sized-slice pattern so no lock is needed.
package main
import (
"fmt"
"strings"
"sync"
"time"
)
// countKeyword scans one document for a keyword. It re-scans a few times
// so each call takes real CPU time and the timing comparison is meaningful.
func countKeyword(doc, keyword string) int {
count := 0
for i := 0; i < 200; i++ {
count += strings.Count(doc, keyword)
}
return count / 200
}
func buildCorpus(n int) []string {
docs := make([]string, n)
for i := range docs {
docs[i] = strings.Repeat("go concurrency scales when goroutines share nothing ", 400)
}
return docs
}
func sequential(docs []string, keyword string) (int, time.Duration) {
start := time.Now()
total := 0
for _, doc := range docs {
total += countKeyword(doc, keyword)
}
return total, time.Since(start)
}
func parallel(docs []string, keyword string) (int, time.Duration) {
start := time.Now()
counts := make([]int, len(docs)) // one slot per doc, no shared writes
var wg sync.WaitGroup
for i, doc := range docs {
wg.Add(1)
go func() {
defer wg.Done()
counts[i] = countKeyword(doc, keyword)
}()
}
wg.Wait()
total := 0
for _, c := range counts {
total += c
}
return total, time.Since(start)
}
func main() {
docs := buildCorpus(48)
const keyword = "goroutines"
seqTotal, seqDur := sequential(docs, keyword)
parTotal, parDur := parallel(docs, keyword)
fmt.Printf("documents: %d\n", len(docs))
fmt.Printf("keyword hits (sequential): %d\n", seqTotal)
fmt.Printf("keyword hits (parallel): %d\n", parTotal)
fmt.Printf("sequential: %v\n", seqDur.Round(time.Millisecond))
fmt.Printf("parallel: %v\n", parDur.Round(time.Millisecond))
fmt.Printf("speedup: %.1fx\n", float64(seqDur)/float64(parDur))
}
Output (on the 2-core machine this was tested on; run with -race, no warnings):
documents: 48
keyword hits (sequential): 19200
keyword hits (parallel): 19200
sequential: 113ms
parallel: 66ms
speedup: 1.8x
Both totals match, so the parallel version is correct, and it runs in a little over half the time. The speedup is 1.8x, not 48x, because this is CPU-bound work on 2 cores: GOMAXPROCS caps how many goroutines execute at the same instant, so throwing 48 goroutines at 2 cores buys you roughly a 2x ceiling. For I/O-bound work (HTTP calls, disk, database queries) the goroutines spend most of their time parked, and the same structure scales far past the core count. Match the number of concurrent goroutines to what the work is waiting on, not to the number of jobs.
Common mistakes, each shown breaking
Forgetting Done deadlocks the whole program. Drop the defer wg.Done() and the counter never reaches zero:
for id := 1; id <= 3; id++ {
wg.Add(1)
go func() {
// forgot: defer wg.Done()
fmt.Println("worker", id, "ran")
}()
}
wg.Wait() // counter stuck at 3
Output:
worker 3 ran
worker 1 ran
worker 2 ran
fatal error: all goroutines are asleep - deadlock!
The workers finish their prints, but the counter stays at 3, so Wait blocks forever and the runtime detects that every goroutine is stuck. Making wg.Done() the first deferred line of every worker prevents this even when the worker returns early or panics.
Add inside the goroutine and copying the WaitGroup are the two failures shown in full above. The first is a race that empties the wait; the second is a go vet failure that deadlocks. Both vanish if you follow the minimal pattern: Add before go, share by pointer.
Reusing a WaitGroup before Wait returns. A WaitGroup is reusable, but only in sequence. The docs require that “new Add calls must happen after all previous Wait calls have returned.” This batches correctly because each Wait fully returns before the next batch calls Add:
for n, batch := range batches {
for _, job := range batch {
wg.Add(1)
go func() {
defer wg.Done()
process(job)
}()
}
wg.Wait() // fully returns before the next batch starts
fmt.Printf("batch %d done\n", n)
}
Calling Add on a WaitGroup while another goroutine is still inside Wait is misuse, and the runtime panics with sync: WaitGroup is reused before previous Wait has returned. If you need overlapping batches, use a fresh WaitGroup per batch.
Using a WaitGroup when a channel is cleaner. If you already know how many results you expect, a channel counts them for you, no Add/Done bookkeeping:
package main
import "fmt"
func main() {
regions := []string{"us-east", "eu-west", "ap-south"}
latencies := make(chan int, len(regions))
for i, region := range regions {
go func() {
_ = region
latencies <- (i + 1) * 10 // pretend measured latency in ms
}()
}
total := 0
for range regions { // receive exactly len(regions) values
total += <-latencies
}
fmt.Println("total latency budget:", total, "ms")
}
Output:
total latency budget: 60 ms
Reach for a WaitGroup when you need to wait for a variable or unknown count of goroutines whose results you collect separately. Reach for a channel when the count is known and the channel is already carrying the data. Using both when one would do is a common over-engineering tell in code review.
What next
You can now start a batch of goroutines and wait for it without the two bugs that catch most people. The pieces that pair with WaitGroup:
- Mutexes in Go for the shared state a WaitGroup coordinates but does not protect.
- Channels: buffered, unbuffered and directional for carrying results and for the cases where a channel replaces the WaitGroup entirely.
- The goroutines concurrency guide for the full arc, including errgroup, context cancellation, and goroutine leaks.
- Concurrency dominates Go interviews; the Go interview questions collection has a section on exactly these primitives.
Run every example here yourself with -race. The deadlock and the race report are more convincing when your own terminal prints them.