Intro

My experience with GoLang error handling has been a strange one. From moments of illumination followed by frustration, to acceptance, then back to pure annoyance, more acceptance and finally getting to a point of developing a practical solution.

If you're new to GoLang's error handling, the first thing you'd notice is that there's no try/cacth as in most mainstream languages. Errors are not thrown or caught. In GoLang errors are values (This actually has serious consequences on the language, but more on that later)

And if you've written GoLang for any length of time you must be familiar with very the popular, very controversial if err != nil statement. So let's start there 😂

Take the snippet of code below:

package main

func connectToDB() (*db, error) { /** */ }
func runMigrations(store *db, path string) error { /** */ }
func setupServer(store *db) (*router, error) { /** */ }

func start() error {

  db, err := connectToDB()
  if err != nil {
    return err
  }

  err = runMigrations(db, "/migrations")
  if err != nil {
    return err
  }

  r, err := setupServer(db)
  if err != nil {
    return err
  }

}

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

Most Gophers will immediately tell you how needlessly verbose this is compared to languages like Rust, Zig which have shorthand syntax for error propagation or even Java, C# which have which have try/catch blocks.
For example, if GoLang had shorthand syntax for error handling, the above example could look like this

package main

func connectToDB() ?*db { /** */ }
func runMigrations(store *db, path string) ? { /** */ }
func setupServer(store *db) ?*router { /** */ }

func start()? {  
  db := connectToDB()?

  runMigrations(db, "/migrations")?

  r := setupServer(db)?
}

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

This is significantly shorter and lucid. In the main function we still want to handle the error so nothing has changed there.

But is this better though? 2 years ago, i'd have immediately answered yes, nowadays i'm not so sure.

Let us backup a bit and talk about error propagation and error handling.
Error propagation is when you return the error back to the calling function (i.e bubbling it up). Error handling is when you actually do something with the error object. But remember when I said error being values have serious consequences? - Let's examine that.

// getint.go
package main

import (
    "errors"
)

func getInt(v int) (int, error) {
    if v < 0 {
        return 0, errors.New("error getting val")
    }
    return v, nil
}

func foo(p int) error {
    if _, err := getInt(p); err != nil {
        return err
    } else {
        // Do stuff with v
        return nil
    }
}

func bar(p int) (int, error) {
    if err := foo(p); err != nil {
        return 0, err
    }

    if p < 3 {
        return 0, errors.New("value is too small")
    }

    return p - 10, nil
}

And a main.go

// main.go
package main

func handler(val int) error {
    if err := foo(val); err != nil {
        return err
    }

    v, err := bar(val)
    if err != nil {
        return err
    }

    if err := foo(v); err != nil {
        return err
    }

    return nil
}

func main() {
    if err := handler(3); err != nil {
        panic(err)
    }
}

Running the above code will panic like so:

panic: error getting val

The more observant would have figured the problem with this approach. We handled the errors properly and didn't cut any corners. The program panicked as expected with the proper error message, but it's unclear which function caused the panic. Is it foo or bar. You can't also tell through the source code without knowing which input value was passed into your program.

This means our error handling approach isn't sufficient. Shorthand syntaxes don't solve this problem either; in fact they worsen it. But more on that later.

GoLang solves this with concept called error wrapping The simple idea is to decorate your error messages with additional information before bubbling it up to the caller such that they provide more context to your errors and possibly help you identify* them.

So with error wrapping you can have something like this:

