Skip to content
gohacks.dev
Concurrency

WaitGroup Go Arrived in Go 1.25 and Why It Matters

WaitGroup.Go in Go 1.25 starts a goroutine and handles Add and Done for you. See the before and after, what it removes, and where it still falls short.

4 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Go 1.25 added one method to sync.WaitGroup, and it deletes a lot of boilerplate. If you have written wg.Add(1) and defer wg.Done() more than a few times, you have also written the bugs that come with them, which the WaitGroup mistakes article lists. The new method makes those bugs structurally impossible.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This note shows the before and after, what the method guarantees, and the limits worth knowing. It is part of the concurrency material and relates to goroutines explained. Everything ran on Go 1.25.6.

Before and after

The old form needed three coordinated pieces:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var wg sync.WaitGroup
	var total atomic.Int64

	for i := 1; i <= 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			total.Add(int64(i))
		}()
	}
	wg.Wait()
	fmt.Println(total.Load())
}
Text
10

The new form is one call per goroutine:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var wg sync.WaitGroup
	var total atomic.Int64

	for i := 1; i <= 4; i++ {
		wg.Go(func() {
			total.Add(int64(i))
		})
	}
	wg.Wait()
	fmt.Println(total.Load())
}
Text
10

Both print 10. The second has no Add, no Done and no go keyword. The method's documentation puts it in one sentence: Go calls f in a new goroutine and adds that task to the WaitGroup. When f returns, the task is removed. The Add is performed synchronously, in the calling goroutine, before the new goroutine starts, so Wait cannot observe a zero counter in the gap that made Add-inside-the-goroutine a bug.

What it removes

Three of the classic mistakes disappear:

  • Add in the wrong place. There is no Add to misplace; the method does it at the right moment.
  • A forgotten Done. The removal happens when f returns, on every path, including early return statements inside f.
  • Copying the group. The closure captures the WaitGroup by reference, and you do not pass it around.

It also reads better in code review: each wg.Go is a complete, self-contained statement, and the reviewer does not need to check that three lines in different places are consistent.

What it does not do

No arguments, no results. f is func(). Pass values by capturing them (since Go 1.22, loop variables are per-iteration, so capturing them is safe), and return results through a slot or a channel:

main.goGo
package main

import (
	"fmt"
	"strings"
	"sync"
)

func main() {
	words := []string{"go", "channels", "select"}
	lengths := make([]int, len(words))

	var wg sync.WaitGroup
	for i, w := range words {
		wg.Go(func() {
			lengths[i] = len(strings.ToUpper(w))
		})
	}
	wg.Wait()
	fmt.Println(lengths)
}
Text
[2 8 6]

Each goroutine writes to its own element, so no lock is needed.

No errors and no cancellation. A WaitGroup only counts. If the functions can fail and you want the first error or to stop the others, use errgroup, whose Go method takes a func() error (errgroup).

No recover, and f must not panic. The documentation says so in as many words: the function must not panic. A panic in f is not caught by Go, and an unrecovered panic in any goroutine ends the program. If the work may panic and the program must survive, recover inside f and report the failure through a channel or an error slot (recover).

Goroutines can start more goroutines. The documentation notes that if the group is not empty, Go may be called at any time, so a goroutine started by Go can call wg.Go itself, and Wait will wait for the new one too. The one rule is ordering: when the group is empty, the first Go must happen before Wait, and when a group is reused for a new set of tasks, the new Go calls must come after the earlier Wait calls have returned.

No concurrency limit. wg.Go starts a goroutine immediately, however many are already running. A million items means a million goroutines. Bound it with a worker pool or a semaphore (bounded parallelism).

Versions

The method exists from Go 1.25. A module needs a go directive of 1.25 or later to call it, so code that must build with older toolchains keeps the manual form. The old form is not deprecated and still works; the vet analyzers added alongside (waitgroup, which flags Add inside a goroutine) catch the mistakes in code that has not been converted. A reasonable migration is to convert new code now and old code when you touch it, rather than in a sweeping change, and to delete any home-grown Go(f) helper once you are on 1.25. The documentation is in the sync.WaitGroup.Go reference, and the change is listed in the Go 1.25 release notes.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.