Error Handling
Reviewed & published by Brayan K
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.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
🆚 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
}Your 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 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
}
}Now 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 unreachableDeep 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")
}
}4. 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
}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: .
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
}4. Wrapping with no context
adds nothing useful. Always describe the operation that failed: .
5. Comparing wrapped errors with ==
fails once the error has been wrapped. Use , which walks the whole chain.
📋 Quick Reference
| Task | Go Syntax |
|---|---|
| Create an error | |
| Error with data | |
| Wrap with context | |
| Check & return | |
| Match a sentinel | |
| Extract a type | |
| Peel one layer | |
| Custom error type |
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
}🎉 Lesson Complete!
- ✅ Errors are values: a fallible function returns (result, error)
- ✅ The idiom: , then the happy path runs unindented
- ✅ Build errors with errors.New and fmt.Errorf
- ✅ Wrap with %w; detect with errors.Is / errors.As
- ✅ Sentinels for known conditions; custom types to carry extra data
- ✅ panic/recover only for unrecoverable bugs — return errors otherwise
Practice quiz
In Go, an error is...
- a thrown exception
- a panic
- an ordinary value you return
- a compiler directive
Answer: an ordinary value you return. Go has no exceptions; failable functions return an error value, conventionally last.
What does divide(10, 2) return on success in a func (float64, error)?
- 5, nil
- 0, an error
- nil, 5
- 5 only
Answer: 5, nil. On success you return the real result and nil for the error.
What is the cornerstone Go error-handling idiom?
- try { } catch { }
- throw err
- assert(err)
- if err != nil { return err }
Answer: if err != nil { return err }. You check the returned error immediately, before trusting the result.
errors.New("x") builds...
- a formatted error with data
- a simple error from a fixed message
- a panic
- a sentinel that auto-wraps
Answer: a simple error from a fixed message. errors.New makes a basic error from a constant message; fmt.Errorf adds formatting.
What does fmt.Errorf("user %d not found", id) do?
- builds an error with the id baked in
- panics
- returns nil
- wraps another error
Answer: builds an error with the id baked in. fmt.Errorf is errors.New with formatting; %d inserts the dynamic value.
Which verb wraps an error so errors.Is keeps working?
- %v
- %s
- %w
- %d
Answer: %w. %w preserves the chain link; %v flattens to text and loses it.
When is panic appropriate?
- for bad user input
- for unrecoverable programmer bugs
- for a missing file
- instead of returning errors
Answer: for unrecoverable programmer bugs. Reserve panic for impossible states; expected failures are errors you return.
recover() must be called inside...
- main only
- a goroutine
- an init function
- a deferred function
Answer: a deferred function. recover only stops a panic when called from within a deferred function.
Why might a function wrongly return nil instead of an error?
- the error was logged
- err was shadowed with := inside a block
- errors.New returns nil
- panic swallowed it
Answer: err was shadowed with := inside a block. Using := inside an if/block declares a new err; the outer one is never updated.
To discard the result but keep the error, you write...
- result, _ := f()
- err, _ := f()
- _, err := f()
- _ := f()
Answer: _, err := f(). Assign the result to the blank identifier _ and keep err to check it.
Continue this course
- Previous: Interfaces
- Next: Goroutines — Lightweight concurrency with the go keyword
Frequently asked questions
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.
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.
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.
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.
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.