Error Handling in Go

Go treats errors as values, not exceptions. This leads to explicit, visible error handling throughout your code.

The Error Interface

type error interface {
    Error() string
}

Any type with an Error() string method is an error.

Basic Error Handling

result, err := doSomething()
if err != nil {
    // Handle error
    return err  // or handle it another way
}
// Use result

This pattern is everywhere in Go. Embrace it.

Creating Errors

Simple Errors

import "errors"

// errors.New
err := errors.New("something went wrong")

// fmt.Errorf (formatted)
err := fmt.Errorf("failed to open file: %s", filename)

Sentinel Errors

Predefined errors that can be checked with ==:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrInvalidInput = errors.New("invalid input")
)

func GetUser(id int) (*User, error) {
    user := db.Find(id)
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

// Checking
user, err := GetUser(123)
if err == ErrNotFound {
    // Handle not found specifically
}

Custom Error Types

type ValidationError struct {
    Field   string
    Message string
}

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

func ValidateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "must contain @",
        }
    }
    return nil
}

// Type assertion to access fields
err := ValidateEmail("invalid")
if ve, ok := err.(*ValidationError); ok {
    fmt.Println("Field:", ve.Field)
}

Error Wrapping (Go 1.13+)

Wrap errors to add context while preserving the original:

import "fmt"

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap with %w verb
        return nil, fmt.Errorf("reading config %s: %w", path, err)
    }

    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }

    return &config, nil
}

Unwrapping Errors

import "errors"

// errors.Unwrap - get the wrapped error
originalErr := errors.Unwrap(wrappedErr)

// errors.Is - check if error chain contains a specific error
if errors.Is(err, ErrNotFound) {
    // Handle not found, even if wrapped
}

if errors.Is(err, os.ErrNotExist) {
    // File doesn't exist
}

// errors.As - check if error chain contains a specific type
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("Path:", pathErr.Path)
}

var validationErr *ValidationError
if errors.As(err, &validationErr) {
    fmt.Println("Invalid field:", validationErr.Field)
}

Implementing Unwrap

For custom error types that wrap other errors:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    return e.Err
}

// Now errors.Is and errors.As work through QueryError

Error Handling Patterns

Return Early

// Good: return early, happy path at the end
func ProcessFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("opening file: %w", err)
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        return fmt.Errorf("reading file: %w", err)
    }

    if err := validate(data); err != nil {
        return fmt.Errorf("validating: %w", err)
    }

    return process(data)
}

Handle Once

Handle errors at one place: either handle it or return it, not both.

// Bad: logging and returning
if err != nil {
    log.Printf("error: %v", err)  // Logged here
    return err                      // And caller might log again!
}

// Good: just return with context
if err != nil {
    return fmt.Errorf("processing user %d: %w", userID, err)
}

// Handle at the top level
func main() {
    if err := run(); err != nil {
        log.Fatal(err)
    }
}

Ignore Errors Explicitly

If you must ignore an error, be explicit:

// Make it clear this is intentional
_ = file.Close()

// Or with a comment
file.Close() // Error ignored: file was opened read-only

Must Functions

For initialization where errors are truly fatal:

func MustOpen(path string) *os.File {
    f, err := os.Open(path)
    if err != nil {
        panic(err)
    }
    return f
}

// Usage during initialization
var configFile = MustOpen("config.json")

// Standard library examples
regexp.MustCompile(`\d+`)      // Panics if invalid regex
template.Must(template.New("").Parse("..."))

Use sparingly, only for truly unrecoverable initialization errors.

Multiple Errors

Collecting Errors

func ValidateUser(u User) error {
    var errs []error

    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Email == "" {
        errs = append(errs, errors.New("email is required"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("age cannot be negative"))
    }

    if len(errs) > 0 {
        return errors.Join(errs...)  // Go 1.20+
    }
    return nil
}

// Go 1.20+: errors.Join creates a multi-error
err := errors.Join(err1, err2, err3)

// errors.Is works with joined errors
if errors.Is(err, ErrNotFound) {
    // At least one of the joined errors is ErrNotFound
}

Before Go 1.20

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    var msgs []string
    for _, e := range m.Errors {
        msgs = append(msgs, e.Error())
    }
    return strings.Join(msgs, "; ")
}

func (m *MultiError) Unwrap() []error {
    return m.Errors
}

Error Messages

Good Error Messages

// Good: lowercase, no punctuation, provides context
return fmt.Errorf("reading config file %s: %w", path, err)

// Good: chain of context
// "starting server: loading config: reading config file config.json: open config.json: no such file or directory"

// Bad: uppercase, periods, redundant
return fmt.Errorf("Error: Failed to read file. %w", err)

// Bad: too vague
return errors.New("something went wrong")

Error Message Convention

  • Start with lowercase
  • No ending punctuation
  • Add context going up the stack
  • Include relevant values (IDs, paths, etc.)

Panic and Recover

For truly exceptional cases (not normal errors):

// Panic stops normal execution
func mustPositive(n int) int {
    if n <= 0 {
        panic("n must be positive")
    }
    return n
}

// Recover catches panics
func safeCall(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    fn()
    return nil
}

When to use panic:

  • Programming errors (impossible states, invalid arguments that indicate bugs)
  • Initialization failures that make the program unusable
  • Never for expected runtime errors (file not found, network errors, etc.)

Error Handling with Defer

func WriteFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return err
    }

    // Handle Close error
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()

    _, err = f.Write(data)
    return err
}

Checking Error Types (Summary)

import "errors"

// Check for specific error value
if err == ErrNotFound { ... }
if errors.Is(err, ErrNotFound) { ... }  // Works through wrapping

// Check for specific error type
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("Path:", pathErr.Path)
}

// Type switch
switch e := err.(type) {
case *ValidationError:
    fmt.Println("Field:", e.Field)
case *NetworkError:
    fmt.Println("Retrying...")
default:
    fmt.Println("Unknown error:", e)
}

Real-World Example

package user

import (
    "errors"
    "fmt"
)

var (
    ErrNotFound       = errors.New("user not found")
    ErrDuplicateEmail = errors.New("email already exists")
)

type ValidationError struct {
    Field   string
    Message string
}

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

type Service struct {
    db Database
}

func (s *Service) Create(u *User) error {
    if err := s.validate(u); err != nil {
        return fmt.Errorf("validating user: %w", err)
    }

    exists, err := s.db.EmailExists(u.Email)
    if err != nil {
        return fmt.Errorf("checking email: %w", err)
    }
    if exists {
        return ErrDuplicateEmail
    }

    if err := s.db.Insert(u); err != nil {
        return fmt.Errorf("inserting user: %w", err)
    }

    return nil
}

func (s *Service) validate(u *User) error {
    if u.Name == "" {
        return &ValidationError{Field: "name", Message: "required"}
    }
    if u.Email == "" {
        return &ValidationError{Field: "email", Message: "required"}
    }
    return nil
}

// Usage
err := service.Create(user)
if err != nil {
    if errors.Is(err, ErrDuplicateEmail) {
        // Show user-friendly message
    }
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        // Highlight the invalid field
    }
    // Log full error with context
    log.Printf("creating user: %v", err)
}

Next Steps

Continue to 09-concurrency.md to learn about goroutines and channels.