GoGo example

How to wait for goroutines to finish in Go

8 min read▶ Runs in an isolated hosted runtimeUpdated Aug 2026

Quick answer

Use a sync.WaitGroup: call wg.Add(1) before each go statement, defer wg.Done() as the first line inside the goroutine, and wg.Wait() in the caller. Wait blocks until the counter is back to zero, so every goroutine has returned before the next line runs.

Starting a goroutine is one keyword; finishing one is the part people get wrong. Go never joins goroutines for you, and when main returns the whole process exits — any goroutine still in flight is killed mid-sentence, with no warning and no output. The fix is a sync.WaitGroup, a counter with a blocking Wait. The traps are all around it: Add in the wrong place, results written into a shared map, output that prints in a different order every run. Every example below runs on this page — hit Run, then edit the code and run it again.

1Why a bare go f() prints nothing

Start with the bug. This program launches three goroutines and then falls off the end of main. There is no join, no Wait, nothing that makes main pause — so the runtime tears the process down while the workers are still queued. The Go spec is explicit about it: when the main function returns, the program exits; it does not wait for other goroutines to complete.

no-wait.go

Output

The only line printed is main: starting 3 workers. Not one worker gets to run. Note what this program is not: it is not deterministic by contract. Nothing stops a worker from sneaking a line out before the process dies — it just almost never wins the race, which is precisely what makes the bug so nasty in production. The cure is never time.Sleep; it is to make the waiting explicit.

2sync.WaitGroup — Add, Done, WaitRecommended

A sync.WaitGroup is a counter guarded by the runtime. Add(n) raises it, Done() lowers it by one, and Wait() blocks until it hits zero. Three rules make it safe: call Add in the parent, before the go statement — never inside the goroutine, or Wait can return before the goroutine has even registered; always defer wg.Done() as the first statement, so it fires on every return path and on a panic; and pass the WaitGroup by pointer if it crosses a function boundary — copying one breaks it silently.

waitgroup.go

Output

Prints main: waiting for 3 goroutines and then 200 OK /home, 200 OK /about, 200 OK /pricing — in that order, every run, because the printing happens in main after Wait. The loop variables are handed to the closure as arguments (i, p) rather than captured. Go 1.22 made each iteration get its own copy, so capturing is fine on modern toolchains, but passing explicitly is unambiguous and behaves the same on every Go version and interpreter. Go 1.25 also added wg.Go(func(){}), which folds Add and Done into one call; the engine behind this page runs an older Go, so the snippets here use the classic form.

3Collecting results without a data race

The natural next question is how to get values out. The wrong answers are famous: append to a shared slice (two goroutines can grab the same index and one write is lost) or write into a shared map (Go detects that and kills the program with fatal error: concurrent map writes). The right answer needs no lock at all — pre-size a slice to len(input) and give each goroutine one index it alone owns. Distinct elements of a slice are distinct memory, so the writes cannot collide, and Wait establishes the happens-before edge that makes it legal to read them afterwards.

results.go

Output

The rows come out in input order — go, concurrency, waitgroup, channel — followed by total letters: 29, no matter which goroutine actually ran first. That ordering is free: it comes from the index, not from the scheduler. Run this with go run -race results.go locally and it stays silent; swap the indexed write for results = append(results, …) and the race detector lights up immediately.

4Shared counters: sync.Mutex and atomic

Sometimes the goroutines really do share one accumulator — a tally, a running total, a cache. Then a WaitGroup is not enough: it tells you when everyone finished, not that their writes were safe. Wrap the shared state in a sync.Mutex and take the lock around every read and write. If the shared state is a single integer, skip the mutex and use sync/atomicatomic.Int64 is lock-free and its Add / Load methods cannot be used incorrectly the way a bare int64 can.

mutex.go

Output

One hundred goroutines, and the sums are exact every time: even sum: 2550, odd sum: 2500, goroutines that ran: 100. Two details are doing real work here. The mutex lives inside the struct it protects, which is why t is a pointer — copying a sync.Mutex copies its lock state and is a bug (go vet catches it). And the map keys are sorted before printing: map iteration order is deliberately randomised in Go, so ranging t.n directly would shuffle the output between runs even though the numbers are correct.

5Waiting with a channel instead

A channel can do the waiting too, and it carries the results at the same time. The idiom is fan-in: every worker sends on one channel, a small closer goroutine does wg.Wait() and then close(out), and the consumer ranges until the channel is closed and drained. The closer belongs in its own goroutine: if you call wg.Wait() on the main goroutine before you start ranging, then any time the channel can fill up — it is unbuffered, or its buffer is smaller than the number of sends — the workers block on the send while you block on Wait, and nothing can ever drain it. That is a deadlock, and Go will kill the program with all goroutines are asleep. Buffering to len(jobs) below dodges that specific case, but the closer goroutine is the shape that keeps working when you don't know the result count up front.

