Making sense of Go errors
Jan 2, 2025
I've been programming Go on and off for the past few months and errors still confuse me. This might sound surprising since Go errors are pretty simple, but their simplicity also betrays the fact that there's no standardized way of structuring them in larger programs, which makes me endlessly confused when navigating different codebases.
There's a really good blog post from 2016 elaborating on errors. It's old though and some features have been added since then. I also figured it'd be better for me to internalize them if I wrote about them myself.
First, Go has no exceptions. Instead, functions that can error just return the error. Go doesn't have union types so instead functions just return potentially multiple values, with the error as one of the values. Error handling and Go gives this example:
func Open(name string) (file *File, err error)
error
is an interface and if it's not nil
, that means an error has happened and you need to handle that.
So, let's say you want to open a file, so you call Open
. You check that the second value it returns is not nil
. What to do then?
First, we need to consider the context. Why are you opening a file? Let's say you have a script that takes a filename from the user and tries to open it. You might get an error because there's no such file with the name provided, in which case the error will be ErrNotExist
. You can check for that error (using errors.Is(err, ErrNotExist)
) and tell the user that the file doesn't exist, for example. In another context, you might have a long-running program (a database, for example) and you can't keep everything in memory so you need to read files at various times. Most of the time you'll expect the files to exist. If they don't then something very wrong has happened and you need to be ready to recover from that somehow.
Now, none of this is particular to Go. Errors are a fact of life and we always need to handle them. In languages with exceptions they can go under the radar since you're not forced to handle them: you can just let them bubble up to the caller, which can lead to an exception blowing up very far from the code that caused it. In Go you're not forced to handle them either, unlike in say Rust or Zig, where you have to explicitly ignore them. Still, the fact that errors get returned from functions makes Go's error handling more explicit than languages with exceptions.
So, what do Go errors look like? First, error
is just an interface:
type error interface {
Error() string
}
It's common to define sentinel errors which are effectively just constants, created with errors.New()
. One example is the aforementioned ErrNotExist
. It can be checked with a direct equality comparison err == ErrNotExist
. However it's preferable to check with errors.Is(err, ErrNotExist)
. This is because of error wrapping. You can wrap an error into another error type by implementing the method Unwrap() error
in the wrapping error type.
For example, I could create a type that wraps filesystem errors with some contextual information on where in the program the operation was taking place. With that I lose the ability to check for ErrNotExist
through a direct equality comparison, but I can still check for it with errors.Is()
.
There's also errors.As()
which can match an error against a particular error type, as opposed to a particular error value. Also, you can wrap errors on the fly without creating new error types, passing the error fmt.Errorf()
and using the %w
specifier, like fmt.Errorf("failed to open file: %w", err)
. This is useful for adding a "stack trace" of sorts to errors while still allowing for checking for them with errors.Is()
.
Dave Cheney's blog post recommends avoiding sentinel errors, because they become part of your module's public interface and create coupling. He instead advocates for having "opaque" error values that you're not meant to inspect; if you do need inspecting you can instead define an interface and check if the error implements the interface, through interface conversion:
type fileError interface {
IsDatabaseFile() bool
}
func open(name string) (*File, error) {
file, err := Open(name)
if err != nil {
t, ok := err.(fileError)
if ok && t.IsDatabaseFile() {
// handle database file error
}
// ...
}
// ...
}
Since Go's interface are implicit this doesn't create coupling between modules. It's a quirky and unusual-looking solution to me, compared to sentinel errors. But I can see myself being swayed if I ever worked in a large Go codebase.
The blog post was written before error wrapping was added to Go but it does suggest a solution that looks a lot like what eventually made it into the language.