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.
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
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
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+)
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
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 callf
yet, but it does evaluatex
immediately and stores bothf
andx
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
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.
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
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
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
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
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
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.
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
}
panic
(or log.Fatal
) only during startup for non-negotiable requirements.Must*
helpers small and only for boot-time invariants.(T, error)
and explicit handling everywhere.