Welcome to our website.

Using errgroup to Simplify Error Handling in Go

When writing Go code, it is very common to end up with repeated if err != nil checks. In many cases this is perfectly fine, but when the same pattern appears again and again, the code can become noisy. There are a few common ways to make this cleaner. Two useful approaches are wrapping the error inside another operation and using errgroup.

A basic example

Consider a simple program that starts several independent services:

package main

import "fmt"

func StartUserService() error {
    fmt.Println("start user service")
    return nil
}

func StartGoodsService() error {
    fmt.Println("start goods service")
    return nil
}

func StartOrderService() error {
    fmt.Println("start order service")
    return nil
}

func main() {
    err := StartUserService()
    if err != nil {
        panic(err)
    }

    err = StartGoodsService()
    if err != nil {
        panic(err)
    }

    err = StartOrderService()
    if err != nil {
        panic(err)
    }
}

This is a familiar situation: multiple functions need to be called, each function may return an error, and each error has to be handled. In this example, the functions are used to start different services.

To keep the discussion focused, assume these services do not depend on one another. Also assume that if any one service fails to start, the program should exit immediately.

Wrapping the error inside an operation

When repetitive code appears, the first thing to consider is usually abstraction. If several operations look similar, we can abstract them behind a common shape and reuse one piece of logic to handle them.

One way to do that here is to introduce a small manager that stores the first error internally:

package main

import "fmt"

type ServiceManager struct {
    err error
}

type StartFn func() (err error)

func (s *ServiceManager) Start(sf StartFn) {
    if s.err != nil {
        return
    }

    s.err = sf()
}

func (s *ServiceManager) Err() error {
    return s.err
}

func StartUserService() error {
    fmt.Println("start user service")
    return nil
}

func StartGoodsService() error {
    fmt.Println("start goods service")
    return nil
}

func StartOrderService() error {
    fmt.Println("start order service")
    return nil
}

func main() {
    sm := &ServiceManager{}

    sm.Start(StartUserService)
    sm.Start(StartGoodsService)
    sm.Start(StartOrderService)

    if err := sm.Err(); err != nil {
        panic(err)
    }
}

The key idea is that the start operation has been abstracted, and the error is wrapped inside the ServiceManager structure. This is a common technique: the caller does not need to repeat the same error-handling logic after every call. The main function can simply call Start for each service and check the final error once.

The Start method also stops doing further work once an error has already been recorded. That matches the assumption above: if one service fails to start, there is no need to continue starting the remaining services.

Using errgroup

Another, more general approach is to use errgroup:

package main

import (
    "context"
    "fmt"

    "golang.org/x/sync/errgroup"
)

func StartUserService() error {
    fmt.Println("start user service")
    return nil
}

func StartGoodsService() error {
    fmt.Println("start goods service")
    return nil
}

func StartOrderService() error {
    fmt.Println("start order service")
    return nil
}

func main() {
    gp, _ := errgroup.WithContext(context.Background())

    gp.Go(StartUserService)
    gp.Go(StartGoodsService)
    gp.Go(StartOrderService)

    if err := gp.Wait(); err != nil {
        panic(err)
    }
}

The underlying idea is similar, but errgroup runs each subtask in a goroutine and then waits for all subtasks to finish. Internally, it is based on WaitGroup. If any task returns an error, errgroup records it and returns it from Wait.

For many cases, this is more convenient than manually managing a WaitGroup, especially when each concurrent task can fail and the caller only needs to handle the final error.

The implementation can be found in the Go sync repository: https://cs.opensource.google/go/x/sync/+/master:errgroup/errgroup.go

A small extension

errgroup also provides SetLimit and TryGo. These methods can be used to set an upper bound on concurrency, ensuring that the number of running tasks does not exceed a specified limit.

In short, repeated error checks can often be reduced by abstracting the repeated operation and centralizing the error handling. For concurrent tasks, errgroup is usually a cleaner option than manually combining goroutines, WaitGroup, and error collection logic.

Related Posts