Effective Error Handling Patterns
Go treats errors as ordinary values instead of exceptions: any function that can fail simply returns an extra error result alongside its normal output, and the caller decides what to do next. This keeps control flow explicit and visible right in the code you read, but it also means the quality of a Go program depends heavily on how carefully you check, wrap, and communicate those errors. This lesson covers the full toolkit for handling errors well in modern Go: sentinel errors, error wrapping with %w, errors.Is and errors.As, custom error types, and the patterns experienced Go developers rely on to keep error handling both correct and readable.
Overview: How Go Handles Errors
The error type is not a special language construct — it is a plain built-in interface, conceptually defined as:
type error interface {
Error() string
}
Any type with a method matching exactly this signature — Error() string — automatically satisfies the error interface. There is no implements keyword and no explicit declaration of intent, the way you might write in Java or C#. This is Go’s implicit interface satisfaction: the compiler checks structurally, at compile time, whether a type’s method set matches an interface, and if it does, the type can be used anywhere that interface is expected. That is why errors.New("boom"), a custom *MyError struct with an Error() method, and a value returned from the standard library can all be assigned to the same error variable.
Go deliberately has no exception mechanism for routine failures. A file that doesn’t exist, a network call that times out, a string that fails to parse — these are expected, recoverable outcomes, and Go’s designers chose to make them visible in a function’s signature and at its call sites rather than hiding them behind an invisible, hard-to-trace throw/catch path. panic and recover do exist, but they are reserved for programming errors and truly unrecoverable situations — not for things like “the file wasn’t found.”
Under the hood, an error variable — like every interface value in Go — is really a two-word pair: a pointer to type information describing the concrete value it holds, and the data itself (a pointer, for pointer types). This has a surprising consequence: an interface value is only == nil when both the type and the value slots are nil. If you store a nil pointer of a concrete type — say, a nil *MyError — inside an error variable, the interface’s type slot gets filled in (*MyError) even though the value slot is nil. The resulting interface is not equal to nil. This “typed nil” trap catches even experienced Go programmers and is covered in Common Mistakes below.
Since Go 1.13, the standard errors package and fmt.Errorf support error wrapping: one error can carry a reference to the error that caused it, forming a chain. errors.Is and errors.As walk that chain so you can check for a specific error, or extract a specific error type, even after it has been wrapped several layers deep by intermediate functions. Go 1.20 added errors.Join, which combines multiple independent errors into a single value that errors.Is/errors.As can still inspect — handy when several independent checks fail at once.
Syntax
There is no special syntax for errors beyond ordinary Go — errors are just values of type error, checked with a plain if. The idiomatic shape looks like this:
if err != nil {
// handle the error here: log it, wrap it with more
// context, return it to the caller, or in rare cases recover
}
Any function that can fail returns its normal result alongside an error as the last return value, by convention:
func doWork(input string) (Result, error) {
if input == "" {
return Result{}, errors.New("input must not be empty")
}
// ... perform the real work ...
return Result{Value: input}, nil
}
| Function | Purpose |
|---|---|
errors.New(msg) |
Creates a new error from a plain string. Good for sentinel errors. |
fmt.Errorf(format, ...) |
Creates a formatted error; use %w to wrap another error inside it. |
errors.Is(err, target) |
Reports whether err — or anything it wraps — matches target. |
errors.As(err, &target) |
Finds the first error in the chain matching a given type and assigns it to target. |
errors.Unwrap(err) |
Returns the error directly wrapped by err, or nil. |
errors.Join(errs...) |
Combines multiple errors into one inspectable value (Go 1.20+). |
Examples
Example 1: A Basic Error Return with a Sentinel Error
The simplest pattern: define a package-level sentinel error with errors.New, return it when something goes wrong, and check it immediately after the call.
package main
import (
"errors"
"fmt"
)
var ErrDivideByZero = errors.New("division by zero")
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
_, err = divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
}
Output:
result: 5
error: division by zero
divide returns the shared ErrDivideByZero value whenever the divisor is zero. The first call succeeds and prints the result; the second call fails, the if err != nil check catches it immediately, and the function returns early rather than continuing with an invalid computation.
Example 2: Wrapping an Error and Extracting a Custom Type
Real programs pass errors up through several layers of function calls. Each layer can add context with fmt.Errorf and %w without losing the original error underneath. A caller further up the chain can still recover the original type with errors.As.
package main
import (
"errors"
"fmt"
)
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s not found", e.Name)
}
func findUser(name string) error {
return &NotFoundError{Name: name}
}
func loadProfile(name string) error {
err := findUser(name)
if err != nil {
return fmt.Errorf("loading profile for %s: %w", name, err)
}
return nil
}
func main() {
err := loadProfile("alice")
if err == nil {
fmt.Println("no error")
return
}
fmt.Println("full error:", err)
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println("missing user:", nf.Name)
}
}
Output:
full error: loading profile for alice: alice not found
missing user: alice
findUser returns a *NotFoundError. loadProfile wraps it with %w, producing an error whose message concatenates both layers but which still remembers the original *NotFoundError underneath. errors.As walks the wrap chain, finds the *NotFoundError, and assigns it to nf so the caller can read its Name field directly — something a plain string comparison could never do.
Example 3: Checking for a Specific Error with errors.Is
When you only need to know whether a particular sentinel error occurred anywhere in the chain — not extract structured data from it — errors.Is is the right tool.
package main
import (
"errors"
"fmt"
)
var ErrPermissionDenied = errors.New("permission denied")
func openResource(allowed bool) error {
if !allowed {
return fmt.Errorf("opening resource: %w", ErrPermissionDenied)
}
return nil
}
func main() {
err := openResource(false)
if errors.Is(err, ErrPermissionDenied) {
fmt.Println("access blocked:", err)
} else {
fmt.Println("unexpected error:", err)
}
}
Output:
access blocked: opening resource: permission denied
openResource wraps ErrPermissionDenied with extra context. errors.Is doesn’t compare the top-level error directly against ErrPermissionDenied — it unwraps layer by layer until it finds a match or runs out of chain, so the check succeeds even though the two values are not literally identical.
How It Works Step by Step
Understanding what fmt.Errorf("...: %w", err) actually produces makes wrapping much less mysterious:
- When you use
%win a format string,fmt.Errorfreturns a value of an internal type that stores both the formatted message and a reference to the wrapped error, and that type implements anUnwrap() errormethod returning the original error. errors.Is(err, target)starts aterrand repeatedly compares it totargetwith==(or calls anIs(error) boolmethod if the error defines one). If there’s no match, it callsUnwrap()on the current error to move one link down the chain, and repeats until it finds a match orUnwrap()returns nil.errors.As(err, target)works the same way, except at each link it checks whether that error’s concrete type is assignable to whattargetpoints to; on the first match it assigns the error intotargetand returnstrue.- This is why wrapping with
%winstead of the plain%vverb matters:%vonly formats the message into a new string with no link back to the original error, breaking the chain thaterrors.Is/errors.Asdepend on. - A function can wrap more than one error at once by passing multiple
%wverbs (Go 1.20+), or by building a value witherrors.Join; both produce an error whoseUnwrap()returns a slice, whicherrors.Is/errors.Asalso know how to walk.
Common Mistakes
Mistake 1: Discarding the Error
Using _ to throw away an error return is the single most common Go bug. The zero value of the success result silently stands in for the failure, with no warning.
package main
import (
"fmt"
"strconv"
)
func main() {
n, _ := strconv.Atoi("abc")
fmt.Println(n * 2)
}
Output:
0
strconv.Atoi("abc") fails because "abc" isn’t a number, so it returns 0 and a non-nil error — but the error is discarded, so the program silently computes with 0 as if that were the real parsed value. Checking the error surfaces the real problem:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("abc")
if err != nil {
fmt.Println("invalid number:", err)
return
}
fmt.Println(n * 2)
}
Output:
invalid number: strconv.Atoi: parsing "abc": invalid syntax
Mistake 2: Comparing a Wrapped Error with ==
Once an error has been wrapped, it is a new value — comparing it directly against a sentinel with == almost always fails, even though the sentinel is right there in the chain.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func lookup() error {
return fmt.Errorf("lookup: %w", ErrNotFound)
}
func main() {
err := lookup()
if err == ErrNotFound {
fmt.Println("not found")
} else {
fmt.Println("unexpected:", err)
}
}
Output:
unexpected: lookup: not found
err is now a wrapper value, not ErrNotFound itself, so == is false and the branch that should have recognized “not found” is skipped. Use errors.Is, which understands wrapping, instead:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func lookup() error {
return fmt.Errorf("lookup: %w", ErrNotFound)
}
func main() {
err := lookup()
if errors.Is(err, ErrNotFound) {
fmt.Println("not found")
} else {
fmt.Println("unexpected:", err)
}
}
Output:
not found
Mistake 3: Shadowing err with :=
Using := inside a nested block silently creates a brand-new local err instead of assigning to the outer one, so the outer variable never learns that anything went wrong.
package main
import "fmt"
func step(n int) (int, error) {
if n < 0 {
return 0, fmt.Errorf("negative input: %d", n)
}
return n * 2, nil
}
func main() {
var err error
n := -5
if n != 0 {
result, err := step(n) // shadows the outer err
fmt.Println("result:", result)
_ = err
}
if err != nil {
fmt.Println("failed:", err)
} else {
fmt.Println("succeeded")
}
}
Output:
result: 0
succeeded
Even though step(-5) fails, the program reports “succeeded”, because the inner result, err := step(n) declared a fresh err scoped to the if block — the outer err was never touched. Reusing the outer variable with plain = fixes it:
package main
import "fmt"
func step(n int) (int, error) {
if n < 0 {
return 0, fmt.Errorf("negative input: %d", n)
}
return n * 2, nil
}
func main() {
var err error
n := -5
if n != 0 {
var result int
result, err = step(n) // reuses the outer err, no shadowing
fmt.Println("result:", result)
}
if err != nil {
fmt.Println("failed:", err)
} else {
fmt.Println("succeeded")
}
}
Output:
result: 0
failed: negative input: -5
Mistake 4: Returning a Typed Nil as an error
This one trips up even experienced Go developers because the code looks completely reasonable. Returning a nil pointer of a concrete error type through an error-typed return value produces a non-nil interface.
package main
import "fmt"
type MyError struct {
msg string
}
func (e *MyError) Error() string {
return e.msg
}
func doSomething(fail bool) error {
var err *MyError
if fail {
err = &MyError{msg: "something went wrong"}
}
return err
}
func main() {
err := doSomething(false)
fmt.Printf("err == nil: %v, dynamic type: %T\n", err == nil, err)
}
Output:
err == nil: false, dynamic type: *main.MyError
Even though fail is false and the local *MyError pointer is nil, doSomething returns that nil pointer through an error-typed return, which fills in the interface’s type slot with *MyError. The interface is no longer == nil, so any caller doing if err != nil is fooled into thinking the call failed. The fix is to return the literal nil when there is no error, rather than a nil-valued named variable:
package main
import "fmt"
type MyError struct {
msg string
}
func (e *MyError) Error() string {
return e.msg
}
func doSomething(fail bool) error {
if fail {
return &MyError{msg: "something went wrong"}
}
return nil // return the literal nil, not a nil-valued *MyError
}
func main() {
err := doSomething(false)
fmt.Printf("err == nil: %v\n", err == nil)
}
Output:
err == nil: true
Best Practices
- Check every error immediately after the call that can produce it — don’t collect several calls and check errors later, since the state after a failure is often unusable.
- Add context when wrapping with
fmt.Errorfand%wso the resulting chain reads like a story, but avoid wrapping the same error redundantly at every single layer. - Reach for a sentinel error (
errors.New) or a custom error type only when callers need to programmatically distinguish that failure from others; otherwise a wrapped message is enough. - Prefer
errors.Isanderrors.Asover direct==comparisons or type assertions the moment wrapping is involved anywhere in the call chain. - Keep error strings lowercase and free of trailing punctuation, since they are frequently embedded inside a larger wrapped message.
- Reserve
panic/recoverfor truly unrecoverable situations or well-defined library boundaries, never for routine error flow. - Never return both a non-nil result and a non-nil error unless that combination is explicitly documented — callers should be able to trust one or the other.
- Use
errors.Join(Go 1.20+) when an operation can fail in more than one independent way at once, instead of concatenating error strings by hand.
Practice Exercises
- Write
parsePercentage(s string) (float64, error)that turns a string like"42%"into0.42. Return a wrapped error if the string doesn’t end in%or the numeric part fails to parse. Test it against"50%"(expect0.5, nil) and"abc%"(expect a non-nil error). - Define a sentinel
ErrTooManyRetriesand a functionretry(n int) errorthat returns it oncenexceeds3, wrapped with the attempt count for context. Inmain, call it with a few values ofnand useerrors.Isto print a friendly message specifically when that sentinel occurs. - Take the typed-nil example from Common Mistakes and rewrite
doSomethingso it can never return a typed nil, no matter how the function grows in the future. Add a check inmainthat prints whethererr == nilfor both a successful and a failing call.
Summary
- Go returns errors as explicit values checked with
if err != nil, not exceptions —panic/recoverare for exceptional, unrecoverable situations only. - Any type with an
Error() stringmethod satisfies theerrorinterface implicitly — there is noimplementskeyword. - Wrap errors with
fmt.Errorfand%wto add context while preserving the original error for later inspection. errors.Ischecks for a specific sentinel error anderrors.Asextracts a specific error type, and both understand wrapped chains — plain==comparisons do not.- A nil pointer stored in a non-nil-typed
errorinterface is itself non-nil — always return the literalnil, not a nil-valued named error variable. - Watch for discarded errors, shadowed
errvariables inside nested blocks, and redundant wrapping — all are common, easy-to-miss bugs.
