In Go Tutorial #4, you learned the basics of error handling — the error interface and the if err != nil pattern. Now it is time to go deeper.

Real applications need more than basic error checks. You need to know what went wrong, where it went wrong, and how to handle different errors differently. Go gives you tools for all of this.

Error Wrapping

When a function calls another function and gets an error, you should add context before returning it. Use fmt.Errorf with the %w verb to wrap errors:

package main

import (
    "fmt"
    "os"
)

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap the error with context
        return nil, fmt.Errorf("readConfig(%s): %w", path, err)
    }
    return data, nil
}

func loadApp() error {
    _, err := readConfig("/etc/app/config.json")
    if err != nil {
        // Wrap again with more context
        return fmt.Errorf("loadApp: %w", err)
    }
    return nil
}

func main() {
    err := loadApp()
    if err != nil {
        fmt.Println("Error:", err)
    }
}

Output:

Error: loadApp: readConfig(/etc/app/config.json): open /etc/app/config.json: no such file or directory

The error message reads like a stack trace: loadApp called readConfig, which tried to open a file. Each layer adds context. The original error is preserved inside.

Important: Use %w (not %v) to wrap errors. Only %w preserves the error chain for errors.Is() and errors.As().

errors.Is — Checking Error Types

errors.Is checks if an error (or any error in its chain) matches a specific error value:

package main

import (
    "errors"
    "fmt"
    "os"
)

func readFile(path string) error {
    _, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("read failed: %w", err)
    }
    return nil
}

func main() {
    err := readFile("/nonexistent/file.txt")

    // Check if the error is (or wraps) os.ErrNotExist
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File does not exist")
    } else if err != nil {
        fmt.Println("Other error:", err)
    }

    // errors.Is walks the entire error chain
    fmt.Println("\nFull error:", err)
}

Output:

File does not exist

Full error: read failed: open /nonexistent/file.txt: no such file or directory

Even though we wrapped the error, errors.Is still finds os.ErrNotExist inside the chain.

Do not use == to compare errors. Always use errors.Is:

// Bad — does not check wrapped errors
if err == os.ErrNotExist { ... }

// Good — checks the entire error chain
if errors.Is(err, os.ErrNotExist) { ... }

errors.As — Extracting Error Details

errors.As extracts a specific error type from the chain. This gives you access to the error’s fields:

package main

import (
    "errors"
    "fmt"
    "net"
)

func connectToServer(addr string) error {
    _, err := net.Dial("tcp", addr)
    if err != nil {
        return fmt.Errorf("connection failed: %w", err)
    }
    return nil
}

func main() {
    err := connectToServer("192.0.2.1:9999")
    if err != nil {
        // Try to extract a *net.OpError from the chain
        var opErr *net.OpError
        if errors.As(err, &opErr) {
            fmt.Println("Operation:", opErr.Op)
            fmt.Println("Network:", opErr.Net)
            fmt.Println("Address:", opErr.Addr)
            fmt.Println()
        }
        fmt.Println("Full error:", err)
    }
}

Output:

Operation: dial
Network: tcp
Address: 192.0.2.1:9999

Full error: connection failed: dial tcp 192.0.2.1:9999: connect: connection refused

errors.As finds the *net.OpError inside the wrapped error chain and fills in our variable. Now we can access its fields.

Sentinel Errors

A sentinel error is a predefined error value that callers can check for. Define them as package-level variables:

package main

import (
    "errors"
    "fmt"
)

// Sentinel errors — package-level variables
var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden    = errors.New("forbidden")
)

type UserStore struct {
    users map[int]string
}

func NewUserStore() *UserStore {
    return &UserStore{
        users: map[int]string{
            1: "Alex",
            2: "Sam",
            3: "Jordan",
        },
    }
}

func (s *UserStore) GetUser(id int) (string, error) {
    name, ok := s.users[id]
    if !ok {
        return "", fmt.Errorf("GetUser(id=%d): %w", id, ErrNotFound)
    }
    return name, nil
}

