Return Errors From Goroutines in Go Without Losing Any
A goroutine cannot return an error to its caller. Send it over a channel, collect results, or use errgroup, and avoid the blocked sender that leaks.

The go keyword starts a function and throws away whatever it returns. That is not a flaw to work around. It follows from what a goroutine is: a separate flow of control with no caller waiting on its result. But the work a goroutine does can fail, and an error that disappears is a program that reports success while a part of it is broken. The question is how the error gets from the goroutine back to something that can act on it.
Tested with Go 1.25.6 and golang.org/x/sync v0.19.0, macOS on Apple Silicon (arm64)
This article walks through the three patterns that answer it, shows the bug that makes the first one leak, and ends with a way to choose. It belongs to the error handling guide, and the concurrency side is developed in goroutines explained. Everything ran on Go 1.25.6, with golang.org/x/sync v0.19.0 for the last pattern.
The problem in one program
This version looks right and reports nothing:
package main
import (
"errors"
"fmt"
"sync"
)
func work(i int) error {
if i == 2 {
return errors.New("job 2 failed")
}
return nil
}
func main() {
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
work(i)
})
}
wg.Wait()
fmt.Println("all done")
}all doneIt prints all done. Job 2 failed and the error went nowhere. The WaitGroup waits for goroutines to finish, not to succeed, so it cannot help. A linter such as errcheck flags the ignored result of work(i) here, which is one of the better reasons to run it (errcheck).
Pattern 1: an error channel
Send each failure to a channel the parent reads from:
package main
import (
"fmt"
"sort"
"strings"
"sync"
)
func work(i int) error {
if i%2 == 0 {
return fmt.Errorf("job %d failed", i)
}
return nil
}
func main() {
const n = 6
errc := make(chan error, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
if err := work(i); err != nil {
errc <- err
}
})
}
wg.Wait()
close(errc)
var msgs []string
for err := range errc {
msgs = append(msgs, err.Error())
}
sort.Strings(msgs)
fmt.Println(strings.Join(msgs, "; "))
}job 0 failed; job 2 failed; job 4 failedThe channel has capacity n, the number of goroutines, so every sender has room and none can block, which matters for reasons the next section shows. After wg.Wait() all senders have finished, so it is safe to close(errc) and range over what is left. The goroutines finish in a different order each run, so the errors arrive in a different order, and the program sorts the messages to make its output stable. If you only want one error, a single select with a default receives the first.
The leak this pattern invites
Suppose the parent stops reading at the first error, which is a natural thing to write: err := <-errc, return it. Any other goroutine that tries to send blocks forever if the channel is unbuffered, because nobody will ever receive. The goroutine and everything it references stay alive for the life of the process. Here is the difference measured:
package main
import (
"errors"
"fmt"
"runtime"
"time"
)
func run(buffered bool) int {
before := runtime.NumGoroutine()
size := 0
if buffered {
size = 5
}
errc := make(chan error, size)
for range 5 {
go func() { errc <- errors.New("boom") }()
}
<-errc // the caller takes the first error and moves on
time.Sleep(50 * time.Millisecond)
return runtime.NumGoroutine() - before
}
func main() {
fmt.Println("unbuffered, leaked goroutines:", run(false))
fmt.Println("buffered, leaked goroutines:", run(true))
}unbuffered, leaked goroutines: 4
buffered, leaked goroutines: 0Each run counts the goroutines before it starts and after the parent has taken one error and waited a moment, so the difference is what that run left behind. The unbuffered run leaves four goroutines blocked on a send that will never complete, one for each error nobody read. The buffered run leaves none, since every send finds a free slot and returns, and the goroutines exit. In a real program you would not spot this by counting: use a leak detector in tests, as goleak does, and watch runtime.NumGoroutine in production (counting goroutines). The rule is plain: size the error channel for the number of senders, or make sure something always drains it. Goroutine leaks covers the broader problem.
Pattern 2: results by index
When each goroutine produces a result as well as a possible error, a pre-sized slice is simpler than channels. Every goroutine writes to its own element, so no two touch the same memory and no lock is needed:
package main
import (
"errors"
"fmt"
"strconv"
"sync"
)
type result struct {
n int
err error
}
func main() {
inputs := []string{"10", "x", "30", "4y"}
results := make([]result, len(inputs))
var wg sync.WaitGroup
for i, s := range inputs {
wg.Go(func() {
n, err := strconv.Atoi(s)
results[i] = result{n, err}
})
}
wg.Wait()
var errs []error
sum := 0
for i, r := range results {
if r.err != nil {
errs = append(errs, fmt.Errorf("input %d: %w", i, r.err))
continue
}
sum += r.n
}
fmt.Println(sum)
fmt.Println(errors.Join(errs...))
}40
input 1: strconv.Atoi: parsing "x": invalid syntax
input 3: strconv.Atoi: parsing "4y": invalid syntaxThe output is deterministic because the results are in input order and the error list is built after everything finished. The pattern is race-free as long as each goroutine writes only to results[i], a claim the race detector verifies if you run the tests with -race (the race detector). It is also the structure to reach for when the caller wants to know which inputs failed (collecting multiple errors).
Pattern 3: errgroup
For the common case, run these functions, wait, and give me the first error, there is a package for it. golang.org/x/sync/errgroup is maintained by the Go team outside the standard library:
package main
import (
"context"
"errors"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
for i := range 4 {
g.Go(func() error {
select {
case <-time.After(time.Duration(i) * 20 * time.Millisecond):
if i == 1 {
return errors.New("job 1 failed")
}
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}
err := g.Wait()
fmt.Println(err)
}job 1 failedg.Go takes a function that returns an error. g.Wait() blocks until every function has returned and then gives back the first non-nil error. WithContext also returns a context that is cancelled when any function returns an error, so the others can stop early: here jobs 2 and 3 see ctx.Done() and return context canceled, which Wait ignores because it already holds the first error, job 1 failed. If you do not want cancellation, use a plain var g errgroup.Group. And if you need to cap concurrency, g.SetLimit(n) bounds the number of goroutines running at once (errgroup in depth and limiting with SetLimit).
The trade-off is that errgroup returns only the first error. If you need every one, pattern 2 or a mutex-guarded slice is the right tool, and you can still use errgroup for the waiting.
Panics are not errors
One more thing the go statement does not give you: a panic in a goroutine cannot be recovered by its parent. A recover only works in a deferred function of the goroutine that panicked, so if a worker may panic and the program must survive it, the worker needs its own deferred recover that converts the panic into an error and reports it by one of the patterns above. Without that, a nil dereference in one of fifty workers ends the entire process, with the trace of that goroutine (recovering from goroutine panics). errgroup does not recover panics for you either, so the same rule applies there.
Test the failure path
The success path of concurrent code is the easy one to write and the easy one to test. Add a test where one goroutine fails, and assert three things: the error reaches the caller, the function returns (rather than hanging on a blocked send), and the other goroutines finish or are cancelled. Running it with -race and a short -timeout catches the leak, because a blocked sender holds the test open until the timeout fires. Testing concurrent code goes through the techniques.
Choosing
- You want the first failure and to stop the rest:
errgroup.WithContext. - You want all the failures, with their inputs: results by index, then
errors.Join. - Goroutines are long-lived and report errors as they happen: a buffered error channel read by a supervisor (supervisor goroutines).
Whichever you use, two habits prevent most problems. Never start a goroutine whose error nobody will read, and never let the receiver stop reading while senders may still be sending. The package documentation for errgroup is short and worth reading in full, and the Go blog's Go Concurrency Patterns: Pipelines and cancellation covers the channel approach and its cancellation problems in more depth.


