Notes · Learn Go › LESSON 7 OF 8

Error Handling

No try/except. Errors are values. Yes, you check every one - and you'll love it.

Contents
  1. The philosophy: errors are values
  2. The error type
  3. Returning errors from your own code
  4. Wrapping errors (the 1.13+ way)
  5. Sentinel errors and typed errors
  6. panic and recover (the emergency exit)
  7. Real-world examples
  8. Gotchas
  9. Glossary

The philosophy: errors are values

Go has no exceptions. Not "Go discourages exceptions" - it literally does not have them. There is no raise, no try, no except, no implicit stack unwinding. Instead:

This sounds painful. It isn't. Once you get used to it, you know exactly where things can fail, what happens when they do, and where errors are handled. Python code with exceptions can fail anywhere and the handler might be 10 stack frames away.

Python - exceptions as control flow
def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        raise  # re-raise
    except json.JSONDecodeError as e:
        raise ValueError(
            f"bad config: {e}"
        ) from e

# caller - might not even know
# it can throw
cfg = load_config("app.json")
Go - errors as return values
func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf(
            "loading config: %w", err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf(
            "parsing config: %w", err)
    }
    return &cfg, nil
}

// caller must deal with it
cfg, err := loadConfig("app.json")
if err != nil { /* handle */ }
Python - exceptions are invisible exits main() calls load_config load_config() calls open() open() reads file happy path FileNotFoundError ??? catch it somewhere up the stack implicit unwind - can happen at ANY call Go - errors are explicit forks main() calls loadConfig loadConfig() returns (cfg, err) err? != nil handle err return early == nil os.ReadFile() returns (data, err) err? != nil wrap + return continue process data Every fork is visible. Nothing hidden.
Left: Python - one happy path with invisible exception exits at every call. Right: Go - every call that can fail produces an explicit fork you read and handle right there.
The ELI5 version
Python exceptions are like a fire alarm. They go off somewhere deep inside and someone somewhere has to deal with them - and you might not even know which room the alarm is in.

Go errors are like a yes/no question after every action: "Did that work? Yes? Keep going. No? Here's what went wrong - handle it now."

It's more typing. But you always know exactly what's happening, and your code can never fail "mysteriously from three layers down."

The error type

error is just an interface built into Go. Here's the entire definition:

builtin (Go source)
type error interface {
    Error() string
}

That's it. Anything with an Error() string method satisfies the error interface. nil means "no error." Non-nil means something went wrong - call .Error() to get the message.

Creating errors

errors.New - simple text
import "errors"

err := errors.New("something failed")
fmt.Println(err.Error())
// "something failed"
fmt.Errorf - formatted text
import "fmt"

err := fmt.Errorf(
    "got %d items, want %d",
    got, want,
)
// "got 3 items, want 5"
FunctionReturnsUse when
errors.New("msg")A simple error with fixed textOne-time, no formatting needed
fmt.Errorf("msg %v", x)A formatted errorYou want to include variable values
fmt.Errorf("ctx: %w", err)A wrapped error (see next section)You want to preserve the original error for callers to inspect
nil is a valid error value. Returning nil as the error is how you say "success." It's not a special case - it's just the zero value of the error interface.

Returning errors from your own code

Convention: the error is always the last return value. If there's no error to return, return nil.

divide.go
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// caller
result, err := divide(10, 0)
if err != nil {
    fmt.Println("error:", err)
    return
}
fmt.Println("result:", result)

A more realistic example with a struct return:

config.go
type Config struct {
    Host string
    Port int
}

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading config: %w", err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }
    return &cfg, nil
}

The "return early on error" pattern

Go code reads top-to-bottom. The pattern is: call, check, return if bad, continue if good. This is called "guard clauses" - you handle the failure case immediately, and the happy path runs straight down without nesting.

Python - deep nesting
def process(path):
    try:
        data = open(path).read()
        parsed = json.loads(data)
        result = transform(parsed)
        return result
    except Exception as e:
        # one handler for all errors
        log(e)
        return None
Go - flat, early returns
func process(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    parsed, err := parse(data)
    if err != nil {
        return "", err
    }
    return transform(parsed), nil
}
Named return values can make error-heavy functions more readable. func loadConfig(path string) (cfg *Config, err error) - then inside you can just write return without listing the values. Use sparingly; it helps when a function has many exit paths.

