Skip to main content
Preslav Rachev
  1. My Writings/

Go's Error Handling Is a Form of Storytelling

·5 mins

It won’t surprise many that the first time I saw Go code, I was put away by the sheer amount of error checks. Having just worked with Python, one of whose mantras is “it’s easier to ask for forgiveness than permission”, it was pricking my eyes to see this code all over the place:

if err != nil {
	return err

There was a quick moment of rejecting reality by simply ignoring all errors and trying to make do with whatever came out as a result:

res, _ := getResultOrFail()
// bye, bye errors. I tricked the system. Or did I?

Of course, as time passed, I got bitten by this mistake a couple of times. Go is not Java, and neither is it Python. A Null Pointer Exception in those languages is annoying, but somewhat part of daily life. When it happens, a thread will explode, but the rest of your app will keep working (unless it leaks memory or causes a deadlock, but that’s another topic). In Go, the same causes a runtime crash across the entire app. I call that a refreshing way of teaching you to respect error handling and leaving as few opportunities for a runtime panic as possible.

Another thing - If 80% of your Go code consists of error handling, it is because 80% of your code might fail at any time. In typical Chekhov fashion, if there is a chance for an error to happen in your code, it will happen sooner or later.

So, I gradually got used to just adding that bit of handling code. Over time and thanks to tooling that automates typing, I learned not to worry about the mechanics of the action that much. And yet, by writing

if err != nil {
	return err

I was not helping my code that much. I realized it the first time I had to inspect an error log, and it came out something like this:

ERROR: not found

Ehm, hold on - what exactly was not found? And where did it come from? This was when it occurred to me that a big part of what makes Go’s error handling different is the opportunity it gives the programmer to tell a story. See, if you simply return an error to the caller, you are almost as good as not returning the error in the first place. At some point, this error will bubble up the call stack, and someone will decide to take action upon it - e.g., log it to a file. Whoever is going through that log file later (likely, it will be you) will be pretty pissed about getting that cryptic hint that says nothing.

The trick to telling the story right is to add meaningful context to the error wherever possible. In Go, adding error context literally means expanding the message of the error you’ve just received with some explanatory text of what you were doing when the error occurred. The error type in Go is a simple interface, exposing an Error() method returning a string. So, for practical reasons, all errors in Go can be equated to strings (although you can make them more complex if you want to).

Instead of returning the error, we can use a handy function from the fmt package called Errorf. It takes a formatted string and uses it to produce a new error. The argument you pass to the formatted string does not have to be the error itself, but you are highly encouraged to do it:

res, err := getResult(id)
if err != nil {
	return nil, fmt.Errorf("obtaining result for id %s: %w", id, err)

fmt.Errorf has this nice property that if you use %w in the formatted string, the newly created error will actually wrap the original one (keeps an internal reference to it). This is useful if you later want to check, whether the error is equal to a well-known one:

if errors.Is(err, sql.ErrNoRows) {
	// do something
	// The condition above will match even if sql.ErrNoRows has been wrapped
	// multiple times over

Crafting a good message is key #

Think of error messages as something that is always open for concatenation. Someone up the call chain will likely wrap the error and prepend their piece of the puzzle. Thus, it is best if your message is concise and describes what the code was trying to do at the time the error occurred. Avoid words like failed, cannot, won’t, etc. - it is clear to the reader of the log message that if it occurred, something did not happen. Here is a good example:

conecting to the DB

someone will likely wrap it up the caller chain:

fetching order status: connecting to the DB

and perhaps, even further:

tracking parcel location: fetching order status: connecting to the DB

A single message like the one above is above for the reader to understand what went wrong - the DB connection failed when a user tried to track the location of their parcel. Much clearer than the one below:

could not track location: unable to fetch order status: DB connection failed

or worse:

error while tracking location: error while fetch order status: DB connection failed

Here are a couple of examples of not-so-good error messages from well-known codebases.

Errors context tells a story in your code too #

As a fresh developer many years ago, I liked putting comments everywhere in my code, especially in places that might lead to an exception or break the expected logic. Well, what better way to make such comments useful and turn them into error message context - because that’s exactly what they are:

jobID, err := store.PollNextJob()
if err != nil {
	return nil, fmt.Errorf("polling for next job: %w", err)

owner, err := store.FindOwnerByJobID(jobID)
if err != nil {
	return nil, fmt.Errorf("fetching job owner for job %s: %w", jobID err)

j := jobs.New(jobID, owner)
res, err := j.Start()
if err != nil {
	return nil, fmt.Errorf("starting job %s: %w", jobID err)

// etc ...

From my perspective, not only helpful for your future debugging self, but also making long bits of code easier to glance and comprehend by just following the error messages.

Have something to say? Join the discussion below 👇

Want to explore instead? Fly with the time capsule 🛸