Goroutines and error channels

August 28, 2025

Join me tomorrow, August 29, at 3:00pm EDT (19:00 UTC), for more live coding. Subscribe on YouTube and hit the Notify me button so you don’t miss it!


Last week we looked at how to handle errors from multiple goroutines using a mutex and a shared error variable. While this works for many common scenarios, it has some limitations. For example, it only captures the first error encountered and ignores any subsequent errors. It also introduces some minor performance overhead due to mutex locking.

Today we’ll re-implement the same solution, using an error channel, which provides a bit more flexibility.

func doManyThings() error {
	var wg sync.WaitGroup{}
	errCh := make(chan error, 100)

	for i := range 100 {
		wg.Go(func() {
			errCh <- doSomething(i)
		})
	}

	wg.Wait()
	close(errCh)
	var err error
	for range 100 {
		err2 := <-errCh
		if err == nil {
			err = err2
		}
	}
	return err
}

In this version, we create a buffered channel to receive errors from each goroutine. Then as each goroutine completes, it sends its error (or nil if no error occurred) to the channel. After all goroutines have finished, we iterate over the channel to check for any errors. The first non-nil error we encounter is returned.

This approach works, and it provides two main advantages over the mutex version:

  1. There’s no need for a mutex, which simplifies the code and reduce contention.

  2. We can easily modify the code to collect all errors instead of just the first one, if desired:

    var errs []error
    for _, err := range errCh {
    	if err != nil {
    		errs = append(errs, err)
    	}
    }
    return errors.Join(errs...)
    

However, there are also some downsides to this approach:

  1. It requires more memory, since we need to allocate a channel with enough buffer space to hold all potential errors.
  2. It’s not very efficient to write a bunch of normally-nil values to a channel, only to discard them later.
  3. It’s a bit sloppy—we don’t always read all values from the channel—only to the first non-nil error.
  4. It’s fragile. If we increase the number of goroutines without increasing the channel buffer size, we could end up with a deadlock.

We could improve several of these issues by using a separate goroutine to collect errors from the channel as they arrive, but that adds more complexity:

func doManyThings() error {
	var wg sync.WaitGroup{}
	errCh := make(chan error) // Unbuffered channel this time!

	done := make(chan struct{})
	var err error
	go func() {
		for err2 := range errCh {
			if err == nil {
				err = err2
			}
		}
		close(done)
	}()

	for i := range 100 {
		wg.Go(func() {
			errCh <- doSomething(i)
		})
	}

	wg.Wait()
	close(errCh)
	<-done
	return err
}

This version is quite a bit better in many ways, but also more complex.

  1. We use an unbuffered channel, which avoids the memory overhead of a large buffer.
  2. We have a dedicated goroutine that reads from the error channel as errors arrive, which avoids the need to store all errors in memory.
  3. We properly close the error channel once all goroutines have completed, which is good practice.
  4. We use a done channel to signal when the error-collecting goroutine has finished processing all errors.

One additional improvement that could be made would be to only send a value on the error channel if an error actually occurrs:

		wg.Go(func() {
			if err := doSomething(i); err != nil {
				errCh <- err
			}
		})

But man, what a lot of extra code just to handle errors from multiple goroutines! Wouldn’t it be nice if there were an easy, standard way to do this in Go? (How’s that for some foreshadowing?)


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Get daily content like this in your inbox!

Subscribe