SPONSORED ADS

Golang error handling best practices

Last Updated Jan 02, 2023

Introduction

As programmers, we often think of handling errors as boring work that we don't give much thought to. In fact, handling errors and figuring out how to fix them should be at the top of our minds, just like testing should be. This should be a part of how good software is made.

In Go, error handling is treated pretty seriously, via an explicit, separate return value. Go has the errors package in the standard library that provides many functions to manipulate errors. You can get a lot done in Go knowing just this about the error type. Go's approach makes it easy to see which functions return errors and to handle them.

Unlike other languages, Go doesn’t have exception handling. Instead of throwing an exception you will create an error and return it to the calling function. By convention, errors are the last return value.

func Beep(s string) (boop string, err error)

Error handling in Golang is unconventional when compared to other mainstream languages. This can make a bit difficult and can be a little confusing for people who are new to Go.

In this tutorial, we will delve more deeply into the concept of error and talk about some best practices for error handling in Go.

Go idiomatic way of handling errors

If you’re writing a function, the rule of thumb is that you return an error along with the return value (if any). If you’re calling a function, inspect the error returned, if it is not nil, handle the error accordingly.

See this example, we return a string and an error, together. This is possible because Go allows multiple return values.

func Beep(s string) (string, error) {
    if s == "" {
        return "", errors.New("ooooops! s is empty. how terible")
    }
    // other
}

Remember that errors are always the last return value.

In the above example, we see errors.New constructs a basic error value with the given error message. This is not the only way to create a new error though. Similarly, the fmt package can be used to format and add dynamic data to the error, data can be int, string, or another error. For example:

fmt.Errorf("beep boop: %s", bop)

The difference between the two is pretty obvious. fmt.Errorf allows you to format the string.

If you’re calling a function, the other rule of thumb is don't ignore errors, deal with it just like any other return value. For example:

f, err := os.Open("chainsawmain_episode01.mp4")
if err != nil {
    log.Fatal(err)
}

You might wonder why Go does it this way, instead of how using exceptions like many other languages.

When asked why at SF meeting in June 2012, Robert "Rob" Pike (the co-author of the Go programming language) said:

The reason we didn't include exceptions in Go is not because of expense. It's because exceptions thread an invisible second control flow through your programs making them less readable and harder to reason about.

In Go the code does what it says. The error is handled or it is not. You may find Go's error handling verbose, but a lot of programmers find this a great relief.

In short, we didn't include exceptions because we don't need them. Why add all that complexity for such contentious gains?

IMO exceptions seem easier to handle but can also be easily missed unless you try and catch them.

Simplifying repetitive error handling

This error handling style makes go code verbose, and become tedious to have to do repetitive checks err, ok := Beep(); if err {} every 10 lines will also hide the intent of the code. How can we prevent that?

Let's check this code :

func Beep(url string) (boo Boop) {
	r, err := http.Get(url)
	if err != nil {
		// deal with error
	}
	defer r.Body.Close()

	data, err := io.ReadAll(r.Body)
	if err != nil {
		// deal with another error
	}

	err = json.Unmarshal(data, &boo)
	if err != nil {
		// deal with another another error
	}
	return
}

You can see that. These error-handling routines are pretty similar to each other and in fact repetitive. Here using the helper function we can simplify it.

func nodeepshit(err error, s string) {
	if err != nil {
		log.Println("err:", s)
        // other stuff
	}
}

Our helper function nodeepshit take an err and a string, and log the string if there is an error. It can also take an func and execute the func instead. You get the idea. Now our code become short and readable.

func Beep(url string) (boo Boop) {
    r, err := http.Get(url)
    nodeepshit(err, "Calling " + url)
    defer r.Body.Close()

    data, err := io.ReadAll(r.Body)
    nodeepshit(err, "Read JSON from response")

    err = json.Unmarshal(data, &boo)
    nodeepshit(err, "Unmarshalling")
    return
}

Creating customized errors

Build-in error is good for many cases, but sometime when communicating more complicated error information to your users, or to your future self when debugging, you may want to create your own custom errors.

The simplest way to implement a customized error is to create a new string-based error using the errors.New or fmt.Errorf

err := errors.New("Syntax error in the code")

You could also archive this using the error interface. An error variable represents any value that can describe itself as a string. Here is the interface’s declaration:

type error interface {
    Error() string
}

The interface contains a single method Error() that returns an error message as a string. So any type that has a method named Error that returns a string is no doubt an error.

type BeepError int

func (b BeepError) Error() string {
    return fmt.Sprintf("beep boop")
}

Another example, the json package specifies a SyntaxError type that the json.Decode function returns when it encounters a syntax error parsing a JSON blob. This example shows you how to add fields and other methods to your custom error to carry more information.

type BeepError struct {
    description    string
    lineNumber int 
}

func (e *BeepError) Error() string { return e.description }

When you get such an error you can typecast it using the comma, ok idiom and extract the additional data for your processing:

if err != nil {
	err, ok := err.(*BeepError)
	if ok {
		// do something with the error
	} else {
		// or do something else
	}
}

Error wrapping


                                 +---------------------------+
                                 |           Wrapper         |
+--------------+                 |     +----------------+    |
|              |                 |     |                |    |
|     Error    +-----wrap------->+     |      Error     |    |
|              |                 |     |                |    |
+--------------+                 |     +----------------+    |
                                 +---------------------------+

Sometimes instead of just returning an error, you want to provide additional context to it. For example, you want to know where in the code that happens for easy debugging later. You can simply extract the information, create a new customized error with the additional information and return that. In general, 2 main use case of error wrapping is:

  1. Add more info for whatever we want into the error
  2. Give the error specific name
