sync.WaitGroup is a synchronization counter to wait for several goroutines to finish. It is the clean way to say: do not continue until all have finished.
In the previous article we used time.Sleep provisionally to wait for the goroutines to finish.
That is unacceptable in a real environment. If you put a Sleep(5 seconds) and the task takes 10ms, you are wasting 4.99 seconds. If the task takes 6 seconds, the program will close before finishing and you will lose data.
We need a way to tell the main program: “Do not close until these X goroutines have finished”.
For that, the standard library sync offers WaitGroup.
What is a WaitGroup
A WaitGroup is, in essence, a thread-safe counter.
Its operation is very simple:
- Increment the counter when you launch a task.
- Decrement the counter when a task finishes.
- Block the goroutine that calls
Waituntil the counter reaches zero.
Key methods
To use it, we need to import the sync package and know these methods:
Add(n int): Addsnto the counter. We generally doAdd(1)just before launching the goroutine.Done(): Subtracts 1 from the counter. It is called inside the goroutine when it has finished.Wait(): Blocks execution until the internal counter is 0.Go(f func()): Launches a function in a goroutine and automatically adds it to the group (Go 1.25+).
Basic example
Let’s fix the example from the previous article.
package main
import (
"fmt"
"sync" // 1. Import sync
"time"
)
func worker(id int, wg *sync.WaitGroup) {
// 3. When the function finishes, we notify the WaitGroup
// We use defer to ensure it executes upon return
defer wg.Done()
fmt.Printf("Worker %d starting...\n", id)
time.Sleep(time.Second) // Simulate work
fmt.Printf("Worker %d completed\n", id)
}
func main() {
// Define the WaitGroup (its zero value is already usable)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
// 2. Increment the counter BEFORE launching the goroutine
wg.Add(1)
// Launch the goroutine passing the pointer to the WaitGroup
go worker(i, &wg)
}
fmt.Println("Main is waiting...")
// 4. Block until all finish (counter == 0)
wg.Wait()
fmt.Println("All work has finished. Goodbye.")
}In the classic pattern, call wg.Add(1) before the go statement. If you do it inside, wg.Wait() could observe the counter at zero before the goroutine has started.
The modern shortcut: WaitGroup.Go
Since Go 1.25, sync.WaitGroup has the Go method, which groups the Add(1), go func(), and Done() pattern into a single call.
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
id := i
wg.Go(func() {
fmt.Printf("Worker %d starting...\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d completed\n", id)
})
}
wg.Wait()
}It is more compact and avoids mismatches between Add and Done. Its contract requires that the function does not cause a panic; if an operation can fail in an expected way, we must manage or communicate its error within the task.
Do not copy a WaitGroup
Notice the signature of the worker function in the previous example:
func worker(id int, wg *sync.WaitGroup)
We are passing a Pointer. Why?
A sync.WaitGroup is a Struct. In Go, everything is passed by value (copy).
If you pass the WaitGroup without a pointer, the goroutine receives a photocopy of the WaitGroup.
- The goroutine calls
Done()on its photocopy. mainwaits on the original WaitGroup.- The original never reaches zero.
- Result: the original never reaches zero and
Waitcould be blocked indefinitely.
Never copy a WaitGroup. If you have to pass it to functions, always use a pointer *sync.WaitGroup.
Use with anonymous functions
The most common practice in Go is not to create an external function like worker, but to launch anonymous functions inside the loop. Here, closures shine.
func main() {
var wg sync.WaitGroup
urls := []string{"http://google.com", "http://spotify.com", "http://luisllamas.es"}
for _, url := range urls {
wg.Add(1)
// Launch anonymous function
go func(u string) {
defer wg.Done()
// Simulate request
fmt.Printf("Visiting %s\n", u)
}(url) // Pass 'url' as argument
}
wg.Wait()
}The closure captures the variable wg, so it works with the same instance from the main scope. We do not need to pass it as a parameter.
WaitGroup vs channels
It’s important to understand when to use what.
- Use WaitGroups when you only care about waiting for a set of tasks to finish, but you do not need them to return results.
- Use Channels (which we will see next) when you need goroutines to communicate or return data to you.