func foo(p int) error {
  if _, err := getInt(p); err != nil {
    return fmt.Errorf("error calling int: %v", err)
  ...
}

func bar(p int) (int, error) {
  if err := foo(p); err != nil {        
    return 0, fmt.Errorf("error calling foo: %v", err)
  ...   
}

And

func handler(val int) error {
  if err := foo(val); err != nil {
    return fmt.Errorf("error calling foo: %v", err)
  }

  v, err := bar(val)
  if err != nil {
    return fmt.Errorf("error calling bar: %v", err)
  }

  if err := foo(v); err != nil {
    return fmt.Errorf("error calling foo: %v", err)
  }

  return nil
}

Which will panic like below:

panic: error calling foo: error calling int: error getting val

The reported error here is definitely more helpful than the previous one. But we still don't know which version of foo is failing.
You might immediately think decorating the foo errors with more specific contexts* like so:

return fmt.Errorf("error calling foo1: %v", err)
return fmt.Errorf("error calling foo2: %v", err)
panic: error calling foo2: error calling int: error getting val

Finally we can now see that the error started from calling the second foo which called the getInt function which returned the error error getting val.
But how practical is this approach in real world code? can you always guarantee the consistency of foos throughout your application? what if the error functions change? what if there are multiple foo functions used across multiple packages? How scalable is this approach?

Error contexts are evil

Stay with me 🫣.
The idea of wrapping errors before bubbling them up is brilliant. Decorating error messages with arbitrary/redundant texts is what I find unappealing.
Let's back up a bit and ask how we ended up here in the first place. Error contexts aren't really a thing in C#, Java, JavaScript etc. cause these languages use exceptions which are managed by the language runtime, so anytime an exception is thrown the runtime would automatically attach a stack trace to the error object. But since languages like GoLang treat errors as values, this feature is not supported.

By this we can tell what we're missing is not more custom error messages. Its stack traces.

Good error reporting

A good error report should inform me of the following:

  1. What went wrong. (Required. This is usually the error message)
  2. Where it went wrong. (Required. This is where you'd need a stack trace or some form of identifier)
  3. Type of error it is. (Optional. Could be error kinds, custom error types etc.)
  4. Useful data or information attached to the error (Optional)

In more traditional languages. #1 and #2 are supported out of the box. In GoLang however you're only guaranteed #1

Consider the line below

conn, err := db.Connect(opts)
if err != nil {
  return fmt.Errorf("error connecting to database: %v", err)
}

What purpose is the additional "context" text error connecting to database serving here? No seriously.
Let me show you what I mean. Below are sample errors gotten trying to connect to a database

failed to connect to `user=test database=test`:
    [::1]:5432 (localhost): dial error: dial tcp [::1]:5432: connect: connection refused
    127.0.0.1:5432 (localhost): dial error: dial tcp 127.0.0.1:5432: connect: connection refused
failed to connect to `user=test database=`: [::1]:5432 (localhost): failed SASL auth: FATAL: password authentication failed for user "test" (SQLSTATE 28P01)

The first error was because the database server was down. The second was because database connection credentials was empty.
Neither of these 2 errors need the error connecting to database prefix to give more "context" or information. For most scenarios (not all) it's actually just noise.
If the above error needed fixing, we'd have to rely on the actual error coming from the function, not our cute little prefix.

Error contexts became necessary when we started treating errors as values because that meant no stack traces. And the Gophers' work around for that limitation was to decorate errors with text unique enough to hint where it went wrong, meaningful enough as a description, and generic enough so it covers all the possible errors returned. But as we've seen from the foo1 and foo2 issue above, hitting that balance can be very difficult and time consuming.

Focus on uniqueness not meaning

My assertions so far:

  • Errors as values misses stack traces
  • Wrap errors before bubbling them up
  • Favour uniqueness over meaning when wrapping/decorating your errors

Thinking uniqueness means ensuring error decorators/wrappers are always unique instead of being pedantic about the perfect description for your error.

Let's see how we could achieve that:

func foo(p int) error {
  if _, err := getInt(p); err != nil {
    return fmt.Errorf("%d: %v", 1745164156, err)
  ...
}

func bar(p int) (int, error) {
  if err := foo(p); err != nil {        
    return 0, fmt.Errorf("%d: %v", 1745164148, err)
  ...   
}

Running the modified code will panic with an error like below:

panic: 1745164156: 1745164127: error getting val

The app now panics with a text that contains some integers that have no meaning alongside the original error message.

Might not seem like an improvement, but trust the process.
The integers here are timestamps statically generated whenever error wrapping is needed (in my case via a vscode snippet). And the fact that timestamps have no meaning here means all attention is on the original error message. This is a feature.

What the stamps provide is a quick and simple way of achieving stack traces (or in this case trace ids)
If you want to trace your error call stack, just search your source code using the ids.

Tidying up with errx

Stamped errors allow reporting errors that meet the #1 and #2 requirements. It provides a simple and consistent standard for creating new errors and wrapping existing errors.

A consistent standard means you no longer think too much or debate which error handling methodology is best. Which context text perfectly describes the error. It saves you valuable time and allows move on and focus on the important parts of your applications

For new errors just create with a stamp, for existing errors just wrap with a stamp.
Thats it! Thats all you have to remember.

Let's tidy up our previous example using the errx library.

// getint.go
package main

import (
  "github.com/michaelolof/errx"
)

func getInt(v int) (int, error) {
  if v < 0 {
    return 0, errx.New(1745172236, "error getting val")
  }
  return v, nil
}

func foo(p int) error {
  if _, err := getInt(p); err != nil {
    return errx.Wrap(1745172176, err)
  } else {
    // Do stuff with v
    return nil
  }
}

func bar(p int) (int, error) {
  if err := foo(p); err != nil {
    return 0, errx.Wrap(1745172192, err)
  }

  if p < 3 {
    return 0, errx.New(1745172208, "value is too small")
  }

  return p - 10, nil
}
// main.go
package main

import (
  "github.com/michaelolof/errx"
)

func handler(val int) error {
  if err := foo(val); err != nil {
    return errx.Wrap(1745172276, err)
  }

  v, err := bar(val)
  if err != nil {
    return errx.Wrap(1745172290, err)
  }

  if err := foo(v); err != nil {
    return errx.Wrap(1745172302, err)
  }

  return nil
}

func main() {
  if err := handler(3); err != nil {
    panic(err)
  }
}
panic: [ts 1745172302]; [ts 1745172176]; [ts 1745172236] error getting val

Already this looks a lot better. The error message is exactly what went wrong and the stamps tell you how the errors got propagated. For majority of your error reporting and debugging, that is all you need really.

Wrap existing errors with stamps, create new errors with stamps and move on with your life. Heck just throw the entire if err != nil block in a code snippet and call it day.


Another thing we might want to do is define error kinds. Error kinds give us a way of grouping or marking errors.

errx provides a way to handle this. Lets see:

// kinds.go
package main

var (
  NotFoundErr      = errx.Kind("notfound")
  InvalidNumberErr = errx.DataKind[int]("invalidnumber")
)

Unlike with traditional GoLang error handling where error kinds are sentinel errors, errx error kinds are just strings
They can be used like so:

func getInt(v int) (int, error) {
  if v < 0 {
    return 0, errx.NewKind(1745172236, "error getting val", NotFoundErr)
  }
  return v, nil
}
func bar(p int) (int, error) {
  if err := foo(p); err != nil {
    return 0, errx.Wrap(1745172192, err)
  }

  if p < 3 {
    return 0, errx.NewKind(1745172208, "value is too small", InvalidNumber(p))
  }

  return p - 10, nil
}

Using error kinds follows the same pattern as normal errors. call NewKind for new errors and WrapKind for existing errors.

Running the above program will panic like this:

[ts 1745172302]; [ts 1745172176]; [ts 1745172236 kind notfound] error getting val

But if I passed a value of 2 to the handler function in main.go file, it would force the bar function to fail and panic like so:

[ts 1745172290]; [ts 1745172208 kind invalidnumber data 2] value is too small

Pretty sweet right?

At this point we've been able to meet the 4 requirements of a good error report as stated above.

Just by inspecting the error message:

  • We know what went wrong: value is too small
  • We know where it went wrong: 1745172290 called 1745172208 which errored
  • We know what kind of error it is: invalidnumber error
  • We can see and access any needed data on the error: 2

Wrapping things up

You might be hesitant to adding random integers all over your errors and one might argue why not just bring back stack traces using reflection. This is a valid argument.

But remember when I said stamps have no meaning; Turns out this is actually an advantage.

  • Stamps are shorter compared to file paths and line numbers that come with stack traces.
  • Stamps are context free which means they're immune to changes and refactors
  • Stamps are easier to log
  • Stamps can be safely exposed to the client/public. I'm perfectly fine with adding a stamp as part of my API error response cause to the outside world, its meaningless.
  • Stamps don't rely on runtime reflection, hereby pay no performance penalty.

Stamping errors has been a game changer for me. I no longer waste precious time polishing error messages to perfection. I don't spend ridiculous amounts of hours staring at error logs trying to trace the call stack that led to my error.
Having a reliable and robust system means I can consider error handling a solved problem in GoLang.

One last thing. Remember the shorthand syntax concept for propagating errors.

func start()? {  
  db := connectToDB()?

  runMigrations(db, "/migrations")?

  r := setupServer(db)?
}

Think about the implications we detailed so far. Nowadays I consider this a code smell and actually see it as bad design.

Unless the shorthand syntax can add stack traces or identifiers to the propagated errors, we are right back where we started.
Plus it'll be much much harder convincing a developer to stop using a shorthand syntax which exist in the language than telling them to just wrap their errors with a unique identifier. They're already writing if err != nil anyways.


On a final note. I still think stack traces are an unsolved problem in modern error handling where errors are treated as values. I'm not sure of any language that has solved this either. And I'm now convinced shorthand notations worsens the problem.

Till then, my motto is:

stamp your errors and move on

Happy hacking all.
Thanks for reading 😎