Go’s error handling is controversial. Some people love it, some hate it. After 6 months of writing Go in production, here’s where I land.

The Go Way

In Go, errors are values. Functions return errors as their last return value:

func readFile(filename string) ([]byte, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

You check errors explicitly:

data, err := readFile("config.json")
if err != nil {
    log.Fatal(err)
}
// Use data

No exceptions, no try/catch. Just explicit error checking.

What I Like

1. Errors Are Visible

You can’t ignore errors in Go (well, you can with _, but it’s obvious):

data, _ := readFile("file.txt")  // Ignoring error - code smell

In languages with exceptions, it’s easy to forget error handling:

// Java - might throw, might not, who knows?
String data = readFile("file.txt");

2. No Hidden Control Flow

Exceptions create hidden control flow. A function 10 levels deep can throw an exception that bubbles up. In Go, errors flow explicitly through your code.

3. Forces You to Think About Errors

Every error requires a decision: log it, return it, wrap it, handle it. This makes you think about error cases upfront.

What I Don’t Like

1. Repetitive Code

This pattern appears everywhere:

result1, err := doThing1()
if err != nil {
    return err
}

result2, err := doThing2(result1)
if err != nil {
    return err
}

result3, err := doThing3(result2)
if err != nil {
    return err
}

It’s verbose. Really verbose. About 50% of my Go code is error checking.

2. Error Context Gets Lost

When you return an error up the stack, you lose context:

func processUser(id int) error {
    user, err := getUser(id)
    if err != nil {
        return err  // What was the ID? Where did this fail?
    }
    // ...
}

You have to manually add context:

if err != nil {
    return fmt.Errorf("failed to get user %d: %v", id, err)
}

3. No Stack Traces

When an error occurs, you don’t get a stack trace. You have to add logging at each level to track where errors originate.

Patterns I Use

1. Wrap Errors with Context

func processOrder(orderID string) error {
    order, err := getOrder(orderID)
    if err != nil {
        return fmt.Errorf("processOrder: failed to get order %s: %v", orderID, err)
    }
    
    err = validateOrder(order)
    if err != nil {
        return fmt.Errorf("processOrder: validation failed for order %s: %v", orderID, err)
    }
    
    return nil
}

This gives you a breadcrumb trail when errors occur.

2. Custom Error Types

For errors that need special handling:

type ValidationError struct {
    Field string
    Message string
}

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

// Usage
if age < 0 {
    return &ValidationError{Field: "age", Message: "must be positive"}
}

Then you can type-assert to handle specific errors:

if err != nil {
    if valErr, ok := err.(*ValidationError); ok {
        // Handle validation error specifically
        log.Printf("Validation failed: %s", valErr.Field)
    }
    return err
}

3. Error Checking Helper

For cases where you just want to panic on error (like in main or tests):

func must(err error) {
    if err != nil {
        panic(err)
    }
}

// Usage
data, err := ioutil.ReadFile("config.json")
must(err)

Don’t use this in library code, but it’s handy for applications.

Comparison with Other Languages

Java: Checked exceptions are annoying, unchecked exceptions are invisible. Go’s approach is better.

Python: Exceptions are fine, but easy to ignore. Go forces you to handle errors.

JavaScript: Callbacks made error handling messy. Promises help, but Go’s approach is more explicit.

Rust: Result types are similar to Go’s approach but with better type safety. I like Rust’s ? operator for propagating errors.

The Verdict

I’ve made peace with Go’s error handling. Yes, it’s verbose. Yes, it’s repetitive. But it’s also explicit, predictable, and forces you to think about error cases.

After debugging production issues in other languages where exceptions were swallowed or ignored, I appreciate Go’s approach.

Would I prefer something like Rust’s Result<T, E> with the ? operator? Maybe. But Go’s simplicity has its own appeal.

Tips for Go Error Handling

  1. Always check errors: Don’t use _ unless you have a good reason
  2. Add context: Wrap errors with information about what failed
  3. Use custom error types: For errors that need special handling
  4. Log at boundaries: Log errors where they’re handled, not everywhere
  5. Don’t panic: Reserve panic for truly exceptional cases

What’s Coming

There’s talk of adding better error handling to Go 2. The community has proposed various solutions, but nothing’s decided yet. I’m curious to see where it goes.

For now, I’m sticking with the current approach. It works, even if it’s not perfect.

What do you think about Go’s error handling? Love it? Hate it? Let me know.