Skip to content
gohacks.dev
Basics

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.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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:

main.goGo
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")
}
Text
all done

It 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:

main.goGo
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, "; "))
}
Text
job 0 failed; job 2 failed; job 4 failed

The 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:

main.goGo
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))
}
Text
unbuffered, leaked goroutines: 4
buffered,   leaked goroutines: 0

Each 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:

main.goGo
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...))
}
Text
40
input 1: strconv.Atoi: parsing "x": invalid syntax
input 3: strconv.Atoi: parsing "4y": invalid syntax

The 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:

main.goGo
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)
}
Text
job 1 failed

g.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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.