Wrapping errors (the 1.13+ way)

When you get an error from a lower-level function, you usually want to add context before passing it up. The %w verb in fmt.Errorf does exactly this - it wraps the original error so callers can still inspect it.

wrap.go
// without wrapping - context lost, original gone
return nil, errors.New("failed to load config")

// with wrapping - context added, original preserved
return nil, fmt.Errorf("loading config: %w", err)

// the error message chains together:
// "loading config: reading file: permission denied"
Error wrapping chain - nested envelopes loading config: ... reading file app.json: ... permission denied *fs.PathError op="open" path="app.json" err=syscall.EACCES outer middle inner Traversing the chain errors.Is(err, fs.ErrPermission) // unwraps until it finds a match Unwrap() errors.As(err, &pathErr) // extracts typed error from chain Unwrap() errors.Unwrap(err) // peels one layer at a time All three traverse the chain automatically
Each layer adds context without losing the original error. errors.Is and errors.As traverse the entire chain searching for a match.

errors.Is - check what's in the chain

check.go
import (
    "errors"
    "io/fs"
)

// wrappedErr = "loading config: reading file: permission denied"
if errors.Is(wrappedErr, fs.ErrPermission) {
    // true - even though it's buried inside two wrappers
    fmt.Println("access denied - check file permissions")
}

// errors.Is works with == too - but only traverses wrapped chains
if errors.Is(err, ErrNotFound) { // sentinel check
    // handles ErrNotFound anywhere in the chain
}

errors.As - extract a typed error

typed.go
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    // pathErr is now populated with the *fs.PathError from the chain
    fmt.Printf("failed on path: %s\n", pathErr.Path)
}
The ELI5 version
Wrapping an error is like putting a note from your manager on top of a note from a worker. The worker's note says "the file is locked." The manager's note says "couldn't load the config."

When someone at the top needs to know if the file was locked, they don't just read the manager's note - they look inside. errors.Is automatically opens all the envelopes until it finds what you're looking for.

errors.As is like saying "I don't just want to know IF there's a note from the worker - I want to actually read that note and see the details."

Sentinel errors and typed errors

There are two common patterns for errors that callers need to handle differently.

Sentinel errors - simple binary signals

A sentinel error is a package-level variable callers can compare against. Use these when the error identity matters but no extra detail is needed.

store.go
package store

// exported sentinel - callers can check for this specifically
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("resource already exists")

func (s *Store) Get(id string) (*Record, error) {
    r, ok := s.data[id]
    if !ok {
        return nil, ErrNotFound
    }
    return r, nil
}

// caller
record, err := store.Get("abc")
if errors.Is(err, store.ErrNotFound) {
    // create it instead
}

Typed errors - when callers need details

A typed error is a struct with an Error() string method. Use these when callers need to inspect the error beyond just its identity.

validation.go
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: %s - %s", e.Field, e.Message)
}

func validateUser(u User) error {
    if u.Age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    return nil
}

// caller extracts the type with errors.As
var vErr *ValidationError
if errors.As(err, &vErr) {
    fmt.Printf("field %q: %s\n", vErr.Field, vErr.Message)
}
PatternUse whenCaller checks with
Sentinel errorBinary case: "it was not found" (no extra details needed)errors.Is(err, ErrNotFound)
Typed errorCaller needs to inspect fields (which field failed, what path, etc.)errors.As(err, &target)
Plain fmt.ErrorfError is only for humans to read, callers won't programmatically inspectJust print or log it

panic and recover (the emergency exit)

Go does have a crash mechanism: panic. But it is not an exception system. It is for truly unrecoverable situations - a bug that means the program's state is corrupt and continuing would be dangerous.

panic_example.go
// panic immediately stops this goroutine's execution
// and unwinds the stack, running any deferred functions
panic("index out of range - this is a bug, not user error")

