Error Handling in Go
Master Go's error interface, create custom errors, use fmt.Errorf for wrapping, and apply errors.Is and errors.As for inspection.
The error Interface
In Go, errors are ordinary values — not exceptions and not special language constructs. Any type that implements the built-in error interface can be used as an error. This design keeps error handling explicit and local: you see exactly which calls can fail, and you handle each failure where it happens rather than in a distant catch block. It also means errors can carry arbitrary data, be composed, and be inspected with the full power of Go’s type system.
// The entire error interface — just one method
type error interface {
Error() string
}
Functions signal failure by returning an error as the last return value. nil means success:
func divide(a, b float64) (float64, error) {
if b == 0 {
// errors.New creates a simple error with a fixed message
return 0, errors.New("division by zero")
}
return a / b, nil // nil = no error
}
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err) // Error: division by zero
return
}
fmt.Println(result)
Always check errors immediately after the call that produced them.
Creating Errors
Go provides several ways to create errors, each suited to different situations. Simple string errors work for static messages. Formatted errors let you include dynamic values. Sentinel errors — package-level variables — are the right choice when callers need to identify a specific condition by comparing against a known value.
import (
"errors"
"fmt"
)
// Simple static error message
err1 := errors.New("something went wrong")
// Formatted error message with dynamic content (does not wrap)
err2 := fmt.Errorf("user %d not found", 42)
// Sentinel errors — package-level variables that callers can compare against
// Name them with the Err prefix by convention
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrTimeout = errors.New("operation timed out")
)
func findUser(id int) error {
if id <= 0 {
return ErrNotFound // return the sentinel directly
}
return nil
}
err := findUser(-1)
if err == ErrNotFound {
fmt.Println("user does not exist")
}
Error Wrapping with fmt.Errorf and %w
As an error travels up the call stack, each layer should add context explaining what it was doing when the failure occurred. fmt.Errorf with the %w verb does exactly this: it creates a new error whose message includes your context string, while keeping the original error accessible inside the chain. The result reads like a breadcrumb trail — you can see exactly which operation failed and why.
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// %w wraps err — the original is preserved inside the new error
return fmt.Errorf("readConfig %q: %w", path, err)
}
_ = data
return nil
}
func loadApp() error {
if err := readConfig("/etc/app/config.json"); err != nil {
return fmt.Errorf("loadApp: %w", err) // wrap again with more context
}
return nil
}
err := loadApp()
// err.Error() = "loadApp: readConfig \"/etc/app/config.json\": open /etc/app/config.json: no such file or directory"
// Reads left-to-right: what failed (loadApp) → what it was doing (readConfig) → the root cause
The error message reads like a call stack — each layer adds context before the : separator.
errors.Is — Checking Sentinel Errors
Once errors are wrapped, you can no longer compare them with == — the wrapping adds a layer that breaks direct equality. errors.Is solves this by unwrapping the entire error chain and checking if any error in it matches your target. This lets you write defensive, layer-agnostic error checks that work regardless of how deeply an error has been wrapped.
var ErrNotFound = errors.New("not found")
func getRecord(id int) error {
// wraps ErrNotFound with additional context
return fmt.Errorf("getRecord %d: %w", id, ErrNotFound)
}
err := getRecord(99)
// Direct comparison fails — err is a *fmt.wrapError, not ErrNotFound
fmt.Println(err == ErrNotFound) // false
// errors.Is unwraps the chain and finds ErrNotFound inside
fmt.Println(errors.Is(err, ErrNotFound)) // true
if errors.Is(err, ErrNotFound) {
fmt.Println("record does not exist — return 404")
}
errors.As — Extracting Typed Errors
errors.As is errors.Is for types rather than values. It walks the error chain looking for an error that can be assigned to your target type. When it finds one, it extracts it so you can access the type’s fields. This is how you retrieve structured error data — like an HTTP status code, a field name, or a database constraint — from deep inside a wrapped error chain.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
}
return nil
}
func processUser(age int) error {
if err := validateAge(age); err != nil {
return fmt.Errorf("processUser: %w", err) // wrapped — but still extractable
}
return nil
}
err := processUser(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
// valErr is now populated — access its fields directly
fmt.Printf("Bad field: %s — %s\n", valErr.Field, valErr.Message)
// Bad field: age — must be between 0 and 150
}
Custom Error Types
Custom error types let you attach machine-readable data to failures. Instead of callers parsing error strings, they extract a typed value and switch on its fields. This is the right approach whenever different error conditions require different handling — for example, mapping HTTP status codes to user-facing messages or retry logic.
// HTTP-style status error — carries a numeric code alongside the message
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
func fetchData(url string) error {
// simulate a 404 response
return &StatusError{Code: 404, Message: "resource not found"}
}
err := fetchData("https://api.example.com/users/99")
var statusErr *StatusError
if errors.As(err, &statusErr) {
// switch on the numeric code — no string parsing needed
switch statusErr.Code {
case 404:
fmt.Println("Not found — check the ID")
case 500:
fmt.Println("Server error — retry later")
default:
fmt.Println("Unexpected status:", statusErr.Code)
}
}
Joining Multiple Errors (Go 1.20+)
Some operations need to validate or process many things and report all the failures at once rather than stopping at the first one. errors.Join (introduced in Go 1.20) combines multiple errors into a single error value. The resulting error satisfies errors.Is and errors.As for any of the constituent errors, so callers can still inspect individual conditions.
import "errors"
// validateForm collects ALL validation failures instead of stopping at the first
func validateForm(name, email string, age int) error {
var errs []error
if name == "" {
errs = append(errs, errors.New("name is required"))
}
if !strings.Contains(email, "@") {
errs = append(errs, errors.New("invalid email"))
}
if age < 0 {
errs = append(errs, errors.New("age must be non-negative"))
}
return errors.Join(errs...) // nil if errs is empty
}
err := validateForm("", "notanemail", -1)
if err != nil {
fmt.Println(err)
// name is required
// invalid email
// age must be non-negative
}
Panic and Recover
Panics are for truly unrecoverable situations: nil pointer dereferences, out-of-bounds index access, or violated invariants that mean the program cannot continue safely. They are not a substitute for error returns. The main use of recover is in library code that must not crash the caller — for example, an HTTP server that catches panics in a handler so one bad request doesn’t bring down the entire server.
func safeDivide(a, b int) (result int, err error) {
defer func() {
// recover catches any panic that occurred in this function
if r := recover(); r != nil {
err = fmt.Errorf("recovered panic: %v", r)
}
}()
return a / b, nil // integer division by zero causes a panic at runtime
}
result, err := safeDivide(10, 0)
fmt.Println(result, err) // 0 recovered panic: runtime error: integer divide by zero
recover() must be called inside a defer function to catch a panic. It’s primarily used in library code that must not crash the calling program.
Practical Example — Repository with Rich Errors
This example shows the full pattern: a sentinel error for generic matching, a custom type for structured details, and a custom Is method so both work together. Callers can do a broad check (errors.Is(err, ErrNotFound)) for HTTP status decisions, and a detailed check (errors.As) when they need to log which resource was missing.
package main
import (
"errors"
"fmt"
)
// Sentinel — for generic "not found" checks anywhere in the codebase
var ErrNotFound = errors.New("not found")
type User struct {
ID int
Name string
}
// NotFoundError carries structured detail about what was missing
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with id %d not found", e.Resource, e.ID)
}
// Is allows errors.Is(err, ErrNotFound) to match even though this is a different type
func (e *NotFoundError) Is(target error) bool {
return target == ErrNotFound
}
type UserRepo struct {
users map[int]User
}
func (r *UserRepo) Find(id int) (User, error) {
u, ok := r.users[id]
if !ok {
// return the rich type — callers can use Is or As depending on what they need
return User{}, &NotFoundError{Resource: "user", ID: id}
}
return u, nil
}
func main() {
repo := &UserRepo{
users: map[int]User{1: {1, "Alice"}, 2: {2, "Bob"}},
}
u, err := repo.Find(99)
if err != nil {
// Generic check — enough to decide on a 404 HTTP response
if errors.Is(err, ErrNotFound) {
fmt.Println("returning 404 to client")
}
// Detailed check — useful for logging or debugging
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Printf("resource=%s id=%d\n", nfe.Resource, nfe.ID)
}
return
}
fmt.Println(u)
}