ToolPopToolPop
Go · Lesson 9 of 22

Errors as values, not exceptions

6 min readUpdated 25 Jun 2026

In most languages, errors fly upward through throw and try/catch. In Go, errors are values. You return them, you check them, you wrap them, you log them. There is no hidden control flow. This is annoying for the first week and a superpower for the next ten years.

error is an interface, and the if err != nil pattern

There is no special "exception" type. error is a one-method interface (type error interface { Error() string }) defined in the built-in package. Anything with an Error() string method is an error. You create errors with errors.New("payment declined") for static messages or fmt.Errorf("user %s not found", id) when you need formatting.

You will read and write this pattern thousands of times.

go
amount, err := strconv.Atoi(input)
if err != nil {
    return fmt.Errorf("parse amount: %w", err)
}
 
order, err := db.FindOrder(ctx, id)
if err != nil {
    return fmt.Errorf("find order: %w", err)
}

Three things to notice. We check immediately after the call. We wrap with %w to preserve the original error for errors.Is and errors.As later. We add context, "parse amount", "find order", so the final error message reads like a breadcrumb trail.

Senior rule: every wrap should add context the caller does not already have. Wrapping err as fmt.Errorf("error: %w", err) adds nothing. Write the verb, not the noun.

Why this is verbose on purpose

Rob Pike and Ken Thompson knew this would be the most-complained-about feature. They shipped it anyway because the alternative, hidden control flow, is what makes large systems unpredictable.

  • You can see every place a function can return early
  • You cannot accidentally swallow an error by forgetting a catch
  • Code review reveals error handling because it is right there in the diff
  • The happy path is visually distinct from the error path

When a Razorpay payment retry fails, you want every layer to have logged exactly what it knew. if err != nil is the discipline that makes that possible.

Sentinel errors and errors.Is

Sometimes you want to check "is this specifically a not-found error?". Export a sentinel value.

go
var ErrNotFound = errors.New("not found")
 
func GetUser(id string) (*User, error) {
    // ...
    return nil, ErrNotFound
}
 
u, err := GetUser("u1")
if errors.Is(err, ErrNotFound) {
    // handle 404
}

errors.Is walks the wrap chain, so this works even if a middle layer wrapped the error with %w.

defer, the cleanup keyword

defer schedules a function to run when the surrounding function returns. Used for cleanup, lock release, file close.

go
func readConfig(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()  // runs no matter how we return
 
    return io.ReadAll(f)
}

Deferred calls run in LIFO order. They run even if the function panics. This is why defer pairs naturally with error handling, your cleanup happens whether you returned cleanly or not.

panic and recover

Panic is Go's "stop the world" mechanism. It is not for control flow. It is for "the program is in a state I cannot reason about".

go
if config == nil {
    panic("config must not be nil") // bug, not a runtime condition
}

recover catches a panic inside a deferred function. You will write it at most once or twice in your career, usually at the top of an HTTP handler to convert a panic into a 500.

Interview trap: panic is for unrecoverable programmer errors. A failed DB query is not a panic. A nil pointer because you forgot to initialise a field is. Returning error is the default. Panicking is the exception.

Diagram
rendering diagram...

Quick reference

error
Built-in interface with one method, Error() string. Any type that implements it is an error.
%w
fmt.Errorf verb that wraps an error so errors.Is and errors.As can unwrap it.
Sentinel error
An exported error variable used as a known value to compare against with errors.Is.
defer
Schedules a function call to run when the surrounding function returns. Runs in LIFO order.
panic
Aborts normal flow and unwinds the stack. Reserved for programmer errors, not runtime conditions.
recover
Catches a panic inside a deferred function. Lets a top-level handler turn a panic into an error response.

Next, goroutines and channels. Where Go's concurrency story finally gets its turn.

Chai0/2 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 2 done)

  1. 1

    Predict the output.

    show answer
    // run the editor
  2. 2

    Change the call to divide(10, 2). What does it print?

    show answer
    r, _ := divide(10, 2)
    fmt.Println(r)