Error Handling in Go
If you've written code in almost any other mainstream language, you've
lived with try/catch, and probably been bitten by it at least once —
an exception thrown three layers deep in a library you didn't write,
caught (or not) in a handler you can't easily find just by reading the
function in front of you. Go's designers looked at that pattern and made
a genuinely unusual choice: they left exceptions out entirely. Not as an
oversight — as a considered rejection, and this chapter is about
understanding why, and how the resulting style actually works in practice.
Go treats failure the way a careful engineer treats a warning light on a dashboard: it doesn't stop the car for you and it doesn't hide the light either — it puts the light directly in front of you, at the exact moment it turns on, and trusts you to look.
Go has no try/catch and no exceptions. Every operation that can fail
returns an ordinary value — an error — alongside its result, and it is
the caller's job to check it. This chapter explains why Go made that
choice, how to build and inspect errors idiomatically, and the narrow
cases where Go does use something exception-like: panic and recover.
The error Interface
error is a built-in interface with exactly one method:
type error interface {
Error() string
}
Any type with an Error() string method satisfies it — which means an
error is just a value like any other, comparable, storable, and passable
around like an int or a string. The simplest way to create one is
errors.New:
import "errors"
func withdraw(balance, amount float64) (float64, error) {
if amount > balance {
return balance, errors.New("insufficient funds")
}
return balance - amount, nil
}
The idiomatic caller pattern checks the error immediately, before touching the result:
newBalance, err := withdraw(100, 150)
if err != nil {
fmt.Println("withdrawal failed:", err)
return
}
fmt.Println("new balance:", newBalance)
An error you don't check is a bug you haven't found yet. Go's design makes that bug visible in the source code itself: an ignorederris a line that stands out to any reviewer scanning for a missingif err != nilblock.
result, _ := doSomething() compiles perfectly and silently discards whatever went wrong. Nothing in the language forces you to check err — the discipline is cultural, not compiler-enforced. Tools like go vet and linters such as errcheck exist specifically to flag ignored errors that slipped past review.Why Go Rejected Exceptions
Languages with exceptions let an error thrown deep in a call stack
propagate upward invisibly until some catch block, possibly many frames
away, decides to handle it — or doesn't, and the program crashes at a
point that may be nowhere near where anything actually went wrong. Go's
designers considered this a readability problem above all else: you
cannot tell, just by reading a function, which of its calls might fail or
where that failure will actually be handled. You have to go hunting.
Returning errors as ordinary values makes every possible failure point
visible in the function's own signature and body — the tradeoff is more
if err != nil lines, in exchange for control flow that never has an
invisible side door.
Formatting Errors with fmt.Errorf and %w
fmt.Errorf builds a formatted error message, and its %w verb
specifically wraps another error inside the new one, preserving a link
back to the original cause:
func loadConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err)
}
return nil
}
The resulting error's message reads naturally as a chain
("loading config from app.conf: open app.conf: no such file or
directory"), and — critically — the original *fs.PathError underneath is
still programmatically reachable through the wrapping, not just visible in
the text.
fmt.Errorf("failed: %v", err) produces a similar-looking message, but the resulting error has no relationship to err as far as errors.Is and errors.As are concerned — the chain is broken. Use %w specifically whenever the underlying error should remain inspectable by callers further up the stack.Inspecting Wrapped Errors: errors.Is and errors.As
errors.Is walks the wrapping chain looking for a specific sentinel error
value:
var ErrNotFound = errors.New("not found")
func find(id int) error {
if id != 1 {
return fmt.Errorf("looking up id %d: %w", id, ErrNotFound)
}
return nil
}
err := find(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("no such record")
}
errors.As instead walks the chain looking for an error of a specific
type, and populates a variable of that type if found — useful when you
need fields off a custom error, not just to detect its presence:
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid field: %s", e.Field)
}
err := fmt.Errorf("processing request: %w",
&ValidationError{Field: "email"})
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Println("bad field:", verr.Field)
}
| Tool | Question it answers | Compares by |
|---|---|---|
errors.Is(err, target) |
"Is this exact error (or one it wraps) target?" |
Value equality (or an Is method) |
errors.As(err, &target) |
"Does this chain contain an error of this type? If so, give it to me." | Type match, then assigns |
err == target |
Same question as errors.Is, but does not unwrap |
Identity only, no unwrapping |
err == ErrNotFound only succeeds if err is ErrNotFound directly — if it was wrapped even once with fmt.Errorf("...: %w", ErrNotFound), the comparison silently fails even though errors.Is(err, ErrNotFound) would correctly report true. Prefer errors.Is over == for any error that might have passed through wrapping.Custom Error Types
Beyond errors.New, defining a type that implements Error() string lets
an error carry structured data instead of just a message string, as
ValidationError did above. This is the idiomatic way to let callers make
decisions based on what kind of failure occurred, not just its text:
type HTTPError struct {
Code int
Msg string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Code, e.Msg)
}
func fetch(status int) error {
if status >= 400 {
return &HTTPError{Code: status, Msg: "request failed"}
}
return nil
}
err := fetch(404)
var httpErr *HTTPError
if errors.As(err, &httpErr) && httpErr.Code == 404 {
fmt.Println("resource missing")
}
This exact shape — a struct carrying a status code, wrapped and inspected
with errors.As — is one you'll meet again almost unchanged once Part 2
starts building real HTTP clients and servers.
Combining Multiple Errors with errors.Join
Sometimes a single operation can fail in more than one independent way at
once — validating several fields, or closing several resources during
cleanup, where you want to report every failure rather than just the
first one encountered. errors.Join (added in Go 1.20) combines several
errors into one, and that combined error still works correctly with
errors.Is and errors.As for any of the errors it contains:
func closeAll(closers ...io.Closer) error {
var errs []error
for _, c := range closers {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
errors.Join(errs...) returns nil if errs is empty, and otherwise
returns a single error whose message lists each wrapped error on its own
line — checking errors.Is(combined, ErrSomeSpecific) still finds a match
if any one of the joined errors satisfies it.
panic and recover: When (Rarely) to Use Them
panic immediately stops normal execution and begins unwinding the call
stack, running any deferred functions along the way; recover, called
inside a deferred function, stops that unwinding and lets the program
continue. This looks like exception handling, and it's tempting to reach
for it that way — but idiomatic Go uses it far more narrowly than that
first impression suggests:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
result = a / b // panics on b == 0: integer divide by zero
return result, nil
}
panic is idiomatic only for programmer errors that should never happen
in correct code — an index genuinely out of range, a precondition
violated by the calling code itself — not for expected, recoverable
failures like a missing file or a closed network connection, which should
always be a returned error instead. recover shows up almost
exclusively at the boundary of a goroutine or an HTTP handler, as a safety
net that converts an unexpected panic into a logged error rather than
crashing the whole process.
If you would tell a colleague "this should never actually happen in correct code," panic is a reasonable choice. If you would tell them "this happens sometimes and here's how to handle it," return an error instead.
panic to signal an ordinary, expected failure (a record not found, a network timeout) forces every caller up the stack to either recover or crash — exactly the invisible control flow Go's designers built the error interface to avoid. Reserve panic for truly unrecoverable programmer mistakes, and let those cases crash loudly in development rather than papering over them with a stray recover.Error Handling Patterns at a Glance
| Pattern | Use when |
|---|---|
errors.New("message") |
A simple, one-off error with no extra data |
fmt.Errorf("context: %w", err) |
Adding context while preserving the original error for inspection |
Custom error type (Error() string) |
Callers need structured data (a code, a field name) from the error |
errors.Is |
Checking for a specific known sentinel error, possibly wrapped |
errors.As |
Checking for (and extracting) a specific error type, possibly wrapped |
panic/recover |
Unrecoverable programmer errors, or a top-level safety net only |
Try It Yourself
Build a tiny validation function that returns a custom, wrapped error, and practice unwrapping it at the call site:
var ErrTooYoung = errors.New("must be at least 18")
type AgeError struct {
Age int
}
func (e *AgeError) Error() string {
return fmt.Sprintf("age %d is invalid", e.Age)
}
func validateAge(age int) error {
if age < 18 {
return fmt.Errorf("validating age %d: %w: %v",
age, ErrTooYoung, &AgeError{Age: age})
}
return nil
}
- The
%w: %vinvalidateAgeonly wraps the first error passed to%w(ErrTooYoung) — confirm witherrors.Isthat it's detected, and confirm witherrors.Asthat*AgeErroris not found, since it was formatted with%v, not%w. - Fix
validateAgeso both errors are wrapped and inspectable, using Go 1.20+'s support for multiple%wverbs in onefmt.Errorfcall:fmt.Errorf("validating age %d: %w: %w", age, ErrTooYoung, &AgeError{Age: age}). - Re-run both
errors.Is(err, ErrTooYoung)anderrors.As(err, &ageErr)and confirm both now succeed. - Bonus: write a second sentinel,
ErrTooOld, and extendvalidateAgeto return it forage > 130, then handle both sentinels in the caller with twoerrors.Ischecks.
Frequently Asked Questions
I wrapped an error with fmt.Errorf("failed: %v", err) and errors.Is still says it doesn't match — why?
Because %v only borrows the original error's text for the new message; it doesn't link the two errors together in any way errors.Is or errors.As can follow. Only %w preserves the chain, so the underlying error stays programmatically reachable, not just visible in the printed string. Swap %v for %w and the same check will succeed.
What's actually different between errors.Is, errors.As, and just writing err == target?
err == target checks identity only and never unwraps, so it silently fails the moment the error has been wrapped even once — a common trap this chapter calls out directly. errors.Is walks the whole wrapping chain looking for a specific sentinel value, which is what == should have done but doesn't. errors.As walks the same chain but looks for an error of a specific type and populates a variable with it, which is what you want when you need fields off a custom error like ValidationError, not just a yes/no on its presence.
Why doesn't Go just give me try/catch like every other language I've used?
Because Go's designers saw exceptions as a readability problem more than anything else: an exception thrown deep in a call stack can surface — or get silently swallowed — many frames away from where anything actually went wrong, and you can't tell just by reading a function which of its calls might fail. Returning errors as ordinary values keeps every failure point visible directly in the function's signature and body, at the cost of more if err != nil lines.
When is it actually correct to reach for panic instead of returning an error?
Only for programmer errors that should never happen in correct code at all — a genuinely out-of-range index, a precondition the calling code itself violated — never for expected, recoverable situations like a missing file or a timed-out connection, which should always come back as an ordinary error. A useful test from this chapter: if you'd tell a colleague "this should never actually happen," panic is reasonable; if you'd tell them "this happens sometimes and here's how to handle it," return an error instead.
Do I need errors.Join if I already know how to wrap one error with %w?
They solve different shapes of the same problem. %w links exactly one underlying error to a new, more specific message — use it when there's a clear single cause. errors.Join is for when several independent errors come out of the same operation with none of them being "the" cause, like closing three files where two failed — and the joined result still works correctly with errors.Is and errors.As against any error it contains.
Where This Goes From Here
Stack every idea from this chapter next to each other and a single
philosophy falls out: Go would rather make failure loud, local, and
ordinary than make it rare-looking and mysterious. An error sits right
there in the return signature. A panic is reserved for things that
should never happen at all. Nothing important gets to fail silently three
files away from where you're reading.
You now have the full shape of "a Go program that does something and
might fail doing it." The next chapter zooms out from a single function to
an entire codebase: how Go organizes many files and many programmers'
work into packages and modules, and how go.mod keeps track of exactly
which version of exactly which dependency your code is actually running
against — the part of Go that starts to matter the moment a project grows
past one file.