sync.WaitGroup.Go combines goroutine creation with task accounting. Added in Go 1.25, the method removes a small but consequential gap between incrementing a wait-group counter and starting the goroutine that will eventually decrement it.

The method does not change what a WaitGroup represents. It still tracks a set of tasks and lets another goroutine block until that set is complete. The difference is that registration and goroutine launch now share one operation.

Task registration moves into the launch operation

Before Go 1.25, a common pattern paired Add with a deferred Done:

var wg sync.WaitGroup

for _, job := range jobs {
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(job)
    }()
}

wg.Wait()

The ordering matters. A positive Add performed while the counter is zero must occur before a Wait that is intended to observe that task. Moving Add(1) into the new goroutine creates a race between registration and waiting: Wait can observe a zero counter and return before the goroutine registers itself.

WaitGroup.Go packages the correct accounting around the function:

var wg sync.WaitGroup

for _, job := range jobs {
    wg.Go(func() {
        process(job)
    })
}

wg.Wait()

Conceptually, Go adds one task, starts f in a new goroutine, and removes the task when f returns. Callers no longer need to keep the counter update and goroutine launch aligned by hand.

The method tracks completion, not results

WaitGroup.Go accepts a func() and returns no value:

func (wg *WaitGroup) Go(f func())

That signature keeps its scope narrow. It records task lifetime; it does not collect errors, cancel sibling work, limit concurrency, or return values from tasks.

Code that needs error propagation still needs another mechanism. A shared error channel, explicit result aggregation, or a higher-level abstraction can sit beside the wait group. The presence of Go does not turn WaitGroup into a general task-group API.

This boundary is useful when completion itself is the synchronization event. For example, several independent cache refresh operations can be launched and joined without inventing result plumbing when each operation already owns its error handling.

Nested tasks can join the same group

The rules for Go depend on whether the wait group is empty. When it is empty, calls to Go that establish a task set must occur before the corresponding Wait. Once the group is non-empty, a task started through Go may itself call Go on the same group.

That permits a task tree whose descendants remain part of the same completion boundary:

var wg sync.WaitGroup

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

wg.Wait()

Wait does not return merely because the outer function has finished. Descendant tasks have incremented the same counter, so the group reaches zero only after every registered task returns.

This property is different from launching untracked goroutines inside a tracked task. A plain go statement inside the outer function creates work that the wait group cannot see.

Panic handling remains outside the contract

The documentation for WaitGroup.Go states that f must not panic. That constraint is part of the API contract and should not be treated as an invitation to depend on internal cleanup details.

A task that can encounter an expected failure should represent that failure through normal control flow. If a program has a deliberate panic-recovery boundary, it belongs at a level where the application can define what recovery means for state, logging, and continuation.

The method’s narrow contract also keeps task accounting separate from failure policy. WaitGroup can establish that registered functions returned; it does not define how abnormal execution should be converted into application state.

Completion establishes a synchronization edge

WaitGroup.Go has a memory-ordering guarantee in addition to its counting behavior. The return from f synchronizes before the return of a Wait call that it unblocks.

That matters when a task writes data that another goroutine reads only after Wait returns. The wait operation is not just polling a counter; it participates in the synchronization relationship specified by the package.

The guarantee does not make arbitrary concurrent access safe. If multiple tasks write the same map or mutate the same object concurrently, those accesses still need suitable synchronization. The edge applies between task completion and the unblocked waiter, not between sibling tasks racing with each other.

Reuse starts after the previous wait completes

A WaitGroup can represent multiple independent batches over its lifetime, but those batches have a boundary. New Go calls for a reused group must occur after all previous Wait calls have returned.

Keeping batches separate avoids ambiguity about which task set a particular Wait is joining. If work continuously spawns more work without a clear zero point, a reusable wait group may not match the lifetime model in the first place.

WaitGroup.Go makes the common launch-and-join pattern smaller without expanding the abstraction beyond task completion. Its main value is structural: the operation that creates concurrent work also registers that work with the object responsible for waiting on it. Error handling, cancellation, concurrency limits, and shared-state protection remain separate design decisions.