errgroup.Group.SetLimit can block the goroutine that calls Group.Go. The limit is enforced before a new worker goroutine starts, so a full group applies backpressure at task submission rather than building an internal queue.

That behavior matters when submission is part of another control path. A loop that appears to launch work asynchronously can itself stop at g.Go(...) until one active function returns.

The limit sits on admission

A zero-value errgroup.Group has no concurrency limit. After SetLimit(n), at most n functions started by Go are active at once. A negative limit restores unlimited admission, while a zero limit prevents every later Go call from starting a function.

The key boundary is the call to Go:

var g errgroup.Group
g.SetLimit(2)

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

err := g.Wait()

With two long-running calls to process, the third invocation of g.Go does not enqueue job inside errgroup. The submitting goroutine waits until capacity becomes available. Only then does Go register and start the next function.

This makes SetLimit different from a worker pool backed by a buffered channel. A buffered queue can accept pending jobs beyond the active worker count. errgroup has no such pending-task buffer in this mechanism; pressure propagates directly to the caller.

Active functions define capacity

The configured number applies to active functions in the group, not to operating-system threads and not to all goroutines in the process. A function occupies one group slot from admission until that function returns.

A task that starts additional goroutines internally still occupies one errgroup slot. Those child goroutines are outside the group’s admission accounting unless they are also started through the same group.

Likewise, a function blocked on network I/O, a channel receive, a mutex, or a timer remains active from the group’s perspective. The slot is released only when the function passed to Go returns.

This distinction can produce low CPU utilization while the group is at its configured cap. The limit constrains in-flight group functions, not runnable CPU work.

Submission order can become part of latency

Because Go may block, the code that enumerates tasks is coupled to task completion. Consider a producer that reads records and immediately submits one function per record:

for scanner.Scan() {
    record := append([]byte(nil), scanner.Bytes()...)

    g.Go(func() error {
        return store(record)
    })
}

Once the limit is full, scanning pauses at g.Go. The source is no longer consumed until an active call to store returns.

That coupling can be useful. It bounds the amount of work admitted from the source and can prevent an unbounded collection of waiting goroutines. It can also affect upstream behavior: socket reads may pause, file scanning may pause, and a channel producer may eventually block because its consumer is no longer receiving.

The resulting backpressure is structural rather than incidental. Raising the limit changes both maximum active work and the point at which the submitter stalls.

Cancellation does not turn admission into a queue

errgroup.WithContext associates a derived context with the group. The first non-nil error returned by a group function cancels that context, and Wait returns the first non-nil error after the group functions finish.

SetLimit does not add task cancellation semantics to functions waiting to be admitted through Go. The admission operation is a blocking call governed by group capacity. Code that needs a non-blocking admission decision has a separate operation: TryGo.

TryGo starts the function only when capacity is currently available and reports the result as a boolean:

if !g.TryGo(func() error {
    return flush(batch)
}) {
    recordSaturation()
}

This changes overload behavior substantially. Go waits for capacity; TryGo rejects immediate admission. Neither operation creates a buffered backlog owned by errgroup.

The limit is not dynamically mutable during active work

SetLimit is configuration for a group boundary, not a live resizing control. The package contract requires that the limit not be modified while goroutines in the group are active.

A design that needs runtime concurrency adjustment therefore needs a different synchronization boundary, such as a separately managed semaphore or worker set. Treating SetLimit as a knob that can be raised and lowered concurrently with active calls violates the API contract.

The stable pattern is to set the limit before submitting work for that group and leave it unchanged until the active set has completed.

Nested submission can deadlock at the cap

Blocking admission has a sharp consequence when an active group function submits more work to the same limited group and waits for that submission path to proceed.

At a limit of one, the problem is immediate:

var g errgroup.Group
g.SetLimit(1)

g.Go(func() error {
    g.Go(func() error {
        return nil
    })
    return nil
})

_ = g.Wait()

The outer function owns the only slot. Its nested g.Go waits for a free slot. The slot cannot become free because the outer function cannot return until its blocked g.Go call completes. No internal queue or scheduler can break that dependency.

Higher limits can exhibit the same shape when all slots are occupied by functions that attempt nested admission to the same group.

The concurrency cap is therefore also a dependency constraint. Code that recursively expands work needs an admission structure that cannot require occupied workers to wait for slots held by themselves as a set.

Bounded concurrency also bounds some retained state

Direct submission through a limited group can reduce memory growth compared with starting one goroutine per item and placing a semaphore inside each goroutine. In the latter shape, every submitted item can already have a goroutine, closure, captured data, and stack state waiting for the semaphore.

With SetLimit, the caller stops admitting functions once the cap is full. If the caller also creates per-task data only near submission, the amount of task-specific state resident at once can remain closer to the active-work bound.

That is not a universal memory bound. The producer may already hold the full input collection, active functions may allocate arbitrary data, and nested work can create independent state. The useful property is narrower: SetLimit does not require one waiting goroutine per not-yet-admitted task.

The cap changes control flow, not only parallelism

A concurrency limit is often described as a count of simultaneous operations. For errgroup.SetLimit, the operational effect is broader: capacity controls whether the submitting goroutine can continue past Go.

That boundary influences source consumption, latency propagation, memory retained by pending work, and the safety of nested submission. TryGo provides a distinct rejection boundary when blocking the submitter is not acceptable.

The limit is most accurately treated as synchronous admission control attached to Group.Go, with each slot retained until its admitted function returns.