Skip to main content
    Courses/Go/Error Handling

    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

    🆚 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 != nilbefore trusting the result. Read this worked example and run it.

    Worked example: the error return pattern
    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
    }
    Output
    10 / 2 = 5.0
    error: division by zero
    user 42 not found
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    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.

    🎯 YOUR TURN: return and check an error
    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 negative
    Output
    age is 30
    error: age cannot be negative
    Fill in the ___ blanks, then run it on the Go Playground and compare with the expected output in the comments.

    2. 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).

    Worked example: sentinels, custom types, Is & As
    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
        }
    }
    Output
    find user 42: not found
    -> that user does not exist
    -> failing field: email
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    Now you try. Fill in the two blanks so the error wraps correctly and the root cause is still detectable.

    🎯 YOUR TURN: wrap with %w and detect with errors.Is
    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 unreachable
    Output
    error: load user 7: connection refused
    root cause: database unreachable
    Fill in the ___ blanks, then run it on the Go Playground and check your output against the comments.

    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.

    Worked example: early return across three layers
    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")
        }
    }
    Output
    error: get profile: fetch user 42: database: connection refused
    root cause: the database was unreachable
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    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.

    Worked example: panic, defer, and recover
    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
    }
    Output
    ok: 5
    recovered: mustPositive: got -1
    program keeps running
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    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.

    The shadowing bug — spot the :=
    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
    }
    This compiles and runs but silently returns 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

    TaskGo Syntax
    Create an errorerrors.New("not found")
    Error with datafmt.Errorf("user %d missing", id)
    Wrap with contextfmt.Errorf("read: %w", err)
    Check & returnif err != nil { return err }
    Match a sentinelerrors.Is(err, ErrNotFound)
    Extract a typeerrors.As(err, &target)
    Peel one layererrors.Unwrap(err)
    Custom error typefunc (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.

    🎯 MINI-CHALLENGE: write sqrt yourself
    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
    }
    Output
    sqrt(16) = 4
    error: cannot sqrt negative number -4.00
    Write the function body yourself, then run it on the Go Playground and compare with the expected output.

    🎉 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.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
    • 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.