03 - Error Handling

Errors as values, custom types, defer & recover, stack unwinding, and best practices

1) The error interface

Definition

error is a built-in interface with a single method Error() string. Any type that implements this is an error.

go
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func f() error {
	return ErrNotFound
}

func main() {
	if err := f(); err != nil {
		fmt.Println("got error:", err)
	}
}

2) Custom errors

Sentinel vs typed errors

go
package main

import (
	"errors"
	"fmt"
)

// Sentinel error
var ErrUserNotFound = errors.New("user not found")

// Typed error with fields
type QuotaErr struct {
	User string
	Used, Max int
}
func (e *QuotaErr) Error() string {
	return fmt.Sprintf("quota exceeded: %s (%d/%d)", e.User, e.Used, e.Max)
}

func main() {
	err := ErrUserNotFound
	if errors.Is(err, ErrUserNotFound) {
		fmt.Println("handle sentinel")
	}

	qe := &QuotaErr{"bob", 12, 10}
	var q *QuotaErr
	if errors.As(qe, &q) {
		fmt.Println("user:", q.User, "limit:", q.Max)
	}
}

Wrapping & inspection

go
package main

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

func Load(path string) ([]byte, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("load %q: %w", path, err)
	}
	return b, nil
}

func main() {
	_, err := Load("missing.txt")
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("file not found")
	}
}

Multi-cause with errors.Join (Go 1.20+)

go
package main

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

func main() {
	err := errors.Join(io.ErrUnexpectedEOF, io.ErrClosedPipe)
	if errors.Is(err, io.ErrUnexpectedEOF) {
		fmt.Println("contains EOF error")
	}
}

3) Defer semantics

A defer statement schedules a function call to run when the surrounding function returns. When you write defer f(args…), Go immediately evaluates f and its arguments but does not call it right away - instead it records the function and those values on a per-function stack. Only when the function is about to exit does the Go runtime pop and invoke each deferred call, in last-in-first-out (LIFO) order.

Evaluation & order

go
package main

import "fmt"

func demo() {
	x := 1
	defer fmt.Println("A", x)            // captures x=1 now
	x = 2
	defer func() { fmt.Println("B", x) }() // reads x at runtime => 2
}

func main() {
	demo()
}
// Output: B 2 then A 1

How defer really works under the hood (simple version):

  • Arguments saved right away: When Go sees defer f(x), it does not call f yet, but it does evaluate x immediately and stores both f and x for later.
  • Runs in reverse order: All defers go onto a stack. When the function ends (return or panic), Go pops them one by one, so the last defer runs first.
  • Closures remember variables, not values: If you write defer func(){ ... }(), that little function is saved and will look at variables when it finally runs. If the variable changed in the meantime, you’ll see the new value.
  • Pointers/slices/maps show changes: Go saves the pointer or slice header at defer time. But the actual data is read later, so if you modify the slice/pointed data before the defer runs, you’ll see the changes.
  • Always runs on return or panic: Even if your function panics, Go will still run the defers while unwinding the stack. (The only thing that skips them is os.Exit.)
  • What the compiler does: Under the hood, Go makes a tiny _defer record for each defer and sticks it onto a list. When the function ends, the runtime walks the list and runs them.
  • Performance note: Defers are very cheap in modern Go. The compiler often inlines them for small cases. Still, don’t put millions inside a tight loop unless you know what you’re doing.
  • Return values can be changed: If your function has named return variables, a deferred function can read or even update them right before the function exits.

That’s why in the demo above you see B 2 first (the closure looked at x later, after it changed), and A 1 second (because the argument was locked in when the defer was written).

Named return values

go
package main

import "fmt"

func c() (i int) {
	defer func() { i++ }()
	return 1 // sets i=1, then defer runs => i=2
}

func main() {
	fmt.Println(c())
}
// prints 2

4) Panic & recover

What is stack unwinding?

After a panic, the runtime pops stack frames one by one and runs each frame’s defers (LIFO). Unwinding stops if a deferred function calls recover(); otherwise it continues to the top and the program crashes with a stack trace.

go
package main

import "fmt"

func main() { f() }

func f() { defer fmt.Println("f"); g() }
func g() { defer fmt.Println("g"); h() }
func h() { defer fmt.Println("h"); panic("boom") }

// Output:
// h
// g
// f
// panic: boom ... stack trace

Recovering mid-stack

go
package main

import "fmt"

func main() { g() }

func g() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered in g:", r)
		}
	}()
	h()
}

func h() { panic("boom") }

5) Tricky parts

Nil interface values

go
package main

import "fmt"

type MyErr struct{}
func (e *MyErr) Error() string { return "oops" }

func foo() error {
	var e *MyErr = nil
	return e // non-nil interface! bug
}

func main() {
	if err := foo(); err != nil {
		fmt.Println("got:", err)
	} else {
		fmt.Println("nil")
	}
}
// prints: got: oops

Why this happens: an interface value in Go is a pair - (concrete type, data pointer). Returning a typed nil like (*MyErr)(nil) stores (type=*MyErr, data=nil) in the interface, which is not the zero interface (type=nil, data=nil). Therefore err != nil is true.

  • Rule of thumb: an interface is nil only if both its concrete type and its data are nil.
  • Symptom: you see logs like "got: oops" even though you thought you returned nil.

Defer in return expressions

go
package main

import "fmt"

func tricky() int {
	defer fmt.Println("defer runs after return value is set")
	return 42
}

func main() {
	fmt.Println(tricky())
}
// prints:
// defer runs after return value is set
// 42

6) Best practices

Return errors, not panics

go
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

// Bad:
func ReadBad() string { panic("file missing") }

// Good:
func Read(ok bool) (string, error) {
	if !ok { return "", ErrNotFound }
	return "data", nil
}

func main() {
	if _, err := Read(false); err != nil {
		fmt.Println("got error:", err)
	}
}

Wrap with %w

go
package main

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

func main() {
	_, err := os.ReadFile("cfg.json")
	if err != nil {
		err = fmt.Errorf("read config: %w", err)
	}
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("config missing")
	}
}

Use panic ONLY for startup musts (Must* pattern)

Routine failures should return errors. Reserve panic for truly unrecoverable startup invariants-things the app cannot run without (DB connection, required config). Encapsulate these in Must* helpers used at initialization.

go
package main

import (
	"database/sql"
	_ "github.com/lib/pq"
)

func MustOpenDB(dsn string) *sql.DB {
	db, err := sql.Open("postgres", dsn)
	if err != nil { panic(err) }           // cannot proceed without DB
	if err := db.Ping(); err != nil { panic(err) }
	return db
}

func main() {
	// Startup: either succeed or exit fast with a clear stack trace.
	db := MustOpenDB("postgres://user:pass@localhost/app")
	_ = db
}
Note
Use panic (or log.Fatal) only during startup for non-negotiable requirements.
Note
Keep Must* helpers small and only for boot-time invariants.
Note
After startup, prefer (T, error) and explicit handling everywhere.