func (s *UserStore) DeleteUser(id int, isAdmin bool) error {
    if !isAdmin {
        return fmt.Errorf("DeleteUser: %w", ErrUnauthorized)
    }

    _, ok := s.users[id]
    if !ok {
        return fmt.Errorf("DeleteUser(id=%d): %w", id, ErrNotFound)
    }

    delete(s.users, id)
    return nil
}

func main() {
    store := NewUserStore()

    // Test GetUser
    _, err := store.GetUser(99)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found (expected)")
    }

    // Test DeleteUser without admin
    err = store.DeleteUser(1, false)
    if errors.Is(err, ErrUnauthorized) {
        fmt.Println("Not authorized (expected)")
    }

    // Test successful delete
    err = store.DeleteUser(1, true)
    if err == nil {
        fmt.Println("User deleted successfully")
    }
}

Output:

User not found (expected)
Not authorized (expected)
User deleted successfully

Sentinel errors give callers a clear way to handle different error cases. The naming convention is Err + the condition: ErrNotFound, ErrTimeout, ErrInvalid.

Custom Error Types

For errors that carry extra information, create a custom type that implements the error interface:

package main

import (
    "errors"
    "fmt"
)

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}

// Implement the error interface
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: %s — %s", e.Field, e.Message)
}

type APIError struct {
    Code    int
    Message string
    Err     error
}

func (e *APIError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("API error %d: %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("API error %d: %s", e.Code, e.Message)
}

// Unwrap enables errors.Is and errors.As to walk the chain
func (e *APIError) Unwrap() error {
    return e.Err
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be positive"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "must be less than 150"}
    }
    return nil
}

func createUser(name string, age int) error {
    if err := validateAge(age); err != nil {
        return &APIError{
            Code:    400,
            Message: "invalid input",
            Err:     err,
        }
    }
    fmt.Printf("Created user: %s (age %d)\n", name, age)
    return nil
}

func main() {
    err := createUser("Alex", -5)
    if err != nil {
        fmt.Println("Error:", err)

        // Extract the APIError
        var apiErr *APIError
        if errors.As(err, &apiErr) {
            fmt.Printf("Status code: %d\n", apiErr.Code)
        }

        // Extract the ValidationError from inside APIError
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            fmt.Printf("Invalid field: %s\n", valErr.Field)
        }
    }
}

Output:

Error: API error 400: invalid input: validation error: age — must be positive
Status code: 400
Invalid field: age

The Unwrap() method is key. It lets errors.As walk from APIError to ValidationError. Without Unwrap, the inner error would be hidden.

panic and recover

panic stops the normal flow of a program. recover catches a panic and lets the program continue. Use them rarely:

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()

    // This panics if b is 0
    return a / b, nil
}

func main() {
    result, err := safeDivide(10, 2)
    fmt.Printf("10 / 2 = %d, err = %v\n", result, err)

    result, err = safeDivide(10, 0)
    fmt.Printf("10 / 0 = %d, err = %v\n", result, err)
}

Output:

10 / 2 = 5, err = <nil>
10 / 0 = 0, err = recovered from panic: runtime error: integer divide by zero

When to use panic:

  • Never in library code (return errors instead)
  • Only in main or initialization when the program cannot continue
  • Examples: missing required config file, corrupt database on startup

When to use recover:

  • In HTTP server middleware (prevent one bad request from crashing the server)
  • At the top of goroutines (prevent one goroutine from crashing the whole program)

Error Handling in Goroutines

Goroutines cannot return errors. Use channels or the errgroup package:

Using Channels

package main

import "fmt"

func doWork(id int, errCh chan<- error) {
    if id == 3 {
        errCh <- fmt.Errorf("worker %d failed", id)
        return
    }
    fmt.Printf("Worker %d succeeded\n", id)
    errCh <- nil
}

func main() {
    errCh := make(chan error, 5)

    for i := 1; i <= 5; i++ {
        go doWork(i, errCh)
    }

    // Collect errors
    for i := 0; i < 5; i++ {
        if err := <-errCh; err != nil {
            fmt.Println("Error:", err)
        }
    }
}

