Error Handling in Go
Go does not have exceptions. Instead, any function that can fail returns an ordinary value that satisfies the built-in error interface, and the caller is expected to check it immediately. This makes failure paths explicit and visible in the code, rather than hidden in a stack of invisible try/catch jumps. Once you understand how error works, how to wrap errors for context, and how to inspect wrapped errors with errors.Is and errors.As, you can handle failure in Go idiomatically and reliably.
Overview / How it works
In Go, error is a built-in interface with a single method:
type error interface {
Error() string
}
Any type that has an Error() string method automatically satisfies this interface — there is no implements keyword to write, because Go interfaces are satisfied implicitly. This is why you can pass around a plain string-backed error (created with errors.New), a custom struct with extra fields, or a wrapped error, and they all work anywhere an error is expected.
By convention, a function that can fail returns its normal result plus an error as the last return value:
value, err := doSomething()
If err is nil, the call succeeded and value is meaningful. If err is non-nil, something went wrong and value should usually be treated as unreliable (often it is the zero value). The compiler does not force you to check err, but idiomatic Go always does, immediately after the call:
if err != nil {
// handle or propagate the error
}
Go deliberately avoids exceptions for routine failures. A thrown exception creates an invisible control-flow path that can jump out of many stack frames at once, which makes it hard to know, just by reading a function, everywhere it might exit or what state it leaves behind. Returning errors as values keeps every exit point visible in the source: you can see exactly which lines can fail and exactly what happens when they do. Go does have panic and recover, but those are reserved for truly exceptional, unrecoverable situations (like a programmer bug such as an out-of-bounds index), not for everyday failures like "file not found" or "invalid input".
Wrapping and unwrapping
As an error travels up through several layers of calling functions, each layer often wants to add context ("which operation was being attempted") without losing the original cause. The fmt.Errorf function supports this with the %w verb, which wraps an existing error inside a new one while preserving a link back to it:
wrapped := fmt.Errorf("loading config: %w", err)
Under the hood, fmt.Errorf with %w returns a value whose type has an Unwrap() error method returning the original err. This forms a chain: wrapped error → original error → possibly another wrapped error beneath that, and so on. The standard library’s errors package can walk this chain for you: errors.Is(err, target) walks the chain calling Unwrap() repeatedly, checking at each link whether it equals (or reports itself equal to, via an Is method) the target error. errors.As(err, &target) similarly walks the chain looking for a link whose concrete type matches target, and if found, assigns it into target. This is how you can add layers of human-readable context while still letting calling code detect a specific underlying failure or extract a specific error type.
Syntax
result, err := someFunction(args)
if err != nil {
// inspect, wrap, log, or return err
}
| Form | Purpose |
|---|---|
errors.New("message") |
Creates a simple error with a fixed message. Good for sentinel errors. |
fmt.Errorf("context: %v", err) |
Creates a new error whose message includes err‘s message, but does not preserve the chain. |
fmt.Errorf("context: %w", err) |
Same as above, but wraps err so errors.Is/errors.As can still find it. |
errors.Is(err, target) |
Reports whether target appears anywhere in err‘s wrap chain. |
errors.As(err, &target) |
Finds the first error in the chain whose type matches target and assigns it. |
Unwrap() error |
Optional method a custom error type implements to expose the error it wraps. |
Examples
Example 1: Checking a basic error
package main
import (
"fmt"
"strconv"
)
func main() {
input := "42"
n, err := strconv.Atoi(input)
if err != nil {
fmt.Println("conversion failed:", err)
return
}
fmt.Println("parsed value:", n)
input2 := "abc"
n2, err := strconv.Atoi(input2)
if err != nil {
fmt.Println("conversion failed:", err)
return
}
fmt.Println("parsed value:", n2)
}
Output:
parsed value: 42
conversion failed: strconv.Atoi: parsing "abc": invalid syntax
strconv.Atoi returns (int, error). The first call succeeds, so err is nil and execution continues. The second call fails because "abc" is not a valid integer, so err is non-nil, the program prints the message, and return exits main before the final line runs.
Example 2: A custom error type
package main
import (
"fmt"
)
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
if age > 150 {
return &ValidationError{Field: "age", Msg: "must be realistic"}
}
return nil
}
func main() {
ages := []int{30, -5, 200}
for _, age := range ages {
if err := validateAge(age); err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("valid age:", age)
}
}
Output:
valid age: 30
error: validation failed on field "age": must not be negative
error: validation failed on field "age": must be realistic
ValidationError is a plain struct with a pointer-receiver Error() method, so *ValidationError satisfies the error interface. Because it carries a Field and Msg, callers who care could inspect those fields directly (with a type assertion or errors.As) instead of only reading a formatted string.
Example 3: Wrapping errors and inspecting the chain
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("item not found")
type DBError struct {
Op string
Err error
}
func (e *DBError) Error() string {
return fmt.Sprintf("db operation %q failed: %v", e.Op, e.Err)
}
func (e *DBError) Unwrap() error {
return e.Err
}
func fetchItem(id int) error {
if id != 1 {
return &DBError{Op: "fetchItem", Err: ErrNotFound}
}
return nil
}
func main() {
err := fetchItem(42)
if err != nil {
fmt.Println("error:", err)
if errors.Is(err, ErrNotFound) {
fmt.Println("reason: the item does not exist")
}
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Println("failed operation:", dbErr.Op)
}
}
}
Output:
error: db operation "fetchItem" failed: item not found
reason: the item does not exist
failed operation: fetchItem
ErrNotFound is a sentinel error — a package-level error value meant to be compared against. DBError wraps it and exposes it through Unwrap(). errors.Is(err, ErrNotFound) walks from the *DBError down through Unwrap() and finds ErrNotFound, so it reports true even though err‘s concrete type is *DBError, not ErrNotFound itself. errors.As(err, &dbErr) walks the same chain looking for a link whose type is *DBError, finds it immediately, and assigns it into dbErr so the code can read dbErr.Op.
How it works step by step
Walking through what happens when code like errors.Is(err, ErrNotFound) runs against a wrapped error:
- Go compares
errdirectly toErrNotFoundusing==. In example 3 this fails, becauseerr‘s concrete type is*DBError, not*errors.errorString. errors.Isthen checks whethererrhas anUnwrap() errormethod.*DBErrordoes, so it calls it, getting backErrNotFound.- It compares this new value to
ErrNotFoundwith==. They match (both point to the same underlying error value created byerrors.New), soerrors.Isreturnstrue. - If no match had been found,
errors.Iswould try toUnwrap()again, and again, until either a match is found or the chain ends (an error with noUnwrap()method, orUnwrap()returningnil), at which point it returnsfalse. errors.Asfollows the identical walk, but instead of comparing with==, it checks at each link whether that link’s concrete type is assignable to the target pointer’s type, stopping and assigning as soon as it finds a match.
Common Mistakes
1. Discarding the error
Assigning an error to _ silently throws away information about failure:
n, _ := strconv.Atoi(input)
fmt.Println(n)
If input is invalid, n is simply 0 and the program has no idea the conversion failed — it proceeds as though it succeeded. Always check the error:
n, err := strconv.Atoi(input)
if err != nil {
log.Fatalf("invalid input: %v", err)
}
fmt.Println(n)
2. Comparing wrapped errors with ==
Once an error has been wrapped, it is no longer identical to the original sentinel, so a direct comparison silently fails to match:
err := fetchItem(id)
if err == ErrNotFound {
fmt.Println("not found")
}
If fetchItem returns ErrNotFound wrapped inside a *DBError (as in example 3), err == ErrNotFound is false even though the underlying cause really is "not found". Use errors.Is, which walks the wrap chain instead of doing a single direct comparison:
err := fetchItem(id)
if errors.Is(err, ErrNotFound) {
fmt.Println("not found")
}
3. Shadowing err with := inside an if
Using := inside an if statement creates a new, block-scoped variable that shadows an outer one of the same name:
var err error
if err := doSomething(); err != nil {
log.Println(err)
}
// err is still nil here, even though doSomething failed
fmt.Println("proceeding, err:", err)
The err inside the if is a brand-new variable that only exists for the lifetime of the if; the outer err is never touched, so code after the block still sees nil even after a real failure. Reuse the outer variable with plain assignment instead of :=:
var err error
err = doSomething()
if err != nil {
log.Println(err)
}
fmt.Println("proceeding, err:", err)
4. Wrapping with %v instead of %w
Both verbs produce a similar-looking message, but only %w preserves the chain that errors.Is and errors.As rely on:
return fmt.Errorf("fetch failed: %v", err)
With %v, the resulting error has no Unwrap() method, so any later errors.Is(result, err) or errors.As call fails to find err, even though its text is embedded in the message. Use %w whenever the caller might need to detect or extract the original error:
return fmt.Errorf("fetch failed: %w", err)
Best Practices
- Check every error immediately after the call that can produce it — don’t let it flow further unchecked.
- Wrap errors with
fmt.Errorfand%wwhen adding context, soerrors.Is/errors.Askeep working further up the call stack. - Declare sentinel errors (
var ErrX = errors.New("...")) for conditions callers need to detect and compare against witherrors.Is. - Define a custom error type when callers need structured data about the failure (a field name, a status code), not just a message string.
- Write error messages in lowercase with no trailing punctuation, since they’re often embedded inside other error messages.
- Use
errors.Isfor sentinel comparisons anderrors.Asfor extracting a specific error type — never==on an error that might be wrapped. - Reserve
panic/recoverfor programmer bugs or truly unrecoverable situations, not for routine, expected failures. - Add context that says which operation failed ("reading config", "connecting to db") rather than repeating the same context at every layer of the call stack.
Practice Exercises
- Write a function
divide(a, b int) (int, error)that returns an error whenbis zero, and a normal quotient otherwise. Call it with a few pairs of numbers, including one wherebis zero, and print either the result or the error. - Define a custom error type
ParseErrorwithLine intandMsg stringfields and anError() stringmethod. Write a function that returns a*ParseErrorfor bad input, then inmainuseerrors.Asto extract it and print itsLinefield. - Create a sentinel error
ErrPermission. Write two functions where the first returnsErrPermissionwrapped with%wand some context, and the second wraps that returned error again with more context. Inmain, call the outer function and useerrors.Isto confirmErrPermissionis still detectable through both layers of wrapping.
Summary
- Go represents failure as ordinary values satisfying the built-in
errorinterface (Error() string), checked explicitly instead of thrown as exceptions. - Always check
errimmediately after a call that returns one; never silently discard it with_except deliberately in throwaway code. errors.Newandfmt.Errorfcreate errors;fmt.Errorfwith%wwraps an existing error and preserves it in a chain, while%vdoes not.errors.Iswalks that chain to check for a specific sentinel error;errors.Aswalks it to find and extract an error of a specific concrete type.- Custom error types implementing
Error()(and optionallyUnwrap() error) let you attach structured data to a failure instead of only a message string. panicandrecoverexist for exceptional, unrecoverable situations, not as a substitute for routine error handling.
