Lesson 5 • Intermediate
Error Handling 🐹
By the end of this lesson you'll handle failure the Go way — returning errors as ordinary values, checking them with if err != nil, wrapping them with context, and detecting the root cause through the whole chain. No exceptions, no surprises.
What You'll Learn in This Lesson
- Return failures as (result, error) — Go's core pattern
- Use the if err != nil { return err } idiom fluently
- Build errors with errors.New and fmt.Errorf
- Wrap with %w and detect causes via errors.Is / errors.As
- Choose between sentinel errors and custom error types
- Know when to panic / recover — and when NOT to
nil — all good) or has a note on it (an error). When a runner gets a note, they don't trip over and crash the whole race like an exception would — they add a line of their own ("during the handoff at gate 3…") and pass it back. The final runner, main, reads the full note and decides what to do.🆚 Errors vs Exceptions
If you come from Java, Python, or C#, you're used to exceptions: a function throws, execution jumps somewhere far away, and you wrap risky code in try/catch. The danger is invisible — nothing in a function's signature tells you it can blow up, so it's easy to forget to catch.
Go made a deliberate choice: errors are values. A function that can fail says so in its return type (T, error), and you handle the failure right where it happens with a normal if. It's more typing, but the failure is impossible to miss and the control flow is obvious — you can read it top to bottom. Go keeps panic only for truly exceptional, unrecoverable bugs.
1. Errors Are Values: (result, error)
A function that can fail returns two things: the result, and an error. By convention error comes last. On success you return a real value and nil; on failure you return the zero value and a non-nil error. The caller then runs the cornerstone idiom — if err != nil — before trusting the result. Read this worked example and run it.
package main
import (
"errors"
"fmt"
)
// In Go an error is just a VALUE you return, not a thrown exception.
// By convention error is the LAST return value, so a function that can
// fail returns (result, error). On success you return a real result and
// nil; on failure you return the zero value and a non-nil error.
func divide(a, b float64) (float64, error) {
if b == 0 {
// errors.New builds a simple error from a fixed message.
return 0, errors.New("division by zero")
}
return a / b, nil // nil means "no error"
}
func main() {
// The cornerstone idiom: call, then check err BEFORE using the result.
result, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return // stop early — don't use a result that may be invalid
}
fmt.Printf("10 / 2 = %.1f\n", result) // 10 / 2 = 5.0
// The failure path. We don't need the result, so discard it with _.
_, err = divide(10, 0)
if err != nil {
fmt.Println("error:", err) // error: division by zero
}
// fmt.Errorf is errors.New with formatting — bake in dynamic data.
id := 42
fmt.Println(fmt.Errorf("user %d not found", id)) // user 42 not found
}10 / 2 = 5.0
error: division by zero
user 42 not foundYour turn. The program below is almost complete — fill in the three ___ blanks using the // 👉 hints, then run it and check it against the expected output.
package main
import (
"errors"
"fmt"
)
// 🎯 YOUR TURN — fill in each ___ , then run it on the Go Playground.
// parseAge should return (0, an error) when age is negative,
// and (the age, nil) when it is valid.
func parseAge(age int) (int, error) {
if age < 0 {
// 1) Return zero and a new error explaining the problem.
return 0, ___ // 👉 errors.New("age cannot be negative")
}
// 2) On success, return the age and nil (no error).
return age, ___ // 👉 nil
}
func main() {
if a, err := parseAge(30); err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("age is", a) // age is 30
}
// 3) Check the error from a bad call and print it.
_, err := parseAge(-5)
if err ___ nil { // 👉 != (only act when there IS an error)
fmt.Println("error:", err)
}
}
// ✅ Expected output:
// age is 30
// error: age cannot be negativeage is 30
error: age cannot be negative2. Sentinel Errors, Custom Types & Wrapping
A sentinel error is a single shared value (like ErrNotFound) that callers compare against to react to a specific, known condition. A custom error type is any type with an Error() string method — use it when you need to carry extra data, like which field failed. To add context as an error travels up, wrap it with fmt.Errorf and the %w verb: that keeps the original detectable with errors.Is (match a sentinel) and errors.As (extract a type).
package main
import (
"errors"
"fmt"
)
// SENTINEL error: a single shared value for a known condition. Callers
// compare against it (with errors.Is) to react to that specific case.
var ErrNotFound = errors.New("not found")
// CUSTOM error type: ANY type with an Error() string method is an error.
// Use it when you want to carry extra data (here: which field failed).
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func findUser(id int) error {
// %w WRAPS ErrNotFound: it adds context AND keeps the original
// detectable later via errors.Is. (%v would lose that link.)
return fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
func main() {
err := findUser(42)
fmt.Println(err) // find user 42: not found
// errors.Is walks the whole wrap chain looking for a sentinel.
if errors.Is(err, ErrNotFound) {
fmt.Println("-> that user does not exist")
}
// errors.As pulls a specific error TYPE out of the chain so you
// can read its fields. It needs a pointer to a variable of that type.
var ve *ValidationError
bad := &ValidationError{Field: "email", Message: "invalid format"}
if errors.As(error(bad), &ve) {
fmt.Println("-> failing field:", ve.Field) // -> failing field: email
}
}find user 42: not found
-> that user does not exist
-> failing field: emailNow you try. Fill in the two blanks so the error wraps correctly and the root cause is still detectable.
package main
import (
"errors"
"fmt"
)
// 🎯 YOUR TURN — fill in the two ___ blanks.
var ErrConnRefused = errors.New("connection refused")
func queryDB() error {
return ErrConnRefused // the low-level failure
}
func loadUser(id int) error {
if err := queryDB(); err != nil {
// 1) Wrap err with context, keeping the chain inspectable.
// Use the %w verb (NOT %v) so errors.Is still works.
return fmt.Errorf("load user %d: %w", id, ___) // 👉 err
}
return nil
}
func main() {
err := loadUser(7)
fmt.Println("error:", err) // error: load user 7: connection refused
// 2) Detect the ROOT cause through the wrap chain.
if errors.___(err, ErrConnRefused) { // 👉 Is
fmt.Println("root cause: database unreachable")
}
}
// ✅ Expected output:
// error: load user 7: connection refused
// root cause: database unreachableerror: load user 7: connection refused
root cause: database unreachable Deep Dive: %w vs %v, and Is vs As
%w wraps; %v just prints. fmt.Errorf("...: %w", err) embeds err so the chain stays inspectable. %v flattens it to text and the link to the original is gone — errors.Is can no longer find it. Use %w exactly once per Errorf when you want the cause preserved.
errors.Is answers "is this specific value (a sentinel) anywhere in the chain?" — errors.Is(err, ErrNotFound). errors.As answers "is there an error of this type in the chain?" and, if so, copies it into your variable so you can read its fields — errors.As(err, &ve).
3. Early Return & the Wrapping Chain
Idiomatic Go handles the error and returns early, which keeps the "happy path" flat and unindented at the bottom of the function — you rarely see deep else nesting. As the error passes up through layers, each one wraps it with its own context, building a readable breadcrumb trail. Crucially, the original cause is still detectable at the very top.
package main
import (
"errors"
"fmt"
)
// The lowest layer fails with a sentinel error.
var ErrConnRefused = errors.New("connection refused")
func queryDB() error {
return fmt.Errorf("database: %w", ErrConnRefused)
}
// Each layer uses EARLY RETURN: handle the error and get out, which keeps
// the happy path flat and unindented. Wrap with %w to add this layer's context.
func fetchUser(id int) error {
if err := queryDB(); err != nil {
return fmt.Errorf("fetch user %d: %w", id, err)
}
return nil // the happy path stays at the bottom, unindented
}
func getProfile(id int) error {
if err := fetchUser(id); err != nil {
return fmt.Errorf("get profile: %w", err)
}
return nil
}
func main() {
err := getProfile(42)
// Each wrap prepended its context, building a breadcrumb trail.
fmt.Println("error:", err)
// The original cause is STILL detectable through the whole chain.
if errors.Is(err, ErrConnRefused) {
fmt.Println("root cause: the database was unreachable")
}
}error: get profile: fetch user 42: database: connection refused
root cause: the database was unreachable4. panic / recover — and When NOT To
panic aborts the normal flow and unwinds the stack — it's Go's closest thing to throwing. Reserve it for unrecoverable programmer bugs (an impossible state, a nil that should never occur), not for expected failures like bad user input — those are errors you return. recover, called inside a defer, stops a panic from crashing the process; libraries use it at a boundary to convert a panic back into an ordinary error.
package main
import "fmt"
// panic is for UNRECOVERABLE programmer bugs, not expected failures.
// A bad user input is an error you return; a logic bug (impossible state,
// a nil you should never get) is a panic. Reach for it rarely.
func mustPositive(n int) int {
if n <= 0 {
// This signals "the program is in a state that should be impossible".
panic(fmt.Sprintf("mustPositive: got %d", n))
}
return n
}
// recover stops a panic from crashing the program, but only inside a
// deferred function. Libraries use it at a boundary to turn a panic
// back into an ordinary error rather than killing the whole process.
func safeCall(n int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r) // panic -> normal error
}
}()
return mustPositive(n), nil
}
func main() {
if r, err := safeCall(5); err == nil {
fmt.Println("ok:", r) // ok: 5
}
if _, err := safeCall(-1); err != nil {
fmt.Println(err) // recovered: mustPositive: got -1
}
fmt.Println("program keeps running") // we did NOT crash
}ok: 5
recovered: mustPositive: got -1
program keeps runningerror. Only panic when continuing would mean the program is genuinely broken.Common Errors (and the fix)
1. Ignoring the error
Writing result, _ := mightFail() throws the error away and uses a possibly-invalid result. The go vet tool and linters flag this. Always assign err and check it: if err != nil { return err }.
2. Panicking instead of returning
Calling panic(...) for an expected failure (bad input, missing file) crashes the program and can't be handled cleanly by the caller. Return an error instead, and let the top level decide what to do.
3. Shadowing err with :=
Using := inside an if or block declares a new err that only lives there, so the outer one is never updated and the function returns nil by mistake. Reuse the existing variable with =, or check the error in the same statement.
package main
import (
"errors"
"fmt"
)
func step() error { return errors.New("step failed") }
func run() error {
err := step()
if true {
// ❌ BUG: ':=' here declares a NEW err that only lives in this block.
// The outer err is never updated, so the function returns nil.
err := step()
fmt.Println("inner:", err)
_ = err
}
return err // returns the FIRST err only — the inner one was lost
}nil. Build it with go vet on the Go Playground to see the shadow warning.4. Wrapping with no context
return fmt.Errorf("%w", err) adds nothing useful. Always describe the operation that failed: fmt.Errorf("load config: %w", err).
5. Comparing wrapped errors with ==
err == ErrNotFound fails once the error has been wrapped. Use errors.Is(err, ErrNotFound), which walks the whole chain.
📋 Quick Reference
| Task | Go Syntax |
|---|---|
| Create an error | errors.New("not found") |
| Error with data | fmt.Errorf("user %d missing", id) |
| Wrap with context | fmt.Errorf("read: %w", err) |
| Check & return | if err != nil { return err } |
| Match a sentinel | errors.Is(err, ErrNotFound) |
| Extract a type | errors.As(err, &target) |
| Peel one layer | errors.Unwrap(err) |
| Custom error type | func (e *E) Error() string |
Frequently Asked Questions
Q: Why doesn't Go just use exceptions like other languages?
By making errors ordinary return values, Go puts failure right in a function's signature (T, error) and forces you to handle it where it happens. There's no hidden control flow jumping to a distant catch, so code reads top to bottom and an error is hard to forget. The trade-off is more if err != nil lines, which the language designers consider worth it for clarity.
Q: What is the difference between errors.Is and errors.As?
errors.Is checks whether a specific error value (a sentinel like ErrNotFound) appears anywhere in the wrap chain. errors.As checks whether an error of a particular type is in the chain and, if so, copies it into your variable so you can read its fields. Use Is to match a known value; use As to get a typed error back.
Q: When should I wrap an error with %w instead of %v?
Use %w when you want the original error to remain detectable later via errors.Is or errors.As — it keeps the chain linked. Use %v (or %s) when you only need a printable message and don't care about inspecting the cause. Each fmt.Errorf should use %w at most once.
Q: When is it OK to use panic?
Almost never for normal failures. Reserve panic for unrecoverable programmer bugs — an impossible state, a violated invariant, or a nil that should never occur. Expected situations like bad input, a missing file, or a not-found record are errors you return, so the caller can handle them gracefully.
Q: Why did my function return nil when there clearly was an error?
You probably shadowed err with := inside an if or block. That declares a brand-new err scoped to the block, so the outer err is never updated and the function returns nil. Use = to reuse the existing variable, or handle the error inside the same statement.
Mini-Challenge: A Safe Square Root
No blanks this time — just a brief and an outline. Write it, run it on the Go Playground, and check your output against the example in the comments.
package main
import (
"errors"
"fmt"
"math"
)
// 🎯 MINI-CHALLENGE: a safe square root
// 1. Write sqrt(x float64) (float64, error)
// 2. If x is negative, return 0 and an error like
// "cannot sqrt negative number -4.00" (hint: fmt.Errorf with %.2f)
// 3. Otherwise return math.Sqrt(x) and nil
// 4. In main, call sqrt(16) and sqrt(-4); print the result OR the error,
// using the if err != nil idiom for each.
//
// ✅ Expected output:
// sqrt(16) = 4
// error: cannot sqrt negative number -4.00
func sqrt(x float64) (float64, error) {
// your code here
return 0, errors.New("not implemented")
}
func main() {
// your code here
_ = math.Sqrt
_ = fmt.Println
}sqrt(16) = 4
error: cannot sqrt negative number -4.00🎉 Lesson Complete!
- ✅ Errors are values: a fallible function returns
(result, error) - ✅ The idiom:
if err != nil { return err }, then the happy path runs unindented - ✅ Build errors with
errors.Newandfmt.Errorf - ✅ Wrap with
%w; detect witherrors.Is/errors.As - ✅ Sentinels for known conditions; custom types to carry extra data
- ✅
panic/recoveronly for unrecoverable bugs — return errors otherwise - ✅ Next lesson: Building Web Services — put this error handling to work in a real HTTP server
Sign up for free to track which lessons you've completed and get learning reminders.