Using errgroup

The errgroup package from golang.org/x/sync is the standard way to handle errors in groups of goroutines:

package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/errgroup"
)

func fetchAPI(ctx context.Context, url string) error {
    // Simulate API call
    time.Sleep(100 * time.Millisecond)

    if url == "https://api.example.com/fail" {
        return fmt.Errorf("fetch %s: server error", url)
    }

    fmt.Printf("Fetched: %s\n", url)
    return nil
}

func main() {
    g, ctx := errgroup.WithContext(context.Background())

    urls := []string{
        "https://api.example.com/users",
        "https://api.example.com/posts",
        "https://api.example.com/fail",
        "https://api.example.com/comments",
    }

    for _, url := range urls {
        url := url // Capture for goroutine
        g.Go(func() error {
            return fetchAPI(ctx, url)
        })
    }

    // Wait for all goroutines and return the first error
    if err := g.Wait(); err != nil {
        fmt.Println("\nFirst error:", err)
    } else {
        fmt.Println("\nAll requests succeeded")
    }
}

errgroup does three things:

  1. Starts goroutines with g.Go()
  2. Waits for all to finish with g.Wait()
  3. Returns the first error (and cancels the context so other goroutines can stop)

Best Practices

Here is a summary of Go error handling best practices:

// 1. Always wrap errors with context
return fmt.Errorf("parseConfig: %w", err)

// 2. Use errors.Is for sentinel errors
if errors.Is(err, ErrNotFound) { ... }

// 3. Use errors.As for custom error types
var valErr *ValidationError
if errors.As(err, &valErr) { ... }

// 4. Handle errors once — do not log AND return
// Bad:
log.Println("error:", err)
return err

// Good — choose one:
return fmt.Errorf("operation failed: %w", err)
// OR
log.Println("error:", err)
// handle it here, do not return err

// 5. Fail fast — check errors immediately
data, err := readFile(path)
if err != nil {
    return err
}
// Continue with data...

// 6. Use errgroup for concurrent operations
g, ctx := errgroup.WithContext(ctx)

Go Errors vs Rust Result

If you are also learning Rust (from the Rust Tutorial series), here is how they compare:

FeatureGoRust
Error typeerror interfaceResult<T, E> enum
Return style(value, error)Result<T, E>
Check styleif err != nilmatch or ? operator
Error chainingfmt.Errorf("%w", err).map_err(), anyhow
Multiple errorserrgroupjoin! macro
Compile-time safetyNo (can ignore errors)Yes (must handle Result)

Go errors are simpler but rely on programmer discipline. Rust errors are safer but more verbose. For a deeper look at Rust’s approach, see Rust Tutorial #8: Error Handling.

Common Mistakes

1. Using %v instead of %w for wrapping.

// Bad — breaks the error chain
return fmt.Errorf("failed: %v", err)

// Good — preserves the error chain
return fmt.Errorf("failed: %w", err)

With %v, the original error becomes a string. errors.Is() and errors.As() cannot find it anymore.

2. Comparing errors with == instead of errors.Is.

// Bad — does not check wrapped errors
if err == os.ErrNotExist { ... }

// Good — checks the entire chain
if errors.Is(err, os.ErrNotExist) { ... }

3. Panicking for expected errors.

// Bad — panicking for a normal error
data, err := readFile(path)
if err != nil {
    panic(err) // Do not do this!
}

// Good — return the error
data, err := readFile(path)
if err != nil {
    return nil, fmt.Errorf("readFile: %w", err)
}

panic is for truly unexpected situations. Use normal error returns for everything else.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-14 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #15: Building HTTP Servers with net/http, you will learn:

  • Creating HTTP handlers with http.HandleFunc
  • Request and response objects
  • JSON encoding and decoding
  • Go 1.22+ routing patterns
  • Building a complete REST API with the standard library

This is part 14 of the Go Tutorial series. Follow along to learn Go from scratch.