Skip to main content
To wait for multiple goroutines to finish, you can use a wait group from the sync package. WaitGroups provide a clean way to coordinate goroutine completion.

Basic WaitGroup Usage

package main

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

func worker(id int) {
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup

	// Launch several goroutines
	for i := 1; i <= 5; i++ {
		wg.Go(func() {
			worker(i)
		})
	}

	// Wait for all goroutines to complete
	wg.Wait()
	fmt.Println("All workers done")
}
The WaitGroup.Go() method automatically handles Add(1) and Done() for you, making it the recommended way to launch goroutines.

WaitGroup Methods

Add(delta int)

Increment the WaitGroup counter:
var wg sync.WaitGroup
wg.Add(1)  // Increment by 1
wg.Add(5)  // Increment by 5

Done()

Decrement the counter by 1:
go func() {
	defer wg.Done()  // Decrement when goroutine finishes
	// Do work
}()

Wait()

Block until counter reaches zero:
wg.Wait()  // Blocks until all Done() calls complete

Go(func())

Launch a goroutine with automatic Add/Done (Go 1.21+):
wg.Go(func() {
	// Work is automatically wrapped with Add(1) and Done()
	doWork()
})

Traditional Pattern (Before Go 1.21)

var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
	wg.Add(1)  // Increment counter
	
	go func(id int) {
		defer wg.Done()  // Decrement when done
		worker(id)
	}(i)
}

wg.Wait()  // Wait for all to finish
Always use defer wg.Done() to ensure the counter is decremented even if the goroutine panics.

Modern Pattern (Go 1.21+)

var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
	wg.Go(func() {
		worker(i)  // Add/Done handled automatically
	})
}

wg.Wait()

Passing WaitGroup to Functions

When passing a WaitGroup to a function, always pass it by pointer, never by value.
// CORRECT: Pass by pointer
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go worker(i, &wg)  // Pass pointer
	}
	wg.Wait()
}
// WRONG: Passing by value creates a copy
func worker(id int, wg sync.WaitGroup) {  // BAD!
	defer wg.Done()  // Decrements the COPY, not original
	fmt.Printf("Worker %d\n", id)
}

Common Patterns

1. Fixed Number of Workers

var wg sync.WaitGroup
numWorkers := 10

for i := 0; i < numWorkers; i++ {
	wg.Go(func() {
		doWork()
	})
}

wg.Wait()

2. Processing a Slice

items := []Item{item1, item2, item3}
var wg sync.WaitGroup

for _, item := range items {
	wg.Go(func() {
		process(item)
	})
}

wg.Wait()

3. Worker Pool with WaitGroup

func processJobs(jobs []Job, numWorkers int) {
	var wg sync.WaitGroup
	jobChan := make(chan Job, len(jobs))
	
	// Start workers
	for i := 0; i < numWorkers; i++ {
		wg.Go(func() {
			for job := range jobChan {
				job.Execute()
			}
		})
	}
	
	// Send jobs
	for _, job := range jobs {
		jobChan <- job
	}
	close(jobChan)
	
	// Wait for all workers
	wg.Wait()
}

4. Nested WaitGroups

func processInBatches(items []Item) {
	var outerWg sync.WaitGroup
	batchSize := 10
	
	for i := 0; i < len(items); i += batchSize {
		batch := items[i:min(i+batchSize, len(items))]
		
		outerWg.Go(func() {
			var innerWg sync.WaitGroup
			
			for _, item := range batch {
				innerWg.Go(func() {
					process(item)
				})
			}
			
			innerWg.Wait()  // Wait for batch
		})
	}
	
	outerWg.Wait()  // Wait for all batches
}

Error Handling with WaitGroups

WaitGroups don’t provide built-in error handling. Here are common approaches:

1. Collect Errors in Slice

var (
	wg   sync.WaitGroup
	mu   sync.Mutex
	errs []error
)

for _, item := range items {
	wg.Go(func() {
		if err := process(item); err != nil {
			mu.Lock()
			errs = append(errs, err)
			mu.Unlock()
		}
	})
}

wg.Wait()

if len(errs) > 0 {
	fmt.Printf("Encountered %d errors\n", len(errs))
}

2. Use Error Channel

var wg sync.WaitGroup
errorChan := make(chan error, len(items))

for _, item := range items {
	wg.Go(func() {
		if err := process(item); err != nil {
			errorChan <- err
		}
	})
}

wg.Wait()
close(errorChan)

for err := range errorChan {
	fmt.Println("Error:", err)
}

3. Use errgroup Package

import "golang.org/x/sync/errgroup"

func processItems(items []Item) error {
	var g errgroup.Group
	
	for _, item := range items {
		g.Go(func() error {
			return process(item)
		})
	}
	
	// Returns first error encountered
	return g.Wait()
}
For advanced error handling, use the errgroup package from golang.org/x/sync/errgroup. It provides WaitGroup functionality with error propagation.

