Highly reliable Go code - Don't ignore errors when you defer, use this pattern instead
Continuing our series on highly reliable Go code, here is a nice pattern for handling errors when you defer
. Let’s start with a common problem. If you are building a Go service, you might have code that looks like this:
s := http.Server{ /* ...setup... */ }
// This returns an error we're ignoring!
defer s.Shutdown(ctx)
s.ListenAndServe()
Improving http.Server
usage
There’s a problem here: two functions return error
, and we’re ignoring both of them! We can do better.
For ListenAndServe
, the fix is straightforward — we should use LogOnError
. You can go to the linked post to learn more, but the key is that LogOnError
is a function that takes an error and logs it if it’s not nil. The deferred Shutdown
is trickier though. Here’s why: when using defer
, all arguments to the function are evaluated immediately, but the function call itself is delayed until the surrounding function returns. So this won’t work:
// Wrong! Shutdown runs immediately
defer LogOnError(s.Shutdown(ctx))
The Shutdown
is called immediately, and only the LogOnError
call is deferred. That’s not what we want! We want both the shutdown and its error handling to happen when the function returns.
Here’s a cleaner solution using a new interface:
type ShutdownWithError interface {
Shutdown(ctx context.Context) error
}
func ShutdownWithError(ctx context.Context, s ShutdownWithError) {
LogOnError(s.Shutdown(ctx))
}
Now we can write:
defer ShutdownWithError(ctx, s)
LogOnError(s.ListenAndServe())
This gives us exactly what we want: proper shutdown timing and error handling. The trick here is that Go’s http.Server
automatically implements our ShutdownWithError
interface, even though its authors never knew about it. This is one of Go’s powerful features — we can declare new interfaces that existing types automatically implement.
Improving Database Transaction Usage
This pattern is particularly useful for database transactions. Best practices dictate that you should always call Rollback
on transactions — it’s either a no-op (when the transaction is already committed) or it prevents resource leaks. The typical pattern for transaction code is:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// This returns an error we're ignoring!
defer tx.Rollback()
// ... do database stuff using the transaction...
tx.Commit()
But Rollback
returns an error, and we’re ignoring it! We can apply the same pattern as before:
type RollbackWithError interface {
Rollback() error
}
func RollbackWithError(r RollbackWithError) {
// Ignore ErrTxDone, it just means things were already committed
LogOnError(r.Rollback(), sql.ErrTxDone)
}
This new function, RollbackWithError
, lets us write cleaner rollback code that no longer ignores errors:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer RollbackWithError(tx)
// ... do database stuff using the transaction...
tx.Commit()
Use it!
This pattern does more than just make linters happy — it makes your code more reliable. You won’t silently ignore errors, but you also don’t have to change the flow of your code to get that benefit. One interface and one function, and you get the safe behavior you want. While you don’t need to handle every error, you should never be in a situation where errors occur without your knowledge. This pattern ensures you’ll know when things go wrong, even when the errors aren’t otherwise handled.
If you liked this, I recommend also checking out LogOnError
and Must
, which are related techniques that make it easier to safely handle errors succinctly. All code in this blog post is CC0, which means you can freely use it however you want.
Happy coding!
Contribute to the discussion by replying on BlueSky!