Error Handling: Go's Multi-Return Value Approach

🔊

Go’s error handling philosophy is “simple, explicit, controllable.” Every function that can fail can return an error value, and the caller must explicitly handle it. No exceptions, no implicit propagation—everything is in plain sight.

The error Interface: One Value, One Method

Go’s error interface is minimal: just one Error() string method.

go
1
2
3
type error interface {
    Error() string
}

This means any type that implements Error() string is an error. The Go standard library provides many built-in error types, most commonly errors.New and fmt.Errorf.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
    "errors"
    "fmt"
)

func main() {
    // Create a basic error
    err := errors.New("file not found")
    fmt.Println(err) // Output: file not found

    // Use fmt.Errorf to format error messages
    path := "/data/config.yaml"
    err2 := fmt.Errorf("failed to read config %s", path)
    fmt.Println(err2) // Output: failed to read config /data/config.yaml
}

Errors are essentially values. You can return them, compare them, store them, even pass them to other functions.

Error Wrapping and Unwrapping

In large programs, error chains are crucial. Go 1.13 introduced error wrapping, allowing you to add context without losing the original error.

fmt.Errorf and %w

The %w verb in fmt.Errorf wraps the original error instead of replacing it.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "errors"
    "fmt"
)

func readFile() error {
    return errors.New("failed to open file")
}

func processConfig() error {
    err := readFile()
    // Use %w to wrap the original error
    return fmt.Errorf("cannot read config: %w", err)
}

func main() {
    err := processConfig()
    if err != nil {
        fmt.Println(err) // Output: cannot read config: failed to open file
    }
}

errors.Is: Check Error Type

errors.Is traverses the error chain to check for a matching error.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "errors"
    "fmt"
)

var (
    ErrNotFound = errors.New("resource not found")
)

func findUser(id int) error {
    if id <= 0 {
        return ErrNotFound
    }
    return nil
}

func main() {
    err := findUser(-1)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("handling not found error")
    }
}

errors.As: Extract Custom Error Types

If your error is a custom type, errors.As can extract it.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
    "errors"
    "fmt"
)

// Custom error type
type NotFoundError struct {
    Resource string
    ID       int
}

func (e NotFoundError) Error() string {
    return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}

func findUser(id int) error {
    if id <= 0 {
        return NotFoundError{Resource: "user", ID: id}
    }
    return nil
}

func main() {
    err := findUser(-1)
    var notFound NotFoundError
    if errors.As(err, &notFound) {
        fmt.Printf("Details: %s, ID: %d\n", notFound.Resource, notFound.ID)
    }
}

errors.Unwrap: Unwrap One Layer

errors.Unwrap removes the outermost wrapper and returns the inner error.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "errors"
    "fmt"
)

func inner() error {
    return errors.New("inner error")
}

func outer() error {
    return fmt.Errorf("outer wrapper: %w", inner())
}

func main() {
    err := outer()
    fmt.Println("Original error:", err)           // outer wrapper: inner error
    fmt.Println("Unwrapped once:", errors.Unwrap(err)) // inner error
}

panic and recover: Only for Truly Unrecoverable Errors

Go’s panic and recover are similar to exception mechanisms in other languages, but their use is extremely limited: only when the program encounters a truly unrecoverable error (like nil pointer dereference). Never use panic for regular error handling.

panic: Trigger Fatal Errors

panic interrupts the current function execution and starts stack unwinding.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func riskyOperation() {
    fmt.Println("Starting risky operation")
    panic("fatal error occurred")
    fmt.Println("This line won't execute")
}

func main() {
    riskyOperation()
}

recover: Catch panic

recover must be called in a defer function to catch a panic.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func safelyDo() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Caught panic:", r)
        }
    }()

    panic("something went wrong")
}

func main() {
    safelyDo()
    fmt.Println("Program continues executing")
}

defer with recover Pattern

This is the standard pattern for handling panic: recover in a defer.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

func doWork() (err error) {
    defer func() {
        if r := recover(); r != nil {
            // Convert panic to error
            err = fmt.Errorf("panic converted to error: %v", r)
        }
    }()

    // Code that might trigger panic
    panic("unrecoverable error")

    return nil
}

func main() {
    err := doWork()
    if err != nil {
        fmt.Println("Caught error:", err)
    }
}

Remember: panic/recover is the last resort. If you can handle it with error, don’t use panic.

errors.Join: Combine Multiple Errors (Go 1.20+)

Go 1.20 introduced errors.Join, which can combine multiple errors into one.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
    "errors"
    "fmt"
)

func validateName(name string) error {
    if name == "" {
        return errors.New("name cannot be empty")
    }
    return nil
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return errors.New("age must be between 0 and 150")
    }
    return nil
}

func main() {
    var errs []error

    if err := validateName(""); err != nil {
        errs = append(errs, err)
    }
    if err := validateAge(-1); err != nil {
        errs = append(errs, err)
    }

    if len(errs) > 0 {
        combinedErr := errors.Join(errs...)
        fmt.Println("Validation failed:", combinedErr)
    }
}

The error returned by errors.Join can be checked with errors.Is and errors.As against all combined errors.

Best Practices

When to Use error vs panic

  • Use error: File read failures, network timeouts, invalid user input, etc.—expected errors.
  • Use panic: Nil pointer dereference, array out of bounds, failed type assertion, etc.—unrecoverable program errors.
go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Wrong: using panic for file reading
func readFile() string {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        panic(err) // Don't do this
    }
    return string(data)
}

// Correct: using error for file reading
func readFile() (string, error) {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        return "", err
    }
    return string(data), nil
}

Error Logging

Don’t log errors in low-level functions. Return them to the caller, and let the caller decide whether to log.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Don't do this
func process() error {
    err := doSomething()
    if err != nil {
        log.Printf("processing failed: %v", err) // Logging too early
        return err
    }
    return nil
}

// Correct approach
func process() error {
    return doSomething() // Just return error, don't log
}

func main() {
    if err := process(); err != nil {
        log.Printf("processing failed: %v", err) // Logged by caller
    }
}

Error Wrapping Layers

When wrapping errors, provide meaningful context, but don’t repeatedly wrap.

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Don't repeatedly wrap
func readFile() error {
    return fmt.Errorf("read file failed: %w", fmt.Errorf("open file failed: %w", errors.New("permission denied")))
}

// Correct: wrap one layer of context per layer
func openFile() error {
    return errors.New("permission denied")
}

func readFile() error {
    err := openFile()
    return fmt.Errorf("read file failed: %w", err)
}

Error Handling Consistency

Maintain consistency in error handling across your project: use errors.New to define error variables uniformly, and use uniform wrapping methods.

go
1
2
3
4
5
6
// Define standard error variables
var (
    ErrNotFound     = errors.New("resource not found")
    ErrInvalidInput = errors.New("invalid input")
    ErrPermission   = errors.New("permission denied")
)

Summary

Go’s error handling seems simple, but behind it lies profound design philosophy: explicit over implicit, simple over complex. Multi-return values make error handling intuitive, the error interface provides enough flexibility, error wrapping makes error chains clear, and panic/recover provides the last line of defense for truly unrecoverable situations.

Remember: Errors are values, not exceptions. Use error correctly and use panic sparingly, and your Go programs will be more robust and reliable.