Waiting for Goroutines with sync.WaitGroup

How sync.WaitGroup works, the Add, Done and Wait rules, the panic messages you get when they are misused, and collecting results safely.

sync.WaitGroup answers one question: "have all the goroutines I started finished yet?" It is a counter. You add to it before starting work, each goroutine subtracts one when it is done, and Wait blocks until the counter reaches zero.

The Three Methods

package main

import (
	"fmt"
	"sync"
	"time"
)

func download(name string) {
	time.Sleep(20 * time.Millisecond)
	fmt.Println("downloaded", name)
}

func main() {
	files := []string{"a.zip", "b.zip", "c.zip"}

	var wg sync.WaitGroup
	for _, f := range files {
		wg.Add(1)
		go func() {
			defer wg.Done()
			download(f)
		}()
	}
	wg.Wait()
	fmt.Println("all downloads finished")
}
  • wg.Add(1) adds one to the counter for each goroutine.
  • wg.Done() subtracts one. Calling it with defer guarantees it runs even if the function returns early.
  • wg.Wait() blocks until the counter is back to zero.

The three downloads finish in any order, and all downloads finished is always printed last. The total time is about one download, not three, because they run at the same time.

Call Add Before Starting the Goroutine

Add must happen in the goroutine that will call Wait, before the go statement. If you call Add inside the new goroutine, Wait might run first, see a counter of zero, and return too early.

// wrong: Add may run after Wait
go func() {
	wg.Add(1)
	defer wg.Done()
	work()
}()
wg.Wait()

Negative counter panic:

Calling Done more times than Add crashes with panic: sync: negative WaitGroup counter. This usually means Done is called both with defer and again at the end of the function, or Add was skipped on one code path.

Passing a WaitGroup to a Function

A WaitGroup must not be copied after first use. If you pass it to a function, pass a pointer:

package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("worker", id, "done")
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
}

If you write worker(id int, wg sync.WaitGroup), each goroutine gets its own copy, calls Done on the copy, and the original never reaches zero. main then waits forever and the program crashes with fatal error: all goroutines are asleep - deadlock!. go vet warns about this with passes lock by value: sync.WaitGroup contains sync.noCopy.

Prefer closures over passing the WaitGroup:

A common style keeps wg.Done() in a small closure and leaves the worker function unaware of the WaitGroup: go func() { defer wg.Done(); worker(i) }(). The worker stays easy to test and to call without goroutines.

Collecting Results Safely

Goroutines that write results into a shared slice or map need care. Writing to different indexes of a slice that already has its final length is safe:

package main

import (
	"fmt"
	"sync"
)

func square(n int) int { return n * n }

func main() {
	inputs := []int{2, 3, 4, 5}
	results := make([]int, len(inputs))

	var wg sync.WaitGroup
	for i, n := range inputs {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = square(n)
		}()
	}
	wg.Wait()
	fmt.Println(results)
}

This always prints [4 9 16 25], in input order, because each goroutine owns exactly one slot. Using append on a shared slice or writing to a shared map from several goroutines is not safe and needs a mutex. A map written concurrently can crash with fatal error: concurrent map writes.

WaitGroup.Go in Newer Go

Go 1.25 added a Go method that does Add, go and Done for you:

// Go 1.25 and newer
var wg sync.WaitGroup
for _, f := range files {
	wg.Go(func() { download(f) })
}
wg.Wait()

It removes the most common mistakes, a missing Add or Done. On older versions, use the Add and defer Done pattern.

Correct WaitGroup usage:

Add before go, defer wg.Done() as the first line inside the goroutine, Wait in the goroutine that called Add, and the WaitGroup never copied. If your code follows those four rules, it will not wait forever or finish early.

Summary

A WaitGroup counts running goroutines: Add before starting each one, Done when it ends, and Wait to block until all are finished. Never copy it, pass a pointer or use a closure. Write results into separate slice slots or protect shared data with a mutex, and on Go 1.25 and newer, wg.Go handles the counting for you.