08 / 20 · Day 3
Day 3 · Concept 08

Errors

Go has no exceptions. Errors are values — the same kind of values you pass around every day. The result is verbose (if err != nil is one of the most grepped strings in any Go codebase) but predictable. Every error path is explicit; nothing escapes upward without your consent.


1 · The intuition

In Java or Python, an error escapes upward via the stack until something catches it. In Go, errors are just values — usually the second (or last) return of a function. The caller decides immediately: handle, ignore (rare), or pass upward by returning. There's no implicit propagation. Every error is documented in the function signature.

The error type. A built-in interface: type error interface { Error() string }. Anything with an Error() string method satisfies it. Most often you create errors with errors.New("msg") or fmt.Errorf("msg: %w", err).

2 · Try it

go main.go · the canonical shape
package main

import (
    "errors"
    "fmt"
    "strconv"
)

// Returns (result, err). nil error means "all good".
func parsePositive(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("not a number: %w", err)
    }
    if n <= 0 {
        return 0, errors.New("must be positive")
    }
    return n, nil
}

func main() {
    for _, in := range []string{"42", "abc", "-5"} {
        n, err := parsePositive(in)
        if err != nil {
            fmt.Printf("%q: error: %v
", in, err)
            continue
        }
        fmt.Printf("%q: ok, n=%d
", in, n)
    }
}

3 · Wrapping with %w

fmt.Errorf("context: %w", err) wraps an error, preserving the original. The wrapped error can later be unwrapped, compared with errors.Is, or matched by type with errors.As.

go main.go · errors.Is
package main

import (
    "errors"
    "fmt"
    "os"
)

func readConfig() error {
    _, err := os.Open("/etc/myapp.conf")
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)  // wrap, don't replace
    }
    return nil
}

func main() {
    err := readConfig()
    if err != nil {
        fmt.Println("got:", err)

        // Check WHAT KIND of error — unwraps the chain
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("config file missing — using defaults")
        }
    }
}

4 · Custom error types — errors.As

go main.go · typed errors
package main

import (
    "errors"
    "fmt"
)

// A typed error — carries data
type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return "validation: " + e.Field + ": " + e.Msg
}

func validate(email string) error {
    if email == "" {
        return &ValidationError{Field: "email", Msg: "is required"}
    }
    return nil
}

func main() {
    err := validate("")
    if err != nil {
        var verr *ValidationError
        if errors.As(err, &verr) {
            fmt.Printf("field %q failed: %s
", verr.Field, verr.Msg)
        }
    }
}
errors.Is vs errors.As. Is checks whether the error chain contains a specific sentinel value. As checks whether the chain contains an error of a specific type, and binds it. Use Is for sentinel comparisons (e.g. io.EOF); use As when you need to inspect the carried data.

5 · panic and recover — the escape hatch

panic aborts the current goroutine, running deferred functions on the way out. recover stops a panic, but only inside a deferred function. Reserved for: programming bugs (nil pointer dereference, out-of-bounds index), unrecoverable corruption, and at API boundaries you want to convert to errors.

go main.go · panic and recover
package main

import "fmt"

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    return a / b, nil  // panics if b == 0
}

func main() {
    r, err := safeDiv(10, 2)
    fmt.Printf("ok: r=%d err=%v
", r, err)

    r, err = safeDiv(10, 0)
    fmt.Printf("zero: r=%d err=%v
", r, err)
}

This pattern is the standard library's go-to at HTTP-handler boundaries: panics inside one handler shouldn't crash the whole server. net/http does exactly this for you.

6 · Sentinel errors — the standard library pattern

Many stdlib packages expose sentinel errors — package-level variables you compare against with errors.Is. The canonical example is io.EOF.

go main.go · sentinel comparison
package main

import (
    "errors"
    "fmt"
    "io"
    "strings"
)

func main() {
    r := strings.NewReader("hi")
    buf := make([]byte, 8)
    for {
        n, err := r.Read(buf)
        if n > 0 {
            fmt.Printf("read %d bytes: %q
", n, buf[:n])
        }
        if errors.Is(err, io.EOF) {
            fmt.Println("EOF reached — clean exit")
            break
        }
        if err != nil {
            fmt.Println("unexpected:", err)
            break
        }
    }
}

7 · From the wild

go etcd · wrapping errors at boundaries (paraphrased)
var (
    ErrStopped       = errors.New("server stopped")
    ErrCanceled      = errors.New("server canceled")
    ErrTimeout       = errors.New("server timeout")
    ErrNoLeader      = errors.New("no leader")
)

func (s *EtcdServer) processInternalRaftRequest(ctx context.Context, r pb.InternalRaftRequest) error {
    if err := s.raftNode.Propose(ctx, data); err != nil {
        return fmt.Errorf("etcdserver: failed to propose: %w", err)
    }

    select {
    case <-ctx.Done():
        return s.parseProposeCtxErr(ctx.Err(), startTime)
    case <-s.stopping:
        return ErrStopped
    }
}

// Callers use errors.Is(err, etcdserver.ErrStopped) to react.
From the wild: github.com/etcd-io/etcd · Apache 2.0

8 · Coming from another language?

If you know…The shift
Python / JavaNo try/catch. Every fallible call returns (value, err). The "stack unwind" you're used to is replaced by explicit returns.
JavaScriptNo throw. panic exists but is reserved for programmer bugs. err is the second return, not a special channel.
RustResult<T, E> minus the generic. ? doesn't exist; you write if err != nil { return err }. Verbose but explicit.
CLike errno + return code, but the error is a real value with a Stringer interface, and the chain can be deep.

9 · Common mistakes

  • Swallowing errors with _. v, _ := foo() is occasionally right but more often a bug. If you've truly considered and chosen to ignore, leave a comment.
  • Using %v instead of %w. %v stringifies. %w wraps and preserves the chain for errors.Is / errors.As. Use %w when wrapping; %v when just printing.
  • Panicking in libraries. Library code should return errors. Reserve panic for "impossible" runtime states or programmer bugs (out-of-bounds in your own data structure).
  • Recovering everywhere. A blanket recover hides real bugs. Recover at well-defined boundaries: HTTP handlers, goroutine entry points, never in tight inner loops.
  • Comparing errors with == on wrapped chains. err == io.EOF fails if err is wrapped. Always errors.Is(err, io.EOF).
  • Returning concrete error types. Return error (the interface), not *MyError. Otherwise return nil may produce a typed-nil that's != nil at the call site.

10 · Exercises (~15 min)

  1. The typed-nil trap. Write a function returning error via var e *MyErr; return e. Then if err != nil { ... } at the call site. What happens? Why?
  2. Wrap and unwrap. Open a non-existent file. Wrap the error with fmt.Errorf("step1: %w", err) twice. Print the result. Then use errors.Is(err, os.ErrNotExist) — verify it still works through the wrap layers.
  3. Custom error. Define type NotFoundError struct { Key string } with Error() string. Return one from a function. Catch it at the caller with errors.As.
  4. Safe div. Reproduce the panic/recover pattern. Then move recover outside the deferred function — what error message do you get?

11 · When it clicks

  • You write if err != nil { return fmt.Errorf("context: %w", err) } by reflex.
  • You distinguish errors.Is (sentinel) from errors.As (type) without thinking.
  • You never use panic in library code unless the API explicitly documents it.
  • You wrap at boundaries (where you have meaningful context) and stop wrapping where context would be noise.
Found this useful?