// recover() - must be called inside a deferred function
// catches a panic and stops the unwind
defer func() {
    if r := recover(); r != nil {
        fmt.Println("caught panic:", r)
    }
}()
panic / recover - stack unwinding CALL DIRECTION main() no defer a() defer recover() ** has recover ** b() no defer c() panic("oh no") PANIC UNWIND panic unwinds b() runs, no defer recover() catches it! panic stops here a() continues normally What happens, step by step: 1. c() calls panic("oh no") 2. c() stops. Its defers run (none here). 3. b() is interrupted. Its defers run (none). 4. a()'s deferred func runs. 5. recover() returns "oh no". Panic stops. 6. a() continues after the defer. 7. main() continues normally. If a() had no recover: Unwind reaches main(), program crashes with a goroutine stack trace. That's usually what you want.
panic unwinds the call stack upward. defer runs at each frame. recover() in a deferred function stops the unwind and lets execution continue.

When to actually use panic

Python - exceptions for control flow
# totally normal in Python
try:
    value = d[key]
except KeyError:
    value = default

# StopIteration is how
# iterators signal "done"
for item in iterable:
    process(item)
Go - panic is the emergency exit only
// WRONG - don't panic for logic
func get(m map[string]int, k string) int {
    v, ok := m[k]
    if !ok { panic("not found") } // NO
    return v
}

// RIGHT - panic for programming bugs
if dep == nil {
    // nil dep is a bug in my code
    panic("dep must not be nil")
}
The rule: use panic only when continuing execution would be worse than crashing. This means programmer errors (nil dependency passed where one is required, impossible state detected in a state machine). Never use it for user input errors, network failures, or file not found.
The ELI5 version
panic is breaking the glass on a fire alarm box. recover is the firefighter who lives inside the wall and can catch the broken glass before it hits the floor.

You don't break the glass because you're a bit warm. You don't use panic because a file is missing. You break the glass when the building is actually on fire - meaning your program's internal state is so broken that continuing would cause real harm.

Most Go programs you write will never call panic directly. The standard library calls it internally for things like index-out-of-bounds, and that's fine - it means there's a bug in your code that needs fixing, not an error to handle gracefully.

Real-world error handling

How Go error handling looks in real code, not toy examples.

BackendHTTP handler with error context

An HTTP handler that wraps errors with context at each layer and returns a clean 500 to the client:

handler.go
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("id")

    user, err := h.store.GetUser(r.Context(), userID)
    if err != nil {
        if errors.Is(err, store.ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        // log the full chain for debugging, hide internals from client
        h.log.Error("getting user", "err", err, "user_id", userID)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }

    if err := json.NewEncoder(w).Encode(user); err != nil {
        // can't send a 500 at this point - headers already sent
        h.log.Error("encoding response", "err", err)
    }
}

// deeper in the stack, wrapping adds context at each layer:
func (s *Store) GetUser(ctx context.Context, id string) (*User, error) {
    row, err := s.db.QueryRowContext(ctx, "SELECT ...", id)
    if err != nil {
        return nil, fmt.Errorf("querying user %s: %w", id, err)
    }
    // ... scan row ...
    return &user, nil
}
CLIExit non-zero when file is not found

A CLI tool that checks the specific error type to give a helpful message, and exits with a non-zero code so shell scripts can detect the failure:

cli.go
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: myapp <config-file>")
        os.Exit(1)
    }
    path := os.Args[1]

    cfg, err := loadConfig(path)
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            fmt.Fprintf(os.Stderr,
                "config file %q not found\nCreate it with: myapp init\n", path)
        } else {
            fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
        }
        os.Exit(1)  // non-zero: signals failure to the shell
    }

    run(cfg)
}
terminal
$ myapp config.yaml
config file "config.yaml" not found
Create it with: myapp init
$ echo $?
1
k8sController reconcile loop

A Kubernetes controller reconciler distinguishing between transient errors (requeue) and terminal conditions (do not retry):

controller.go
func (r *MyReconciler) Reconcile(
    ctx context.Context, req ctrl.Request,
) (ctrl.Result, error) {

    obj := &myv1.MyResource{}
    if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil {
        if apierrors.IsNotFound(err) {
            // object deleted - nothing to do, don't requeue
            return ctrl.Result{}, nil
        }
        // transient error (network blip, API server unavailable)
        // return err: controller-runtime will requeue automatically
        return ctrl.Result{}, fmt.Errorf("getting object: %w", err)
    }

    if err := r.doWork(ctx, obj); err != nil {
        var permErr *PermanentError
        if errors.As(err, &permErr) {
            // this will never succeed - record status, don't requeue
            r.setStatusFailed(ctx, obj, permErr.Reason)
            return ctrl.Result{}, nil
        }
        // transient - requeue with backoff
        return ctrl.Result{RequeueAfter: 30 * time.Second}, err
    }

    return ctrl.Result{}, nil  // success
}

