Error Handling

📋 Jump to Takeaways

Go doesn't have exceptions. No try/catch, no throw, no hidden control flow. Errors are just values. You return them, check them, wrap them with context, and move on. It feels verbose at first, but it means you always know exactly where things can fail. Every Go developer who's debugged a stack trace in another language eventually appreciates this.

error Is an Interface

In Go, errors are just values. The error type is a built-in interface with a single method.

type error interface {
    Error() string
}

Any type that implements Error() string is an error.

Returning Errors

Functions return errors as the last return value. This is Go's core error-handling pattern.

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Checking Errors

Always check errors immediately. Don't ignore them.

result, err := divide(10, 0)
if err != nil {
    fmt.Println(err) // division by zero
    return
}
fmt.Println(result)

Creating Errors

Use errors.New for simple static errors. Use fmt.Errorf when you need formatting.

import "errors"

err1 := errors.New("something failed")

name := "config.yaml"
err2 := fmt.Errorf("file not found: %s", name)

Custom Error Types

Create a struct that implements the error interface for errors that carry extra data.

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func validate(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be positive"}
    }
    return nil
}

err := validate(-1)
fmt.Println(err) // age: must be positive

Wrapping Errors

Use fmt.Errorf with %w to wrap an error with context. This preserves the original error for inspection.

func readConfig() error {
    err := openFile()
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)
    }
    return nil
}

Unwrapping: errors.Is and errors.As

errors.Is checks if any error in the chain matches a target value. errors.As extracts a specific error type from the chain.

// In your package — define the sentinel
var ErrNotFound = errors.New("not found")

// In the calling code — wrap and check it
err := fmt.Errorf("lookup failed: %w", ErrNotFound)

// Check by value
fmt.Println(errors.Is(err, ErrNotFound)) // true

// Extract by type
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println(valErr.Field)
}

valErr is already a pointer (*ValidationError), so why &valErr? Because valErr starts as nil. errors.As digs through the error chain, finds the matching error, and sets valErr to point to it. To fill in a caller's variable, any function needs its address. Since valErr is a pointer, its address is **ValidationError. Looks odd, but same idea as json.Unmarshal or fmt.Scan.

panic

panic stops normal execution and begins unwinding the stack. Use it only for truly unrecoverable situations: programmer bugs, not user input errors.

func mustEnv(key string) string {
    val := os.Getenv(key)
    if val == "" {
        panic("required env var missing: " + key)
    }
    return val
}

recover

recover catches a panic and returns the panic value. It only works inside a deferred function.

func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    panic("something broke")
}

safeCall()
fmt.Println("program continues") // program continues

defer + recover Pattern

This pattern is useful for catching panics at boundaries like HTTP handlers, goroutines, or plugin systems.

func handleRequest() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("caught panic:", r)
        }
    }()
    processRequest() // may panic
}

Don't Use panic for Normal Error Handling

panic is not Go's exception system. Return errors for expected failures. Reserve panic for impossible states and programming mistakes.

// Wrong — don't do this
func findUser(id int) User {
    u, err := db.Find(id)
    if err != nil {
        panic(err) // bad: callers can't handle this gracefully
    }
    return u
}

// Right — return the error
func findUser(id int) (User, error) {
    return db.Find(id) // returns (User, error), both passed through
}

Key Takeaways

  • error is an interface — any type with Error() string qualifies
  • Return errors as the last value; check with if err != nil
  • Use errors.New for simple errors, fmt.Errorf for formatted ones
  • Wrap errors with %w to add context while preserving the original
  • Use errors.Is to match values and errors.As to extract types from the chain
  • Custom error types carry structured data beyond a message string
  • panic is for unrecoverable bugs, not normal error flow
  • recover only works inside defer — use it at system boundaries

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy