Golang WaitGroup: sync.WaitGroup Example, Add, Done, and Wait

Learn how to use sync.WaitGroup in Go to wait for goroutines, with Add, Done, Wait, WaitGroup.Go, common mistakes, and practical examples.

Published

Updated

Read time 7 min read

Reviewed byDeepak Prasad

Golang WaitGroup: sync.WaitGroup Example, Add, Done, and Wait

sync.WaitGroup is how you wait for goroutines to finish before main (or another coordinator) moves on. Without a wait, the process can exit while workers are still running. This page walks through the counter model (Add, Done, Wait), loop patterns, WaitGroup.Go on newer toolchains, channels and errors, comparisons with mutexes and channels, and mistakes that cause panics or deadlocks. For background, see goroutines in Go, channels, mutexes, and context.

Go 1.24 on Linux. WaitGroup.Go examples require Go 1.25 or later; they are marked non-runnable here when your toolchain is older.


What is sync.WaitGroup in Go?

A WaitGroup is a small counter in the sync package. You increment it before starting work (Add), each worker decrements it when finished (Done), and the coordinator blocks until the count hits zero (Wait). It answers “have all these goroutines finished?”—not “what did they compute?” and not “is shared memory safe?”


Why main can exit before goroutines finish

If you start a goroutine and return from main immediately, the runtime may shut down the program before the goroutine runs.

go
package main

import "fmt"

func main() {
	go func() {
		fmt.Println("this line may never print")
	}()
	// main returns; the program can exit here first
}

sync.WaitGroup (or a server loop, or receiving from a channel) is how you wait so shutdown happens after work completes.


Basic WaitGroup example

go
package main

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

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("worker finished")
		time.Sleep(20 * time.Millisecond)
	}()
	wg.Wait()
	fmt.Println("main finished")
}
Output

After Run you should see worker finished then main finished. The usual ordering is: Add before go, defer Done() inside the worker, then Wait in the goroutine that should block.


How Add, Done, and Wait work

Call Effect
wg.Add(n) increases the counter by n (often 1 per worker)
wg.Done() decreases the counter by one (same as Add(-1))
wg.Wait() blocks until the counter is zero

defer wg.Done() right after Add in the worker body is idiomatic so Done still runs on early return or panic (you may add recover in production if you must always unblock Wait).


Example: wait for multiple goroutines

go
package main

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

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			fmt.Printf("task %d\n", i)
			time.Sleep(50 * time.Millisecond)
		}(i)
	}
	wg.Wait()
	fmt.Println("all done")
}
Output

After Run you should see three task lines (order can vary) then all done.


WaitGroup with loops

Call wg.Add(1) inside the loop before each go, or call wg.Add(n) once before the loop if you launch exactly n goroutines.

Always pass the loop variable into the closure—do not close over i alone:

go
// Good: go func(i int) { ... }(i)
// Bad:  go func() { fmt.Println(i) }()  // i changes; classic bug

WaitGroup.Go (Go 1.25+)

Go 1.25 added (*WaitGroup).Go(f func()), which starts f in a new goroutine and manages the Add/Done bookkeeping for you (see WaitGroup.Go in the sync docs). On Go 1.24 and earlier, that method does not exist—keep using Add / defer Done / go.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Go(func() { fmt.Println("task A") })
	wg.Go(func() { fmt.Println("task B") })
	wg.Wait()
	fmt.Println("done")
}

The function passed to Go must not panic in a way you rely on silently; treat panics like any other worker (consider recover if you need guarantees). Prefer Add/Done when you support multiple Go versions or need explicit per-branch counting.


Passing a WaitGroup to functions

Pass a pointer (*sync.WaitGroup) if helpers call Add/Done/Wait. A WaitGroup must not be copied after first use—copying breaks the internal state and can mis-synchronize with Wait.

go
func worker(wg *sync.WaitGroup, id int) {
	defer wg.Done()
	// ...
}

WaitGroup with errors

WaitGroup only waits; it does not return errors from workers. Typical patterns:

  • send (result, err) on a channel and read after Wait
  • use golang.org/x/sync/errgroup for a group that shares context cancellation and aggregates the first error

Below, each worker sends at most one error (or nil) on a buffered channel; main waits, closes the channel, then drains—so you never read before producers finish.

go
package main

import (
	"errors"
	"fmt"
	"sync"
)

func main() {
	const n = 3
	var wg sync.WaitGroup
	errCh := make(chan error, n)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			if id == 1 {
				errCh <- fmt.Errorf("worker %d: %w", id, errors.New("boom"))
				return
			}
			errCh <- nil
		}(i)
	}
	wg.Wait()
	close(errCh)

	for err := range errCh {
		if err != nil {
			fmt.Println("seen:", err)
		}
	}
}
Output

After Run you should see one line starting with seen: for the failing worker. For first-error-only semantics, use a select with a non-blocking send or a dedicated errgroup worker pool.


WaitGroup with channels

A common pattern: launch workers that send results on a channel, call Wait, then close the results channel so one reader drains with for range. That avoids closing while producers still write. For a fuller pipeline sketch, see fan-out / fan-in.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	const workers = 3
	ch := make(chan int, workers)
	var wg sync.WaitGroup

	for w := 1; w <= workers; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			ch <- id * 10
		}(w)
	}
	wg.Wait()
	close(ch)

	sum := 0
	for v := range ch {
		sum += v
	}
	fmt.Println("sum", sum)
}
Output

After Run you should see sum 60 (that is 10 + 20 + 30, order of sends does not matter for the sum).


WaitGroup vs channels

  • WaitGroup: join a set of goroutines; no data path.
  • Channels: move values or signals between goroutines; often paired with a WaitGroup to know when it is safe to close.

The previous section is the usual combination: WaitGroup for “all writers finished,” channel for “here are the values.”


WaitGroup vs mutex

  • WaitGroup: completion only.
  • Mutex: protects shared mutable state. If workers update shared maps or structs, add a mutex (or channel discipline); a WaitGroup does not prevent data races by itself.

You often use both: wait until everyone is done, and serialize updates to shared data while they run.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	total := 0
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			total++
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("total", total)
}
Output

After Run you should see total 5.


Common WaitGroup mistakes

Calling Add inside the goroutine

Wrong:

go
package main

import "sync"

func main() {
	var wg sync.WaitGroup
	go func() {
		wg.Add(1)
		defer wg.Done()
	}()
	wg.Wait()
}

Wait can return before the goroutine runs Add, so the counter can hit zero too soon or you can race the counter updates.

Correct:

go
package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
	}()
	wg.Wait()
}

Forgetting Done or calling it too many times

Missing Done leaves Wait blocked forever. Extra Done drives the counter negative and panics.

Copying a WaitGroup

Do not pass WaitGroup by value to another function that mutates it; use a pointer.

Reusing a WaitGroup incorrectly

You may start another phase after Wait returns and the counter is zero; do not overlap new Add calls with an in-flight Wait unless you fully control ordering.

Waiting while a goroutine is stuck

If a worker blocks forever (channel receive with no sender, deadlock), Wait blocks forever too—fix the worker logic or use context cancellation.


Best practices

Call Add before go, use defer wg.Done() at the top of the worker, pass *sync.WaitGroup when sharing, and keep the lifecycle easy to read. Use context.Context to cancel long work; use errgroup or error channels when failures matter.


Golang WaitGroup cheat sheet

Situation Use
Wait for goroutines to finish sync.WaitGroup
Protect shared mutable data sync.Mutex or channels
Send results between goroutines channel
Cancel work context.Context
Collect errors from a group error channel or errgroup
Go 1.25+ shorthand for spawn + track wg.Go(func(){ ... })
Portable / explicit pattern Add(1) + go + defer Done() + Wait

Summary

sync.WaitGroup is a counter: Add declares how many goroutines you are waiting for, Done marks one finished (usually defer’d in the worker), and Wait blocks the coordinator until the count reaches zero—so main does not exit before goroutines finish. Loops need correct argument capture; WaitGroup.Go (Go 1.25+) reduces boilerplate but is not a replacement for mutexes or channels when you share state or stream data. For errors, add channels or errgroup; for cancellation, pair workers with context.


References


Frequently Asked Questions

1. What does sync.WaitGroup do in Go?

It counts outstanding goroutines: Add raises the counter, Done lowers it by one, Wait blocks until the counter is zero so the caller can wait for workers to finish.

2. Should I call Add before or after starting the goroutine?

Call Add in the coordinator before go starts the worker (or Add(n) once before a loop that launches n workers) so Wait never returns too early; Add inside the worker races with Wait.

3. What happens if Done is called too many times?

The counter goes negative and the program panics; keep Add and Done balanced and prefer defer wg.Done at the start of each worker.

4. What is WaitGroup.Go?

Go 1.25 added WaitGroup.Go(f) to run f in a new goroutine and manage Add/Done for you. On older releases, use Add(1), go func(){ defer wg.Done(); ... }(), and Wait.

5. Does WaitGroup protect shared variables?

No. It only waits for completion; use a mutex, channels, or atomic types if goroutines mutate shared state.

6. Can WaitGroup collect errors from workers?

No. Use an error channel, return values through a channel, or golang.org/x/sync/errgroup for coordinated errors and cancellation.
Antony Shikubu

Systems Integration Engineer

Highly skilled software developer with expertise in Python, Golang, and AWS cloud services.