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 foo
s 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:
- What went wrong. (Required. This is usually the error message)
- Where it went wrong. (Required. This is where you'd need a stack trace or some form of identifier)
- Type of error it is. (Optional. Could be error kinds, custom error types etc.)
- 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
called1745172208
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 😎