Practical Examples

Concurrent HTTP Requests

func fetchURLs(urls []string) map[string]string {
	var wg sync.WaitGroup
	var mu sync.Mutex
	results := make(map[string]string)
	
	for _, url := range urls {
		wg.Go(func() {
			resp, err := http.Get(url)
			if err != nil {
				return
			}
			defer resp.Body.Close()
			
			body, _ := io.ReadAll(resp.Body)
			
			mu.Lock()
			results[url] = string(body)
			mu.Unlock()
		})
	}
	
	wg.Wait()
	return results
}

File Processing

func processFiles(filenames []string) error {
	var (
		wg   sync.WaitGroup
		mu   sync.Mutex
		errs []error
	)
	
	for _, filename := range filenames {
		wg.Go(func() {
			if err := processFile(filename); err != nil {
				mu.Lock()
				errs = append(errs, err)
				mu.Unlock()
			}
		})
	}
	
	wg.Wait()
	
	if len(errs) > 0 {
		return fmt.Errorf("failed to process %d files", len(errs))
	}
	return nil
}

Database Queries

func executeQueries(queries []string, db *sql.DB) []QueryResult {
	var (
		wg      sync.WaitGroup
		mu      sync.Mutex
		results []QueryResult
	)
	
	for _, query := range queries {
		wg.Go(func() {
			rows, err := db.Query(query)
			if err != nil {
				mu.Lock()
				results = append(results, QueryResult{Error: err})
				mu.Unlock()
				return
			}
			defer rows.Close()
			
			data := scanRows(rows)
			
			mu.Lock()
			results = append(results, QueryResult{Data: data})
			mu.Unlock()
		})
	}
	
	wg.Wait()
	return results
}

WaitGroup with Context

func processWithContext(ctx context.Context, items []Item) error {
	var wg sync.WaitGroup
	errorChan := make(chan error, len(items))
	
	for _, item := range items {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		
		wg.Go(func() {
			if err := processItem(ctx, item); err != nil {
				errorChan <- err
			}
		})
	}
	
	wg.Wait()
	close(errorChan)
	
	for err := range errorChan {
		if err != nil {
			return err
		}
	}
	
	return nil
}

Common Mistakes

Adding Inside Goroutine

// BAD: Race condition
for i := 0; i < 5; i++ {
	go func() {
		wg.Add(1)  // TOO LATE! May race with Wait()
		defer wg.Done()
		doWork()
	}()
}
wg.Wait()

// GOOD: Add before launching
for i := 0; i < 5; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		doWork()
	}()
}
wg.Wait()

// BEST: Use wg.Go() (Go 1.21+)
for i := 0; i < 5; i++ {
	wg.Go(doWork)
}
wg.Wait()

Passing by Value

// BAD: Copies WaitGroup
func worker(wg sync.WaitGroup) {  // Passed by value!
	defer wg.Done()  // Decrements copy, not original
}

// GOOD: Pass by pointer
func worker(wg *sync.WaitGroup) {
	defer wg.Done()  // Decrements original
}

Reusing WaitGroup

// BAD: Counter may not be zero
var wg sync.WaitGroup
wg.Add(5)
// ... goroutines ...
wg.Wait()

wg.Add(3)  // Unsafe if previous goroutines still running!

// GOOD: Create new WaitGroup
wg1 := &sync.WaitGroup{}
wg1.Add(5)
// ... batch 1 ...
wg1.Wait()

wg2 := &sync.WaitGroup{}
wg2.Add(3)
// ... batch 2 ...
wg2.Wait()

Forgetting Done()

// BAD: Deadlock - Wait() blocks forever
wg.Add(1)
go func() {
	// Forgot wg.Done()!
	doWork()
}()
wg.Wait()  // Blocks forever

// GOOD: Always use defer
wg.Add(1)
go func() {
	defer wg.Done()
	doWork()
}()
wg.Wait()

Performance Considerations

WaitGroups are very lightweight. The overhead per Add/Done operation is ~20-30 nanoseconds.

Benchmarks (approximate)

  • Add(): ~20 ns
  • Done(): ~20 ns
  • Wait(): ~30 ns (when counter is zero)
  • Memory: ~24 bytes per WaitGroup

Best Practices

  1. Use wg.Go() - Simplest and safest (Go 1.21+)
  2. Add before goroutine - Avoid race conditions
  3. Pass by pointer - Never pass WaitGroup by value
  4. Use defer for Done() - Ensures cleanup even on panic
  5. Consider errgroup - For error handling scenarios
  6. Create fresh WaitGroups - Don’t reuse across batches
  7. Document ownership - Make it clear who calls Add/Done/Wait

WaitGroup vs Alternatives

PatternUse When
WaitGroupWait for N goroutines to complete
ChannelNeed result or error from goroutine
ContextNeed cancellation or timeout
errgroupNeed error handling from workers

Build docs developers (and LLMs) love