GUIDE
GoTutorialBackendSystems Programming

Module 4 of 6

Module 4: Error Handling as Control Flow

Learn why Go treats errors as values — sentinel errors, typed errors, wrapping chains, and when to use panic vs. error returns.

6 min read
by Faisal Ahmed Sifat

Module 4: Error Handling as Control Flow

The mental model shift

Let me tell you about the moment I started to appreciate Go's error handling.

I was debugging a Node.js service. An exception had been thrown deep in a third-party library, caught by an Express middleware, and surfaced as a 500 error. The stack trace was 40 frames long, most of it framework code. The actual trigger — a null reference in a callback — was buried on line 7 of the trace. It took an hour to find.

In Go, that same bug would have been if err != nil { return fmt.Errorf("fetching user %d: %w", id, err) } on the exact line where it happened. Six months later, another engineer could read the error chain and understand the full story: "handleRequest → getUser(42) → queryDB: database connection failed."

This is the tradeoff Go makes: error handling is verbose. You will type if err != nil thousands of times. But every single one of those lines is a deliberate decision — handle it, wrap it, or explicitly ignore it with _. Nothing hides. Nothing tunnels through 15 stack frames to surface somewhere unrelated.

The real mental shift: errors change how you design functions. In Go, a function that can fail returns (T, error). This signature forces you to think about failure modes when you write the function, not when you discover a bug report. Errors compose naturally — you return them up the call stack, adding context at each layer like geological strata.

Key concepts

  • error is just an interface: type error interface { Error() string }. Anyone can implement it.
  • Sentinel errors: var ErrNotFound = errors.New("not found") — compare identity with == or errors.Is. These are simple flags: "this specific thing happened."
  • Typed errors: custom structs implementing error — use errors.As to extract them. These carry structured data: "this thing happened, and here are the details."
  • Wrapping: fmt.Errorf("context: %w", err) preserves the original error inside a new one. The %w verb is the magic — it creates a chain that errors.Is and errors.As can walk.
  • errors.Is(err, target) walks the chain checking identity. errors.As(err, &target) walks the chain checking type.
  • panic/recover are NOT your error handling mechanism. They're for truly unrecoverable states — nil pointer dereferences, out-of-bounds access, programmer errors that should crash the process.

Drill 1 — Sentinel vs typed errors (when to use which)

package main
 
import (
    "errors"
    "fmt"
)
 
// Sentinel errors: use when the fact of the error IS the information
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
 
// Typed errors: use when the error needs to carry structured data
type ValidationError struct {
    Field   string
    Message string
}
 
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
 
func findUser(id int) (string, error) {
    if id <= 0 {
        return "", &ValidationError{Field: "id", Message: "must be positive"}
    }
    if id > 100 {
        return "", fmt.Errorf("findUser: %w", ErrNotFound)
    }
    return fmt.Sprintf("user_%d", id), nil
}
 
func main() {
    // Typed error — extract the structured data
    _, err := findUser(-1)
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("bad field: %s (%s)\n", ve.Field, ve.Message)
    }
 
    // Sentinel through wrap — walks the chain to find the root
    _, err = findUser(200)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("not found (wrapped but detected)")
    }
    fmt.Println("raw error:", err) // "findUser: not found"
}

The decision framework I use:

  • If callers only need to know "did this specific thing happen?" → sentinel error
  • If callers need details about what happened → typed error with errors.As
  • If neither → just fmt.Errorf with a descriptive message, no exported sentinel

Drill 2 — Error wrapping chains (the geological strata pattern)

package main
 
import (
    "errors"
    "fmt"
)
 
var ErrDBConnection = errors.New("database connection failed")
 
func queryDB(query string) error {
    return fmt.Errorf("queryDB(%q): %w", query, ErrDBConnection)
}
 
func getUser(id int) error {
    if err := queryDB("SELECT * FROM users WHERE id = ?"); err != nil {
        return fmt.Errorf("getUser(%d): %w", id, err)
    }
    return nil
}
 
func handleRequest(userID int) error {
    if err := getUser(userID); err != nil {
        return fmt.Errorf("handleRequest: %w", err)
    }
    return nil
}
 
func main() {
    err := handleRequest(42)
    fmt.Println("full chain:", err)
    // "handleRequest: getUser(42): queryDB("SELECT..."): database connection failed"
 
    // The beauty: errors.Is walks the entire chain
    fmt.Println("is db error:", errors.Is(err, ErrDBConnection)) // true
}

Read this error message aloud: "handleRequest: getUser(42): queryDB(...): database connection failed." In six words across four layers, you know exactly what was happening at every level. This is the power of the %w verb — each layer adds context without destroying what came before.

The discipline: Every layer asks "what was I trying to do when this failed?" and wraps accordingly. %w preserves the original. %v would destroy it. Get this right and your error messages become a narrative of what happened, not a cryptic log line.

Drill 3 — Panic and recover (the nuclear option)

package main
 
import "fmt"
 
// recover converts a panic to an error — use at package boundaries
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered panic: %v", r)
        }
    }()
    return a / b, nil
}
 
// panic during init is idiomatic — the program can't run without this
func mustParse(s string) int {
    var n int
    if _, err := fmt.Sscan(s, &n); err != nil {
        panic(fmt.Sprintf("mustParse(%q): %v", s, err))
    }
    return n
}
 
func main() {
    result, err := safeDiv(10, 0)
    fmt.Println(result, err) // 0, recovered panic: runtime error: integer divide by zero
 
    n := mustParse("42")
    fmt.Println(n) // 42
 
    // mustParse("abc") would panic — and at init time, that's correct behavior
}

My rule of thumb after years of Go:

  • error returns: for anything that might fail in production (network, disk, validation, timeouts)
  • panic: for programmer errors (nil pointer in logic you control, impossible state) and init-time failures (config parsing, dependency wiring)
  • recover: only at package boundaries or HTTP handler middleware — to prevent one panicking goroutine from crashing the entire process

Capstone — HTTP service with layered errors

Build a small HTTP service with three endpoints: GET /user/{id}, POST /user, DELETE /user/{id}.

The architecture:

  • A store package with its own errors (ErrNotFound, ErrDuplicate, ValidationError)
  • A service package that wraps store errors with business context
  • A handler package that maps errors to HTTP status codes (404, 409, 400, 500)

The constraint: no HTTP logic in the service layer. No business logic in the handler layer. Errors flow up like water, getting richer at each level, and only the handler knows about HTTP. Use errors.Is and errors.As in the handler to distinguish error types.

When it works, you'll feel it: the handler receives an error, checks if errors.Is(err, store.ErrNotFound), returns 404. The full error chain goes to the logs. The clean message goes to the client. Clean separation, zero coupling.