WaitGroups — Junior¶
← Back to WaitGroups
"How do I wait for all my goroutines to finish?"
This is the very first concurrency problem every Go beginner runs into. You spawn a few goroutines with go f(), you reach the end of main, and... your program exits before the goroutines have a chance to do anything. The screen stays empty. You sprinkle a time.Sleep(time.Second) everywhere and tell yourself it's fine. It is not fine.
This page introduces sync.WaitGroup — Go's idiomatic way to wait for a known set of goroutines to finish. By the end you'll know how to use Add, Done, and Wait, why defer wg.Done() is universal, and why the WaitGroup must always be passed by pointer.
1. The "main exits too early" problem¶
Here is the canonical broken program. Read it and predict the output before you run it.
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println("goroutine", i)
}(i)
}
fmt.Println("main done")
}
What you might expect:
What you actually get on most runs:
That's it. The five goroutines were scheduled but never given a chance to run, because Go's runtime kills every goroutine the moment main returns.
You need a way for main to block until the goroutines have finished. That tool is sync.WaitGroup.
2. The three methods of sync.WaitGroup¶
A WaitGroup is a counting semaphore that starts at zero. It exposes three methods:
| Method | Effect |
|---|---|
Add(n) | Increase the counter by n. You call this before spawning goroutines. |
Done() | Decrease the counter by 1. The goroutine calls this when it finishes. |
Wait() | Block until the counter reaches zero. |
Mental model:
+-----------------------+
| WaitGroup counter |
+-----------------------+
|
| Add(1) ───► counter++
| Done() ───► counter-- (same as Add(-1))
| Wait() ───► block until counter == 0
That is the entire API. Three methods, one counter.
3. Fixing the broken program¶
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // increment BEFORE the go statement
go func(i int) {
defer wg.Done() // decrement when the goroutine ends
fmt.Println("goroutine", i)
}(i)
}
wg.Wait() // block here until counter is 0
fmt.Println("main done")
}
Sample output (order will vary):
Three things to notice:
wg.Add(1)runs in the parent goroutine, before thegokeyword.defer wg.Done()is the very first line of the goroutine body.wg.Wait()is at the bottom ofmain. The line "main done" is reliably last.
These three rules are the heart of WaitGroup correctness. Memorise them.
4. Why Add must come before go¶
Beginners often try a small "improvement":
for i := 0; i < 5; i++ {
go func(i int) {
wg.Add(1) // BUG: inside the goroutine
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
Looks symmetrical. It is broken. Here is why.
When the loop finishes and wg.Wait() is called, the counter might still be 0 because none of the goroutines have started yet. Wait() returns immediately, main exits, goroutines are killed mid-flight. You may see 0, 1, or 5 lines on different runs — classic race condition.
The rule is:
Always call
Addfrom the goroutine that will eventually callWait, before launching the goroutine that will callDone.
The Go documentation states this directly: "Note that calls with a positive delta that occur when the counter is zero must happen before a Wait."
5. The defer wg.Done() reflex¶
Inside the goroutine, the very first line should be defer wg.Done(). Why?
go func() {
defer wg.Done()
doSomething() // even if this panics, Done() still runs
doSomethingElse()
return // even on early return, Done() still runs
}()
If you place wg.Done() at the bottom of the function instead, an early return or a panic will skip it and your program will hang forever inside wg.Wait(). The defer form makes Done unconditional.
Compare:
// BAD — Done is skipped on early return
go func() {
if !connect() {
return // wg counter never decremented, Wait hangs
}
process()
wg.Done()
}()
This is so universal that "defer wg.Done()" is one of the most-typed lines in Go.
6. Always pass a WaitGroup by pointer¶
A sync.WaitGroup contains internal state — a counter and a semaphore — that must be shared across all goroutines that touch it. If you copy the value, each copy has its own counter, and Done on one copy does not decrement the counter of the other.
// BAD — wg passed by value
func worker(wg sync.WaitGroup) {
defer wg.Done() // decrements a LOCAL copy
// ...
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(wg) // copy made here
wg.Wait() // hangs forever — the original counter is never decremented
}
The compiler will warn you with go vet:
The fix is always the same: pass *sync.WaitGroup.
// GOOD — pointer
func worker(wg *sync.WaitGroup) {
defer wg.Done()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(&wg)
wg.Wait()
}
This is so important that we'll repeat it in every section: a WaitGroup must never be copied after first use.
7. A practical example: parallel HTTP fetches¶
Let's apply the WaitGroup to something realistic — fetching several URLs concurrently and printing each response's status.
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func fetch(url string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("%-30s ERROR: %v\n", url, err)
return
}
defer resp.Body.Close()
fmt.Printf("%-30s %s in %v\n", url, resp.Status, time.Since(start))
}
func main() {
urls := []string{
"https://go.dev",
"https://pkg.go.dev",
"https://example.com",
"https://www.google.com",
}
var wg sync.WaitGroup
start := time.Now()
for _, u := range urls {
wg.Add(1)
go fetch(u, &wg)
}
wg.Wait()
fmt.Printf("\nall done in %v\n", time.Since(start))
}
Three things this shows:
- The total time is roughly the time of the slowest request, not the sum.
defer wg.Done()covers both the success path and the error path.- We pass
&wgbecause the goroutine needs to share the same counter asmain.
8. Add(N) instead of looping with Add(1)¶
When you know up front how many goroutines you'll start, you can call Add once with the total count.
var wg sync.WaitGroup
wg.Add(len(urls)) // single Add call
for _, u := range urls {
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait()
Both styles are correct. The single-Add form is slightly more efficient (one atomic increment instead of N) but Add(1) inside the loop is fine and arguably more readable when the loop body is dynamic.
9. WaitGroup vs sleeping¶
Beginners frequently "fix" the missing-wait problem with time.Sleep.
for i := 0; i < 5; i++ {
go work(i)
}
time.Sleep(2 * time.Second) // hope the goroutines are done by now
This is wrong for several reasons:
| Problem | Explanation |
|---|---|
| Wasteful | If the work finishes in 50ms you still wait 2s. |
| Incorrect | If the work takes 3s, you cut it off and exit too early. |
| Non-portable | Same code may pass on a fast machine and fail in CI. |
| Hides bugs | A racing Add/Done can pass tests by accident if Sleep is "long enough". |
WaitGroup gives you the exact answer with no guessing.
10. The goroutine-counts-itself pattern (anti-pattern)¶
You'll sometimes see code like this:
done := make(chan struct{}, 5)
for i := 0; i < 5; i++ {
go func(i int) {
work(i)
done <- struct{}{}
}(i)
}
for i := 0; i < 5; i++ {
<-done
}
This is valid — the buffered channel acts as a counter. But it forces you to know 5 in two different places. WaitGroup centralises that:
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
work(i)
}(i)
}
wg.Wait()
The done-channel pattern is still useful when you also want to receive results; we'll see that in the senior page.
11. What Wait actually does¶
wg.Wait():
- If counter == 0, it returns immediately.
- If counter > 0, it parks the calling goroutine on a semaphore.
- When the counter hits 0 (the last
Done), every parked waiter is released.
You may have multiple goroutines call Wait. They will all block and all be released together when the counter reaches zero. This is rare in practice but legal.
var wg sync.WaitGroup
wg.Add(1)
go func() { wg.Wait(); fmt.Println("waiter A") }()
go func() { wg.Wait(); fmt.Println("waiter B") }()
time.Sleep(100 * time.Millisecond)
wg.Done() // releases both A and B
12. Negative counter ⇒ panic¶
If you call Done more times than Add, the counter goes negative and the runtime panics.
The panic message is intentionally loud:
panic: sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(...)
.../sync/waitgroup.go:79
sync.(*WaitGroup).Done(...)
.../sync/waitgroup.go:104
main.main()
./main.go:13 +0x...
It is not a soft error — your program crashes immediately. This is on purpose: a negative counter means you have a logic bug and it's better to fail loudly than to deadlock or under-count.
13. Forgotten Done ⇒ deadlock¶
The opposite mistake — calling Done fewer times than Add — is just as dangerous, and harder to detect.
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); doWork() }()
go func() { /* forgot defer wg.Done() */ doWork() }()
wg.Wait() // hangs forever, counter stuck at 1
The Go runtime will detect this in the special case where every goroutine is blocked:
But if any other goroutine is alive (e.g. an HTTP server running), the program just hangs silently. The defer wg.Done() reflex is your safety net.
14. Quick reference card¶
┌──────────────────────────────────────────────────────┐
│ The WaitGroup checklist │
├──────────────────────────────────────────────────────┤
│ 1. var wg sync.WaitGroup │
│ 2. wg.Add(n) BEFORE go statement │
│ 3. defer wg.Done() FIRST line of the goroutine │
│ 4. wg.Wait() in the parent │
│ 5. pass *sync.WaitGroup, never sync.WaitGroup │
└──────────────────────────────────────────────────────┘
If you can produce this checklist from memory, you can use a WaitGroup correctly.
15. Walkthrough: parallel checksum of files¶
Let's combine everything in one realistic program. Goal: take a list of file paths, compute the SHA-256 of each one in parallel, and print the result.
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"sync"
)
func checksum(path string, wg *sync.WaitGroup) {
defer wg.Done()
f, err := os.Open(path)
if err != nil {
fmt.Printf("%-30s ERROR: %v\n", path, err)
return
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
fmt.Printf("%-30s ERROR: %v\n", path, err)
return
}
fmt.Printf("%-30s %s\n", path, hex.EncodeToString(h.Sum(nil)))
}
func main() {
paths := os.Args[1:]
if len(paths) == 0 {
fmt.Println("usage: checksum file1 file2 ...")
return
}
var wg sync.WaitGroup
wg.Add(len(paths))
for _, p := range paths {
go checksum(p, &wg)
}
wg.Wait()
}
Run it:
The same program with time.Sleep would be a horror show. With WaitGroup it's robust and minimal.
16. What about errors?¶
You'll have noticed our examples print errors instead of returning them. That's because WaitGroup does not propagate errors — it only counts. To collect errors from goroutines, you have three options at the junior level:
- Print/log them inside the goroutine.
- Send them on a buffered channel and drain it after
Wait. - Use
golang.org/x/sync/errgroup(covered in middle/senior).
A simple channel approach:
errs := make(chan error, len(paths))
for _, p := range paths {
wg.Add(1)
go func(p string) {
defer wg.Done()
if err := checksum(p); err != nil {
errs <- err
}
}(p)
}
wg.Wait()
close(errs)
for err := range errs {
fmt.Fprintln(os.Stderr, err)
}
Notice the order: wg.Wait() first, then close(errs), then drain. Closing before all senders finish would panic.
17. A common confusion: WaitGroup is not a counter you can read¶
There is no wg.Count() method. You cannot ask "how many goroutines are still alive?" via the WaitGroup API. The counter is internal.
If you need that information, you have to maintain it yourself with atomic.Int64 or a separate channel.
18. Things to internalise this week¶
- The three-method API:
Add,Done,Wait. - Always
Addbefore thegostatement. - Always
defer wg.Done()as the first line of the goroutine. - Always pass
*sync.WaitGroup. - Negative counter panics; missing
Donedeadlocks. - WaitGroup waits, it doesn't return values or errors.
Once these are second nature, you can move on to the middle page where we cover dynamic spawn, struct embedding, errgroup, and the rules around reuse.
19. Self-check¶
Try answering without re-reading:
- What happens if you call
Waitand the counter is already zero? - Why is
wg.Add(1)inside the goroutine wrong? - What error message do you see if you copy a WaitGroup?
- What error message do you see if
Doneis called too many times? - What happens if
Doneis called too few times? - Why is
defer wg.Done()better thanwg.Done()at the end? - Can two goroutines call
Waiton the same WaitGroup? - Is there a way to read the current counter value? (No.)
If you got six or more, you're ready for middle.md.
20. Going deeper¶
- middle.md — patterns, error handling, dynamic spawn, reuse rules
- senior.md — memory model, errgroup, context cancellation
- find-bug.md — fix broken WaitGroup programs
- tasks.md — exercises to cement the basics
Welcome to concurrent Go. The WaitGroup is one of the smallest tools you'll ever learn, and one of the most useful.