fanin.go

Output

Prints 2 squared is 4, 4 squared is 16, 5 squared is 25, 9 squared is 81. That order comes from sort.Strings (see sorting a slice in Go), not from the goroutines — the arrival order on a channel is whatever the scheduler decides, so any program that prints as values arrive has output that changes between runs. If you need input order back, use the indexed-slice pattern from section 3 instead; if you genuinely want streaming, sort or key the results at the end.

6Which should you use?

MethodWaits reliablyCarries resultsBest for
sync.WaitGroupYesNo — pair with a sliceAlmost everything
WaitGroup + indexed sliceYesYes, in input orderFixed set of jobs with results
sync.Mutex around shared stateNo — not a waitYes, one shared valueTallies, caches, maps
atomic.Int64No — not a waitOne integerCounters on a hot path
Channel + closer goroutineYesYes, in arrival orderStreaming or unknown result counts
time.SleepNo — guessworkNoNothing. Never ship it

7Getting errors out of goroutines

A goroutine cannot return anything — go f() discards the result — so an error has to be written somewhere the caller can read after Wait. The same one-slot-per-goroutine trick works: an []error sized to the input, then errors.Join to collapse it. Join drops the nils, returns nil if they were all nil, keeps input order, and the joined error still answers errors.Is and errors.As for each wrapped cause.

errors.go

Output

Prints cache ok, queue ok, then health check failed: and db: connection refused — the %w verb wrapped the cause with the service name, so the joined message says which check failed. This is golang.org/x/sync/errgroup written by hand; the real package adds two things worth having in production — it returns only the first error, and its WithContext constructor cancels the remaining goroutines as soon as one fails. It is not in the standard library, so it cannot run on this page, but the shape above is what it does underneath. To cap how many run at once, add a buffered chan struct{} as a semaphore around the body.

Frequently asked questions

How do I make main wait for a goroutine to finish in Go?

Use a sync.WaitGroup. Declare var wg sync.WaitGroup, call wg.Add(1) in the parent just before each go statement, put defer wg.Done() as the first line inside the goroutine, and call wg.Wait() where you need everything to be complete. Wait blocks until the counter returns to zero. Never use time.Sleep for this — it is a guess, and it is either too short (you lose work) or too slow.

Why does my Go program print nothing when I start a goroutine?

Because main returned first. The Go spec says the program exits when the main function completes, and it does not wait for other goroutines — so a goroutine that has not been scheduled yet is simply discarded along with the process. Add a sync.WaitGroup (or receive from a channel) so main blocks until the work is done.

Should wg.Add go inside or outside the goroutine?

Outside — in the parent, before the go statement. If you call wg.Add(1) as the first line inside the goroutine, the parent can reach wg.Wait() before that goroutine is ever scheduled; the counter is still zero, Wait returns immediately, and you have the same bug you were trying to fix. wg.Done() is the only one that belongs inside, and it should be deferred.

How do I collect return values from goroutines in Go?

A goroutine cannot return a value, so write it somewhere instead. The simplest race-free pattern is to pre-size a slice with results := make([]T, len(input)) and have goroutine i write only results[i] — distinct slice elements are distinct memory, so no lock is needed, and reading them after wg.Wait() is safe. Do not append to a shared slice or write to a shared map from several goroutines: append loses writes, and concurrent map writes crash the program with fatal error: concurrent map writes.

Do I need a mutex if I already use a WaitGroup?

They solve different problems. A WaitGroup tells you when goroutines have finished; it says nothing about whether their writes were safe. If every goroutine touches its own slice index or its own variable, no mutex is needed. If they share one map, counter or struct, guard it with sync.Mutex (or use sync/atomic for a single integer) — the WaitGroup will not protect it.

Why does my goroutine output print in a different order every run?

Because the scheduler decides which goroutine runs when, and that is deliberately not fixed. If goroutines print directly, the interleaving changes between runs. Make output deterministic by not printing from the goroutines at all: write each result into results[i] and print the slice after wg.Wait(), or collect through a channel and sort before printing.

How do I stop the other goroutines when one returns an error?

Pass a context.Context into each goroutine and have them return early when ctx.Done() fires, then cancel it on the first failure. golang.org/x/sync/errgroup packages exactly that: errgroup.WithContext(ctx) gives you a group whose context is cancelled as soon as any goroutine returns a non-nil error, and g.Wait() returns that first error. With the standard library only, collect an []error and combine it with errors.Join.