The pattern here is: (ctrl.Result{}, err) means "something went wrong, please retry." (ctrl.Result{}, nil) means "done." (ctrl.Result{RequeueAfter: ...}, err) means "retry but give it a moment." The error type decides which path to take.

Gotchas

1. Ignoring an error with _ is risky - did you mean to handle it?

result, _ := doThing() silently discards the error. Sometimes that's intentional (you know this specific call can't fail). But if you're not sure, that underscore is hiding a bug. Add a comment: // error ignored: X is always valid here so future-you knows it was deliberate.

2. Returning a non-nil result alongside a non-nil error confuses callers.

Convention: if you return an error, set the other return values to their zero values (nil, 0, ""). Some callers check the error first and ignore the value; others might use the value even if err != nil. Don't make them guess.

3. Don't log.Fatal in library code - return the error, let the caller decide.

log.Fatal calls os.Exit(1) - there's no recovering from it, no cleanup, no choice. If your code is a library or reusable package, you have no idea what the caller wants to do when something fails. Return the error. The caller - usually a main() function - can decide whether to fatal, retry, or fall back.

4. if err != nil { return err } loses context - wrap instead.

Bare return err means the caller sees "permission denied" with no idea where it came from. Wrap it: return fmt.Errorf("reading config file: %w", err). Now the error says "reading config file: permission denied" and the call site is obvious. The %w verb preserves the original for errors.Is/errors.As.

5. panic/recover is NOT try/except - resist the urge.

Coming from Python, the first instinct is to reach for panic like you'd reach for raise. Don't. Panics are for bugs (programmer errors), not expected failures (user errors, missing files, network timeouts). Using panic for control flow makes your code unpredictable and hard to use as a library.

6. Comparing errors with == only works for sentinel errors - use errors.Is for wrapped ones.

err == ErrNotFound fails if the error was wrapped with fmt.Errorf("...: %w", ErrNotFound) - the wrapper is a different pointer. errors.Is(err, ErrNotFound) traverses the chain and finds it. Always use errors.Is unless you wrote the error yourself and know it was never wrapped.

Glossary

TermMeaning
errorA built-in interface with one method: Error() string. The universal type for failure information in Go.
error interfaceThe full definition: type error interface { Error() string }. Any type implementing this method satisfies the interface.
sentinel errorA package-level variable (var ErrNotFound = errors.New(...)) that callers compare against to detect specific conditions.
typed errorA struct type that implements the error interface. Callers extract it with errors.As to inspect its fields.
wrappingAdding context to an error while preserving the original: fmt.Errorf("context: %w", err). The original is still accessible via the chain.
%w verbThe format verb in fmt.Errorf that wraps an error instead of just stringifying it. Enables errors.Is and errors.As to traverse the chain.
errors.IsChecks if a specific error (or any error in its wrapped chain) matches a target value. Handles wrapped errors correctly; use this instead of ==.
errors.AsFinds the first error in the chain that matches a target type and assigns it to a pointer. Used to extract typed errors from wrapped chains.
errors.UnwrapReturns the next error in a wrapped chain. Usually called indirectly via errors.Is/errors.As, rarely called directly.
panicStops the current goroutine's normal execution and begins unwinding the stack. For unrecoverable programmer errors only - not for expected failures.
recoverCalled inside a deferred function; catches a panic and returns the value passed to panic. Stops the stack unwind.
defer-recover idiomdefer func() { if r := recover(); r != nil { ... } }() - the standard pattern for catching panics at a boundary (e.g., HTTP handler wrapping plugin code).

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
error
Built-in interface: type error interface { Error() string }. Anything with an Error() string method satisfies it.
errors.New
Quick way to make a basic error: errors.New("not found"). From the std lib's errors package.
fmt.Errorf
Format error with context. Wrap with %w: fmt.Errorf("loading config: %w", err).
errors.Is
Checks if an error matches a sentinel: errors.Is(err, io.EOF). Walks the wrap chain.
errors.As
Extracts a specific typed error: var pathErr *fs.PathError; errors.As(err, &pathErr).
panic
Immediate stack unwind. For unrecoverable bugs, NOT errors.
recover
Catches a panic in a deferred function. Main use case: middleware that prevents one bad request from crashing the whole server.
Fun fact The famous Rob Pike quote (2015): "Errors are values." The Go team explicitly rejected exceptions because they hide control flow. Go's if err != nil { return err } repetition is intentional - it makes the cost of errors visible at every callsite. Go 2's experimental try proposal (2019) was rejected after community feedback - the verbosity is a feature, not a bug. Modern Go (1.13+) added wrapping with %w which makes the repetition more productive: each return adds context to the error chain.
Bug hunt

