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.

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:
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())
}10The new form is one call per goroutine:
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())
}10Both 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:
Addin the wrong place. There is noAddto misplace; the method does it at the right moment.- A forgotten
Done. The removal happens whenfreturns, on every path, including earlyreturnstatements insidef. - Copying the group. The closure captures the
WaitGroupby 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:
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)
}[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.


