The Art of Error Handling in Go
Error handling in Go can be confusing for beginners and for those accustomed to other programming languages. In this article, we will distinguish between errors (error
) and exceptions (panic
), discuss what constitutes a “good” error (one that is easy to check and debug), and present a common method for improving error handling through the use of fmt.Errorf()
.
By the way, if you’re new to Go and come from a different programming language background, I recommend reading my previous post, “The Second Language Guide to Golang”.
In this context, “users” refers to developers writing code (that’s you!).
Distinguishing Between Exceptions (panic
) and Errors (error
)
Go categorizes unexpected program states into two types: panic
(exceptions) and error
(errors).
Similar to throw
in Java/JS or raise
in Python, panic
halts program execution and enters the exception handling logic. Exceptions can be caught and handled using defer recover
at any level of the call stack. Uncaught panics
cause the program to exit abnormally after printing the stack trace. panic
is rarely used in user code and is generally reserved for language-level issues such as division by zero, out of memory errors, or type assertion failures. These are uncommon and usually indicate a flaw in the user’s implementation, so they’re not typically the focus of defensive programming.
error
, on the other hand, is much more mundane. Any value that satisfies the error
interface can be considered a valid error:
|
|
error
is just a value, treated no differently by the runtime; ignoring it won’t cause the program to exit with a stack trace.
Errors are prevalent in user code, representing issues such as connection timeouts, JSON parsing failures, or file not found errors. These are common, often directly related to the business logic, and typically require defensive programming.
In summary:
panic
: An exception not focused on defense, with no specific throw location.error
: An error that requires careful consideration and handling by the user, with a fixed and obvious throw location. Whenever a function’s return value tuple includes anerror
type, the user needs to consider how to handle it.
In other words, the fact that error
is a value in Go encourages users to consider and handle each error, leading to robust and resilient programs.
We Prefer “Good” Errors
Yes, errors can be good or bad.
For example, suppose you have a weather API service that checks Redis for cached responses before querying an external service. If the call fails, the error message might simply say context deadline exceeded
, indicating a timeout somewhere. But where? Was it the cache or the external API?
Good errors provide clear causes and context from the call stack, making them easy to diagnose without guesswork. Imagine if the error message instead read:
reading cache: redis GET: context deadline exceeded
Or included a stack trace:
context deadline exceeded
goroutine 1 [running]:
main.Example(0x19010001)
/Users/hello/main.go
temp/main.go:8 +0x64
main.main()
/Users/bill/main.go
temp/main.go:4 +0x32
Your debugging effort would be much easier.
I prefer the first method; manually added textual error contexts are more readable, contain higher information density, and can be understood even by those unfamiliar with the code.
Providing Context to Errors
Introduced in Go 1.13 (September 2019), fmt.Errorf()
allows for adding context to errors, and the errors standard library added Is()
, As()
, and Unwrap()
for easier error discrimination and comparison (a topic for another day).
This feature was decided upon after much discussion; the Go maintainers are generally cautious about introducing new features.
Before Go 1.13, the community experimented with various approaches, with the third-party library pkg/errors being a notable example. It explored both adding context and stack traces to errors.
The usage of fmt.Errorf()
looks something like this, recalling the “good error” we aimed for:
reading cache: redis GET: context deadline exceeded
This error unfolds like a story, progressing layer by layer from left to right. Each layer of fmt.Errorf()
narrates what it attempts to do, followed by a colon (:
) that separates it from the next layer, with the %w
verb indicating an error to wrap. In the pseudo-code below, where FindWeather()
calls ReadCache()
, consider the error received by the caller of FindWeather()
when ReadCache()
encounters an error:
|
|
Indeed, repeating if err != nil
and fmt.Errorf()
does require a bit of typing (a trade-off), but consider configuring your favorite IDE’s autocomplete feature to save some keystrokes.
Static analysis tools for Go can help identify errors in the code that you forgot to handle (sometimes critically so) and can flag improper error context formatting (e.g., %d
, %f
, %v
, %w
, %x
). I usually employ a collection of these tools in the form of golangci-lint.
Other Ways to Add Context to Errors
Although the official introduction of textual error context into the standard library signals a commitment to this approach, you might still find other methods for adding context to errors useful. e.g. Adding stack traces to errors with pkg/errors.