// create beep err
beep := errors.New("beep")

// wrap beep in boop
boop := fmt.Errorf("boop - %w", beep)

// we can get beep back
// boo == beep
boo := errors.Unwrap(boop)

fmt.Errorf works well for adding some small data, in case there is a lot stuff to add, create a custom error with the method Unwrap

type BoopError struct {
        boo string
        anotherStuff int
        moreStuff string // ... and as much as you want

	BeepErr  error
}

func (err *BoopError) Error() string {
	return fmt.Sprintf("oops!")
}

// Unwrap allow this err to be unwrapped
func (err *BoopError) Unwrap() error {
	return err.BoopErr
}

// and then somewhere when we need to return err
// we wrap beep into boop
if err != nil {
    return BoopError{BeepErr: err}
}

go 1.20 introduced a new way to wrap errors errors.Join, it allow wrapping multi errors at once, and works like this

// go 1.20
beep := errors.New("beep")
boop := errors.New("boop")
bop := errors.New("bop")

err := errors.Join(beep, boop, bop)

This help gather errors from goroutines easier (when the number of errs is not known)

Also fmt.Errorf now accepted multiple %w format. This is malformed in previous go version but now ok

// go 1.20
fmt.Errorf("%w + %w", beepErr, boopErr)

Your customized error struct could also support wrapping multiple errors instead of one by implementing Unwrap() []error

type BeepBoop struct {
    errs []error
}

func (e *BeepBoop) Error() string {
    // loop and handle errs
}

// go 1.20
func (e *BeepBoop) Unwrap() []error {
    return e.errs
}

Due to the fact that Go does not support method overloading, each type can only implement Unwrap() error or Unwrap() []error, but not both.

Basically wrapping allows you to embed errors into other errors, just like wrapping exceptions in other languages. A function that encounters an "unauthorized" error can add more context information to the message like "unauthorized: user x was trying to do y".

Inspecting errors

Since Go 1.13, Instead of typecast we have new helpful ways to find out the type of error: errors.Is and errors.As these functions allow you to extract what the underlying cause of a given error was.

if errors.Is(err, BeepError) {
    // handle error
}

Why not compare err directly ? err == BeepError well because errors.Is also can check an err is BeepError even if it was wrapped inside other errors.

The idea is that you get an error back, and you want to unwrap that error to find the underlying error that you know about and you can/want to handle. For example, when querying a database you get back the error, if that error is kind of unique violation then do this, however, if the err is a bad connection then do that.

Another way is using errors.As , the method take 2 parameters, the first is error and the second is a pointer.

func As(err error, target any) bool

This function unwraps the err just like errors.Is sequentially looking for an error that matches the second argument (the tarket) and if found, it performs the assignment and returns true

So in order for the assignment, the second argument (target) must be a pointer. Because As needs to modify the value that is being passed in. Also, since we deal with an error here, the pointer needs to be an interface or a type implementing error.

var target *SyntaxError
// err could be ParentError what wrap SyntaxError
if errors.As(err, &SyntaxError) {
    // target is now SyntaxError
}

Panic

In Go, panic is a built-in function that stops the ordinary flow. Once a panic is triggered, it continues up the call stack until either the current goroutine has returned or panic is caught with recover - which we will talk about later.

A panic means something bad happened unexpectedly. A common use of panic is to abort quickly if a function returns an error value that we don’t know how to (or want to) handle. Here’s an example of panicking if we get an unexpected error when creating a new file.

 _, err := os.Create("/tmp/file")
    if err != nil {
        panic(err)
    }

An unexpected error is something that would put the system into an unreasonable state or recovery is not possible without explicit handling of that case. For example, the database connection becomes unavailable.

Rule of thumb is in your actual main function it is ok to use panic. Error in all other cases. If you don't know which one to use panic or error, going with error is a safe bet.

You can however use panic in your internal API or private function if the err in that function is an unexpected one. But remember to add recover in the public function when consuming the private one.

Just to be clear, panic would not cause your service to go down. It wouldn't even affect requests that are being made at the same time. This is because there would always be a recover at the top level of the request/unit of work itself.

Recover

Go has a function called "recover" that lets you get back on track after a panic. A recover can prevent panic from stopping the program from running and instead let it keep going.

For example in the built-in net/http package when a client connection has a critical error, the server recover from the panic, closes the connection, and moves on to serve other clients.

recover must be called from inside deferred function. When the function that is being called panics, the defer will be called, and a recover call inside it will catch the panic.


func main() {
	// call recover within a defer closure
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("boop", r)
		}
	}()

	shouldPanic()
}

func shouldPanic() {
	fmt.Println("peep")
	panic("oh no !!!")
	fmt.Println("will never go here")
}

In the shouldPanic function, once panic is called, it stops the current function and goes up to the main function. Also because the panic is caught with recover, it does not stop the goroutine.

Why would we want that, though? You could be using a package that freaks out and panic when it runs into a problem it can't fix. That doesn't mean you want your program to end. Here's where recover comes in handy.

So, that's it. You made it until the very end! Congratulation. We hope that this tutorial helped you learn how to handle errors in Go. See you in the next post.

Others articles of me write about golang

If you like golang, here is some others articles of the same author (me) about golang topic. Check it out !

  1. learn golang in 5 minutes
  2. golang context best practices
  3. how to graceful shutdown golang service

Hi there. Nodeepshit is a hobby website built to provide free information. There are no chargers to use the website.

If you enjoy our tutorials and examples, please consider supporting us with a cup of beer, we'll use the funds to create additional excellent tutorials.

If you don't want or unable to make a small donation please don't worry - carry on reading and enjoying the website as we explore more tutorials. Have a wonderful day!