Bounded Parallelism in Go With Errgroup SetLimit Added
Bound concurrency with errgroup SetLimit and TryGo: Go blocks when the limit is reached, TryGo declines, a limit of zero or negative has special meaning.

The first idea most people have for processing a large list concurrently is to start a goroutine for every item. For a thousand items that is fine; for a million it is a problem, and for items that each open a connection or a file it is an outage. The standard answer is to bound the number in flight, and errgroup has a one-line way to do it.
Tested with Go 1.25.6 and golang.org/x/sync v0.19.0, macOS on Apple Silicon (arm64)
This note shows SetLimit and TryGo, what they guarantee, and the edges. It follows errgroup and belongs to the concurrency material. Everything ran on Go 1.25.6 with golang.org/x/sync v0.19.0.
SetLimit
package main
import (
"fmt"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
var g errgroup.Group
g.SetLimit(4) // at most four tasks run at once
var active, peak atomic.Int32
submitted := 0
for i := 0; i < 40; i++ {
g.Go(func() error { // blocks here while four tasks are running
n := active.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(3 * time.Millisecond)
active.Add(-1)
return nil
})
submitted++
}
err := g.Wait()
fmt.Println("submitted:", submitted, "| peak concurrency:", peak.Load(), "| error:", err)
}submitted: 40 | peak concurrency: 4 | error: <nil>Forty tasks, never more than four at once. The mechanism is in Go: the package documentation says that it blocks until the new goroutine can be added without the number of goroutines in the group exceeding the configured limit. The submitting loop therefore slows to the pace of the workers, which is backpressure. That also bounds memory: the loop does not run ahead and queue forty thousand closures, because it cannot get past Go until a slot opens.
TryGo: decline instead of wait
TryGo starts the function only if a slot is free and returns whether it did:
package main
import (
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
var g errgroup.Group
g.SetLimit(2)
started, declined := 0, 0
for range 5 {
ok := g.TryGo(func() error {
time.Sleep(20 * time.Millisecond)
return nil
})
if ok {
started++
} else {
declined++ // the group is full: skip, queue elsewhere or tell the caller
}
}
g.Wait()
fmt.Println("started:", started, "| declined:", declined)
}started: 2 | declined: 3The two slots fill with the first two calls, and the other three are declined. That is the right shape for load shedding: a request handler that fans work out and prefers to answer "busy" over waiting (load shedding). Declined work must be handled by the caller: dropped, retried later, or run inline.
The special values and the rules
- Negative removes the limit (
SetLimit(-1)is the default behavior). - Zero prevents any new goroutines:
Gowould block forever, andTryGoreturns false. It is useful as a way to pause a group, and almost always a bug otherwise. - Set the limit before the first
Go. The documentation says the limit must not be modified while any goroutines in the group are active, and the implementation panics if you do. - Pick the number from the work. CPU-bound tasks: the core count. Tasks that wait on a dependency: its concurrency limit (pool sizing).
A note on errors and cancellation
A limit changes when failures take effect. With a WithContext group, the first error cancels the derived context, but Go calls that are blocked waiting for a slot are not interrupted; once a slot frees, they start their tasks, which then see an already-cancelled context and should return at once. So check ctx.Err() before submitting in the loop, and stop submitting when it is set:
for _, item := range items {
if ctx.Err() != nil {
break // a task failed: don't queue more
}
g.Go(func() error { return process(ctx, item) })
}Without that check, a failed batch of a million items would still start, and immediately abandon, the remaining tasks one by one.
When to use a pool instead
SetLimit suits a finite batch: a slice of items, a directory to walk. It creates a goroutine per task, bounded in number, and reports the first error. A worker pool suits a continuous stream, where workers live for the whole process and jobs arrive over time, with a graceful shutdown (worker pools). For a bound without errors or a group, a semaphore does the same job (semaphores). The documentation for the methods is in errgroup.Group.SetLimit and TryGo.


