Error handling in Golang can be tricky to understand. I know I struggled with it and the articles I stumbled upon offered some information but it was a bit cluttered and some used complex examples.
I’ll try to make it simple for you in this article.
Before we start, one article from which I gleaned a lot of information about error handling in Golang is this one, you might want to check it out after you read this post.
If you spot any mistakes in this article please let me know, thank you.
This is a long post, so brace yourself, knowledge is coming.
Ok, let’s begin.
Simple error checking
Errors are values. In the code below we create a new error variable with a certain value. Then we check if one value equals another value.
The value against which we check ( ErrBadInput
) is also called a sentinel value.
import (
"errors"
"fmt"
)
var ErrBadInput = errors.New("the input was bad")
func do() error {
return ErrBadInput
}
func main() {
err := do()
if err == ErrBadInput {
fmt.Println("we have a bad input error")
}
}
// prints:
// we have a bad input error
Error wrapping with fmt.Errorf() and unwrapping with errors.Unwrap()
Sometimes we want to wrap our errors and return some extra information about the context in which the error occurred.
We use a function such as fmt.Errorf()
where we use something called a %w verb
to embed another error.
Running the code below we won’t have any output, because the error returned fromdo()
has another value other than ErrBadInput
.
var ErrBadInput = errors.New("the input was bad")
func do() error {
return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}
func main() {
err := do()
if err == ErrBadInput {
fmt.Println("we have a bad input error")
}
}
// prints:
//
But we can see that the ErrBadInput
is wrapped inside the error returned by our do()
function. Wouldn’t it be nice to be able to extract it from there…or unwrap it? That way we could check if the error from do()
has embedded in it ErrBadInput
. Turns out we have a function in the errors package which does exactly that:
var ErrBadInput = errors.New("the input was bad")
func do() error {
return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}
func main() {
err := do()
if errors.Unwrap(err) == ErrBadInput {
fmt.Println("we have a bad input error")
}
}
// prints:
// we have a bad input error
Pretty neat.
But what if we have yet another function that wraps our wrapped error again? A double wrap if you will.
Then it follows logic that we have to unwrap it twice, like so:
var ErrBadInput = errors.New("the input was bad")
func do() error {
return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}
func do2() error {
err := do()
return fmt.Errorf("do2() extra information, error is %w", err)
}
func main() {
err := do2()
if errors.Unwrap(errors.Unwrap(err)) == ErrBadInput {
fmt.Println("we have a bad input error")
}
}
// prints:
// we have a bad input error
And so we come to error chain
, which is in the words of the official go blog:
The result of unwrapping an error may itself have an Unwrap
method; we call the sequence of errors produced by repeated unwrapping the error chain.
Now imagine we have an unknown number of functions which keep wrapping, resulting in a long error chain. It would be pretty difficult to unwrap it using the errors.Unwrap
() function
Enter errors.Is()
Unwrapping automatically with errors.Is()
errors.Is()
does the unwrapping for us:
var ErrBadInput = errors.New("the input was bad")
func do() error {
return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}
func do2() error {
err := do()
return fmt.Errorf("do2() extra information, error is %w", err)
}
func main() {
err := do2()
// if errors.Unwrap(errors.Unwrap(err)) == ErrBadInput {
// fmt.Println("we have a bad input error")
// }
if errors.Is(err, ErrBadInput) {
fmt.Println("we have a bad input error")
}
}
// prints:
// we have a bad input error
errors.Is()
looks in the error chain to see if we have an underlying error embedded there, whose value equals the value of ErrBadInput
So in a nutshell, use errors.Is()
instead of err==ErrMyErr
to check if the value of an error is equal to a sentinel error value.
Custom error types
What if we want to implement our own custom error types? Let’s say that we need some extra information passed alongside the actual error which occurred, such as a timestamp?
Enter custom errors. All we need is a struct which implements the error
interface. This interface has a single Error()
method which returns a string.
Here’s a simple example:
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return ErrCustom{info: "my info", err: errors.New("bad input")}
}
func main() {
err := do()
fmt.Println(err)
}
// prints:
// info is my info, error is bad input
Look in the code above and try to understand it. We have 2 fields, info
and err
, but we could have as many extra fields as we wanted, for others types of extra information.
Afterwards we implement the error
interface by creating a method Error()
with ErrCustom
as a value receiver. I use a value receiver because we don’t need to change the fields values inside the ErrCustom
struct.
Finally, in the do()
function we return this custom error with the fields set to certain values.
errors.As()
Now let’s say we need to know if the error we got from a function is of our freshly defined custom error type, ErrCustom
. How do we do that?
You will probably immediately think about errors.Is()
, but that won’t work. It won’t work because errors.Is()
checks 2 error values to see if they are the same (also checking deep in the error chain).
But in the case above we have one value, err
and one type ErrCustom
. What we need to know is: is err
of type ErrCustom
?
This is where errors.As()
comes to the rescue.
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return ErrCustom{info: "my info", err: errors.New("bad input")}
}
func main() {
err := do()
var errCustom ErrCustom
if errors.As(err, &errCustom) {
fmt.Println("we have an err of type ErrCustom")
}
}
// prints:
// we have an err of type ErrCustom
There are a few changes in the way errors.As()
operates.
First argument is an error
value, err
in our case. Second argument is of type any
, which is an alias for interface{}
. This second argument should be a pointer to a custom type that implements the error
interface, which is the case as ErrCustom
implements error
interface.
errors.As()
is similar with errors.Is()
in the fact that it also looks in the error chain to check for a specific error type. And it also takes two error values as arguments, sorta (cause one is an error value, the other a pointer to a error value) but instead of checking if they are equal it checks if arg1 is of type arg2. More on that in the advanced section below.
So if we wrap our custom error with fmt.Errorf()
we can still get a correct check with errors.As()
:
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return ErrCustom{info: "my info", err: errors.New("bad input")}
}
func do2() error {
err := do()
return fmt.Errorf("do2() extra info, error is %w", err)
}
func main() {
err := do2()
var errCustom ErrCustom
if errors.As(err, &errCustom) {
fmt.Println("we have an err of type ErrCustom")
}
}
// prints:
// we have an err of type ErrCustom
Intermezzo
Still with me so far? Thank you if you are, here’s a cat meme to clear your brain 😀 (source)
Let’s recap:
We have 2 major use cases for error handling:
- Check if two errors have the same value. The naive approach is
err == ErrCustom
, the preferred approach iserrors.Is(err, ErrCustom)
. Botherr
andErrCustom
are error values. - If we have a custom error type and we want to check if an error value
err
is of typeErrCustom
.err
is an error value,ErrCustom
is a type. We useerrors.As(err, &errCustom)
, where&errCustom
is a pointer to anErrCustom
value.
Let’s look into other more complex use cases:
errors.As() with pointer receiver
What if when implementing the error
interface you would use a pointer receiver, like func (e *ErrCustom) Error() string {...}
?
Then in order to use errors.As()
you would need to have the following changes:
type ErrCustom struct {
info string
err error
}
func (e *ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return &ErrCustom{info: "my info", err: errors.New("bad input")}
}
func main() {
err := do()
var errCustom *ErrCustom
if errors.As(err, &errCustom) {
fmt.Println("we have an err of type ErrCustom")
}
}
// prints:
// we have an err of type ErrCustom
Astute readers will notice something that looks like a pointer to a pointer. The official go blog specifies that:
although it may feel odd to take a pointer to a pointer, in this case it is correct. Think of it instead as taking a pointer to a value of the error type; it so happens in this case that the returned error is a pointer type.
errors.As() with a pointer to a custom error value with specific data
Take a look at the code below:
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return ErrCustom{info: "my info", err: errors.New("bad input")}
}
func main() {
err := do()
var errCustom ErrCustom = ErrCustom{info: "dummy info", err: errors.New("dummy err")}
if errors.As(err, &errCustom) {
fmt.Println("we have an err of type ErrCustom")
}
}
// prints:
// we have an err of type ErrCustom
Maybe you will be surprised that we still get true
when checking, since err
fields have different values than errCustom
. Remember though, with errors.As()
we check if an error value err
is of type ErrCustom
, which it is, even if errCustom
has fields with different data.
errors.As() with pointer to an empty interface, interface{}
Take a look at the code below:
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func do() error {
return ErrCustom{info: "my info", err: errors.New("bad input")}
}
func main() {
err := do()
var errCustom interface{}
if errors.As(err, &errCustom) {
fmt.Println("we have an err of type ErrCustom")
}
}
// prints:
// we have an err of type ErrCustom
Again, you may be surprised that the check passes. Looking into the source code, we see that:
// As panics if target is not a non-nil pointer to either a type that implements
// error, or to any interface type.
func As(err error, target any) bool {
...
So we can pass an empty interface as a target
argument. Since the empty interface can hold any values it does make sense that value err
is of type interface{}
Implementing Unwrap() on our custom error type and calling errors.Is()
Here’s a more complex example:
var ErrBadInput error = errors.New("bad input")
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func (e ErrCustom) Unwrap() error {
return e.err
}
func do() error {
return ErrCustom{info: "my info", err: ErrBadInput}
}
func main() {
err := do()
if errors.Is(err, ErrBadInput) {
fmt.Println("is err bad input")
}
}
// prints:
// is err bad input
If we implement a method called Unwrap()
on our custom type, then errors.Is()
is going to use this custom method to get to the underlying error value. This you can see in the code above.
Unwrap() + errors.Is() + errors.As()
For an even more mind twisting example (sorry), here’s the following code:
var ErrBadInput error = errors.New("bad input")
type ErrCustom struct {
info string
err error
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}
func (e ErrCustom) Unwrap() error {
return e.err
}
func do() error {
return ErrCustom{info: "my info", err: ErrBadInput}
}
func main() {
err := do()
if errors.Is(err, ErrBadInput) {
fmt.Println("is err bad input")
}
var errCustom ErrCustom
if errors.As(err, &errCustom) {
fmt.Println("is ErrCustom")
}
}
// prints:
// is err bad input
// is ErrCustom
Wait a second… so our error is both ErrBadInput and ErrCustom? What it’s happening here?
Because we have implemented the method Unwrap()
on our custom error type, errors.Is()
uses that method, that method returns e.err
, and our do()
function sets the field value of e.err
to ErrBadInput
. So the value of err
is equal to the value of ErrBadInput
(which is a value too if you look in the code).
On the other hand, errors.As()
works as usual and reports that err
is of type ErrCustom
.
Some Inception level stuff, right? It can get a bit confusing, but once you understand how it works behind the scene things are a bit clearer.
fmt.Errorf(“%w”)
If you use fmt.Errorf("%w")
(or more specifically the verb %w
is included) then the error returned by fmt.Errorf()
” will have an Unwrap method returning the argument of %w, which must be an error” (from the go blog)
Custom Is() function
Some of you may think of a case where we have a custom error type which implements error
interface. What if we would use errors.Is()
like so:
type ErrCustom struct {
info string
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("the error is %v", e.info)
}
func do() error {
return ErrCustom{info: "do info"}
}
func main() {
err := do()
if errors.Is(err, &ErrCustom{}) {
fmt.Println("it is ErrCustom")
}
}
// prints:
//
This won’t pass the conditional check. Remember, errors.Is
checks if two values are equal, using Unwrap()
to get the underlying error value.
What if you do something like this?
type ErrCustom struct {
info string
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("the error is %v", e.info)
}
func do() error {
return ErrCustom{info: "do info"}
}
func main() {
err := do()
if errors.Is(err, &ErrCustom{info: "do info"}) {
fmt.Println("it is ErrCustom")
}
}
// prints:
//
Even if it seems that we have the same error values, this still won’t work. The way to make this work is to implement the Is()
method on our custom error type.
type ErrCustom struct {
info string
}
func (e ErrCustom) Error() string {
return fmt.Sprintf("the error is %v", e.info)
}
func (e ErrCustom) Is(target error) bool {
t, ok := target.(*ErrCustom)
if !ok {
return false
}
return (e.info == t.info)
}
func do() error {
return ErrCustom{info: "do info"}
}
func main() {
err := do()
if errors.Is(err, &ErrCustom{info: "do info"}) {
fmt.Println("it is ErrCustom with info='do info'")
}
}
// prints:
// it is ErrCustom with info='do info'
Now the check passes.
[edit] errors.Join()
After I published this article on reddit and received the hug of death (server went down), someone pointed out that I should also mention errors.Join()
err1 := errors.New("err1")
err2 := errors.New("err2")
err := errors.Join(err1, err2, nil)
fmt.Println(err)
//prints:
// err1
// err2
The official documentation states:
“Join returns an error that wraps the given errors. Any nil error values are discarded. Join returns nil if every value in errs is nil. The error formats as the concatenation of the strings obtained by calling the Error method of each element of errs, with a newline between each string.”
and
“A non-nil error returned by Join implements the Unwrap() []error method.”
This means we can use errors.Is()
to check for error values, like so:
err1 := errors.New("err1")
err2 := errors.New("err2")
err := errors.Join(err1, err2, nil)
if errors.Is(err, err1) {
fmt.Println("it's err1")
}
if errors.Is(err, err2) {
fmt.Println("it's err2")
}
//prints:
// it's err1
// it's err2
Final words
This was a long read, thank you for reaching the end! In trying to explain things thoroughly I might have created another hard to understand article, whereas the intention was to create an article that made things easy to understand. If so, I apologize.
Thank you for reading and please let me know your suggestions and thoughts in the comments below.