find(1) returns a typed-nil (*NotFoundError)(nil). Does err != nil enter the error branch?

typed-nil.go
package main

import "fmt"

type NotFoundError struct{}
func (NotFoundError) Error() string { return "not found" }

func find(id int) error {
    if id == 0 {
        return nil
    }
    var e *NotFoundError
    return e   // typed nil - the trap
}

func main() {
    err := find(1)
    if err != nil {
        fmt.Println("error:", err)
    } else {
        fmt.Println("no error")
    }
}
Click to reveal the bug

YES - it enters the error branch. Output: error: <nil>.

This is the typed-nil interface trap. find returns error (an interface). When you assign a typed-nil pointer ((*NotFoundError)(nil)) to an interface, the interface itself is NOT nil because it has a type assigned. Only when BOTH the type AND value are nil does an interface compare equal to nil.

Fix: always return raw nil for the error path, never a typed nil:

fix.go
func find(id int) error {
    if id == 0 {
        return nil
    }
    return nil   // not `var e *NotFoundError; return e`
}
Real-world incident Six weeks of payments lost to _ = sendToBank() $3M reconciliation + SEC inquiry

A payment processor's daily settlement job had this pattern in 30 places:

settle.go
_ = sendToBank(payment)   // silently discards errors

The leading underscore silently discarded errors. When the bank's API started returning 500s due to a partial outage, the code didn't notice. 6 weeks passed before someone noticed payments stopped settling. The money sat in a holding account. Reconciliation took 3 months. SEC questions followed.

Lesson: Never silently discard an error. Even if you genuinely don't care, log it: if err := sendToBank(payment); err != nil { log.Printf("ignoring: %v", err) }. Linters like errcheck catch the underscore pattern.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. Go's idiomatic way to return an error from a function is:

  • panic(err)
  • throw err
  • Return (result, error) as multiple return values
  • Set a global LastError variable
Show answer
Answer: c. The idiomatic pattern is the (result, error) pair. Caller checks if err != nil before using result.

2. To wrap an error with additional context (preserving the chain):

  • fmt.Errorf("loading: %v", err)
  • fmt.Errorf("loading: %w", err)
  • err.Wrap("loading")
  • errors.Wrap(err, "loading") (third-party)
Show answer
Answer: b. %w (added in Go 1.13) creates a wrapped error that errors.Is/errors.As can unwrap. %v formats the error string but loses the chain.

3. errors.Is(err, io.EOF) checks if:

  • err == io.EOF exactly
  • err or any wrapped error in the chain is io.EOF
  • err is convertible to io.EOF
  • err's message contains "EOF"
Show answer
Answer: b. errors.Is walks the wrap chain via Unwrap() and returns true if any error in the chain matches. This is why %w wrapping matters.

4. Using panic for normal error handling is:

  • Idiomatic Go
  • Discouraged - reserve panic for unrecoverable bugs
  • Faster than returning errors
  • The only way to handle unrecoverable cases
Show answer
Answer: b. panic is for "this should never happen" situations (truly broken state). Normal errors (file not found, network timeout) should be returned as error values.

5. recover() is most useful for:

  • Catching any error
  • Catching panics in a server's middleware so one bad request doesn't crash the whole process
  • Replacing the error return mechanism
  • Logging stack traces only
Show answer
Answer: b. recover catches panics from deferred functions in the same goroutine. The main use case: an HTTP server's middleware that recovers panics from individual handlers so the server keeps serving other requests.