How to Use sync.WaitGroup in Go

sync.WaitGroup blocks the main goroutine until all launched goroutines call Done() to signal completion.

The main goroutine exits too fast

You launch three goroutines to fetch user profiles, order history, and recommendations. You print a success message right after spawning them. The program exits instantly. The goroutines never run. The main goroutine finished and took the process down with it.

This happens because goroutines run concurrently. The main function continues executing immediately after the go statement. If main returns, the entire program terminates, killing every other goroutine in its wake. You need a mechanism to pause the main goroutine until the background work completes.

That mechanism is sync.WaitGroup. It acts as a counter that tracks how many goroutines are still working. The main goroutine blocks on the WaitGroup until the counter drops to zero.

A counter, not a signal

Think of a sync.WaitGroup as a checklist held by a project manager. You write down the number of tasks before anyone starts. Each worker crosses off a task when they finish. The manager blocks the exit door until the list is empty.

The WaitGroup doesn't care which task finishes first. It doesn't pass data. It only tracks the quantity of active work. When the count reaches zero, the wait ends.

WaitGroup is a counter, not a signal. It tracks quantity, not order.

Minimal pattern

Here's the smallest pattern: add the count, spawn the work, wait for the count to drop to zero.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	// Set the expected number of goroutines.
	// This must happen before the goroutines start.
	wg.Add(3)

	for i := 0; i < 3; i++ {
		go func(id int) {
			// Decrement the counter when this goroutine exits.
			// Defer ensures it runs even if the function panics.
			defer wg.Done()
			fmt.Printf("Worker %d running\n", id)
		}(i)
	}

	// Block until the counter reaches zero.
	// The program continues only after all goroutines call Done.
	wg.Wait()
	fmt.Println("All workers finished")
}

The Add call increments the internal counter. Each goroutine calls Done to decrement it. Wait blocks the calling goroutine until the counter is zero. The defer wg.Done() pattern is standard. It guarantees the counter updates even if the goroutine panics.

How the counter works

The WaitGroup struct contains internal state that tracks the counter. The operations Add, Done, and Wait are thread-safe. Multiple goroutines can call Done concurrently without data races. The main goroutine can call Wait while workers are still running.

You cannot copy a WaitGroup. If you try to pass it by value to a function, the compiler rejects the code with cannot use wg (variable of type sync.WaitGroup) as sync.WaitGroup value in argument. The struct contains unexported fields that prevent copying. Copying would duplicate the counter state, breaking the synchronization. Pass a pointer if you need to share it, or keep it in the same scope.

Never copy a WaitGroup. The compiler enforces this rule strictly.

Realistic batch processing

In real code, you usually drive the WaitGroup from a loop over a slice of work items. You often need to collect results. Pre-allocating a slice and writing by index avoids the need for a mutex.

// fetchAll downloads data from multiple URLs concurrently.
// It returns a slice of results indexed by the input order.
func fetchAll(urls []string) []string {
	var wg sync.WaitGroup
	// Pre-allocate the results slice to match the input length.
	// This allows safe concurrent writes by index.
	results := make([]string, len(urls))

	// Increment the counter for the total number of tasks.
	// Call Add once before spawning goroutines to avoid races.
	wg.Add(len(urls))

	for i, url := range urls {
		go func(idx int, target string) {
			// Decrement counter when this task finishes.
			// Defer guarantees execution even if a panic occurs.
			defer wg.Done()

			// Simulate fetching. Write to the pre-allocated slot.
			// Each goroutine writes to a unique index, so no mutex is needed.
			results[idx] = fmt.Sprintf("fetched %s", target)
		}(i, url)
	}

	// Block until all goroutines have called Done.
	// The slice is fully populated only after this returns.
	wg.Wait()
	return results
}

Notice the loop variable capture. We pass i and url as arguments to the closure. If we captured the loop variable directly, all goroutines would see the final value from the last iteration. This is a common trap. Go 1.22 changed loop semantics to fix this, but passing variables explicitly remains the safest habit. It makes the dependency clear and works across all versions.

Pre-allocate your results. Write by index. WaitGroup handles the timing.

Handling errors and panics

WaitGroup tracks completion, not success. If a goroutine fails, Done still runs. The caller sees everything finished, but doesn't know about the error. You need a separate mechanism to collect errors. A mutex protects a shared error variable, or you can use a channel.

// fetchWithErrors demonstrates collecting errors alongside a WaitGroup.
// WaitGroup handles timing; a mutex protects the error variable.
func fetchWithErrors(urls []string) error {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var firstErr error

	wg.Add(len(urls))
	for _, url := range urls {
		go func(target string) {
			defer wg.Done()

			// Simulate a failure on specific URLs.
			if target == "https://broken.com" {
				mu.Lock()
				// Store the first error encountered.
				// Subsequent errors are ignored to keep logic simple.
				if firstErr == nil {
					firstErr = fmt.Errorf("failed to fetch %s", target)
				}
				mu.Unlock()
				return
			}
		}(url)
	}

	wg.Wait()
	return firstErr
}

If you call Done more times than Add, the program panics with sync: negative WaitGroup counter. This usually means a goroutine ran twice or Done wasn't deferred. If you call Wait before Add, you get sync: WaitGroup is missing Add. The counter starts at zero, so Wait returns immediately, defeating the purpose.

If a goroutine panics and you didn't use defer wg.Done(), the counter never decrements. Wait blocks forever. The program hangs. This is why defer is essential. It guarantees the counter updates even if the work crashes.

Defer Done. Count carefully. Panic means your math is wrong.

When to use WaitGroup

Use sync.WaitGroup when you spawn multiple goroutines and need the caller to block until all of them complete.

Use a channel when you need to pass data between goroutines or signal a specific event rather than just waiting for completion.

Use context.Context when you need to propagate cancellation or deadlines to long-running tasks.

Use sync.Once when you have a one-time initialization that must run exactly once, even if called from multiple goroutines.

Use sequential code when the task is simple and fast; concurrency introduces complexity that isn't worth the speedup for trivial work.

WaitGroup waits. Channels communicate. Context cancels. Pick the tool that matches the shape of your problem.

Where to go next