Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Go Error Handling

Error Handling in Go

Go takes a unique approach to error handling. Instead of try/catch exceptions found in languages like Java or Python, Go treats errors as values. This forces developers to handle errors explicitly where they occur, leading to more robust and predictable software. The trade-off is real: Go code has more if err != nil blocks than most developers are used to. But the benefit is equally real — you always know where errors are handled (right there, at the call site), and you never have an exception flying across stack frames to a catch block you forgot existed. As Rob Pike put it: “Errors are values. Values can be programmed.”

The error Interface

In Go, an error is anything that implements the built-in error interface:
type error interface {
    Error() string
}

Basic Error Handling

Functions often return an error as the last return value.
file, err := os.Open("filename.txt")
if err != nil {
    log.Fatal(err)
}
// use file

Creating Errors

You can create simple errors using errors.New or fmt.Errorf.
import "errors"

func validate(input int) error {
    if input < 0 {
        return errors.New("input cannot be negative")
    }
    return nil
}

Custom Error Types

Since error is an interface, you can create custom structs that implement it to provide more context.
type NetworkError struct {
    Code    int
    Message string
}

func (e *NetworkError) Error() string {
    return fmt.Sprintf("Network Error %d: %s", e.Code, e.Message)
}

Wrapping and Unwrapping Errors

Go 1.13 introduced error wrapping, allowing you to add context to an error while preserving the original error. This creates an “error chain” that can be inspected programmatically.

Wrapping

Use the %w verb with fmt.Errorf to wrap an error:
if err != nil {
    return fmt.Errorf("failed to connect to database: %w", err)
}
Why Wrap? Error wrapping provides a “stack trace” of context without losing the original error. Each layer adds information about where and why the error occurred. Go Error Wrapping Example Error Chain:
initialization failed: failed to read config: EOF
                                              ^
                                              Original error (io.EOF)

Unwrapping (errors.Is and errors.As)

When you have a wrapped error, you need special functions to inspect the chain:
  • errors.Is(err, target): Checks if any error in the chain matches the target error.
  • errors.As(err, &target): Finds the first error in the chain that matches the target type and assigns it.
import (
    "errors"
    "io"
)

// Check for specific error
if errors.Is(err, io.EOF) {
    // Handle end of file
}

// Extract custom error type
var netErr *NetworkError
if errors.As(err, &netErr) {
    fmt.Println("Network error code:", netErr.Code)
    // Can now access all fields of NetworkError
}
Why Not Just Compare? Simple comparison (err == io.EOF) only works for the outermost error. errors.Is traverses the entire chain.
err := fmt.Errorf("wrapped: %w", io.EOF)
fmt.Println(err == io.EOF)      // false (wrapped)
fmt.Println(errors.Is(err, io.EOF)) // true (unwraps and checks)

Panic and Recover

Go has a mechanism for exceptional conditions called panic. It should be used sparingly, mostly for unrecoverable errors (like nil pointer dereferences or invariant violations).

Panic

panic stops the ordinary flow of control and begins panicking.
panic("something went terribly wrong")

Recover

recover is a built-in function that regains control of a panicking goroutine. It is only useful inside a deferred function.
func safeFunction() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    
    // Code that might panic
    panic("oops")
}

Sentinel Errors

Sentinel errors are pre-defined error values used for comparison. The standard library defines several:
import (
    "database/sql"
    "io"
    "os"
)

// Standard library sentinel errors:
// io.EOF        -- end of file/stream
// sql.ErrNoRows -- query returned no rows
// os.ErrNotExist -- file does not exist

// Define your own sentinel errors for your package:
var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("resource conflict")
)

// Check with errors.Is (works through wrapped error chains):
if errors.Is(err, ErrNotFound) {
    http.Error(w, "Not found", http.StatusNotFound)
    return
}
Idiomatic Go: Export sentinel errors with the Err prefix (ErrNotFound, ErrTimeout). This is a strong convention across the standard library and community packages. When a caller sees Err prefix, they know it is a comparable error value.

Best Practices

  1. Don’t Ignore Errors: Always check the err return value. Use _ only if you are absolutely sure the error is irrelevant (e.g., fmt.Println return values).
  2. Add Context: Wrap errors to provide “stack trace”-like context (e.g., “loading config: parsing json: syntax error”). Each layer should add what it was trying to do, not just repeat the error.
  3. Avoid Panic: Use errors for normal control flow. Reserve panic for truly exceptional, unrecoverable states (programming bugs, invariant violations).
  4. Use %w for wrapping, %v for opaque errors: If callers should be able to inspect the underlying error with errors.Is or errors.As, use %w. If you want to hide implementation details, use %v to create an opaque error.
  5. Define error types at package boundaries: Public APIs should document what errors they return (sentinel errors or custom types), so callers can handle them specifically.
Pitfall — Checking err == nil on an Interface Holding a Typed Nil: This is one of Go’s most confusing gotchas and ties directly back to the interface nil discussion from the Interfaces chapter:
func doWork() error {
    var myErr *MyError  // myErr is nil, but it has type *MyError
    // ... some logic that might set myErr ...
    return myErr // Returns a non-nil interface!(type=*MyError, value=nil)
}

err := doWork()
fmt.Println(err == nil) // false! The interface holds (*MyError, nil)
The fix: always return nil explicitly when there is no error, never return a typed nil pointer:
func doWork() error {
    var myErr *MyError
    // ...
    if myErr != nil {
        return myErr
    }
    return nil // Explicitly return nil, not the typed nil variable
}

Summary

  • Errors are values in Go, implementing the error interface.
  • Handle errors explicitly using if err != nil.
  • Use fmt.Errorf with %w to wrap errors with context.
  • Use errors.Is and errors.As to inspect wrapped errors.
  • panic and recover are for exceptional circumstances, not normal flow control.

Interview Deep-Dive

Strong Answer:
  • errors.Is(err, target) traverses the error chain looking for an error that matches the target value. It is used for sentinel errors like io.EOF, sql.ErrNoRows, or your own ErrNotFound. It compares by value equality (or by a custom Is method if the error type defines one). Use it when you want to know “is this (or was this caused by) a specific known error?”
  • errors.As(err, &target) traverses the chain looking for an error that can be assigned to the target type. It is used for custom error types when you need to extract structured information (like an HTTP status code, a retry-after duration, or a list of validation failures). Use it when you want to know “is there a specific error type in this chain, and if so, give me its fields.”
  • %w (wrap) preserves the original error in the chain so callers can use errors.Is and errors.As to inspect it. %v creates a new error string that includes the original’s message but does NOT preserve the chain — callers cannot unwrap it. Use %w when you want callers to be able to match on the underlying error. Use %v when you want to hide implementation details (for example, you do not want callers depending on the specific database driver error your package returns).
  • This is an API design decision at package boundaries. If your package’s documentation says “returns ErrNotFound when the item does not exist,” you must wrap with %w so callers can check with errors.Is. If your package wants to abstract away the underlying storage, wrap with %v to make the error opaque.
Follow-up: How would you design error handling for a REST API that needs to return appropriate HTTP status codes based on error types?I would define a set of sentinel errors or error types at the service layer (ErrNotFound, ErrConflict, ErrValidation, ErrUnauthorized) and use errors.Is or errors.As in the HTTP handler to map them to status codes. The handler acts as a translation layer: if errors.Is(err, service.ErrNotFound) { w.WriteHeader(404) }. For richer errors, I define a type APIError struct { Code int; Message string; Details []FieldError } and use errors.As to extract it. The key principle is that the service layer should not know about HTTP — it returns domain errors. The handler translates domain errors to HTTP responses. This separation means the same service can be used by gRPC handlers, CLI tools, or message consumers, each mapping errors to their own protocol’s conventions.
Strong Answer:
  • Errors as values force explicit handling at every call site. You see exactly where each error is checked and what happens when it occurs. There are no invisible control flow jumps across stack frames. The code reads linearly: call, check, handle, continue. In Java, a method five levels deep can throw a checked exception that flies up to a catch block you wrote months ago, and the intermediate stack frames have no idea what happened.
  • Errors as values are also composable. Since an error is just a value implementing an interface, you can wrap it with context at each layer, store it in data structures, pass it through channels, compare it programmatically, and make decisions based on its type. You can write functions that take errors as arguments and return errors. Rob Pike’s phrase “errors are values; values can be programmed” captures this elegance.
  • Where it falls short: verbosity. if err != nil blocks add roughly 30% more lines to Go code compared to equivalent Python or Java code. Every function call that can fail requires three lines of boilerplate. This is the most common criticism of Go’s error handling, and even the Go team has acknowledged it with failed proposals for error handling shorthand. Second, it is easy to accidentally ignore an error by not checking the return value. Linters like errcheck catch this, but it is not enforced by the compiler. Third, the error wrapping chain can become unwieldy: "creating order: validating payment: connecting to stripe: dial tcp: i/o timeout" is useful for debugging but too detailed for end users.
  • The pragmatic view: in my experience, Go’s error handling produces more reliable production systems because errors are handled where they occur. The verbosity is a real cost but one that pays for itself when you are debugging a production incident at 2 AM and every function has explicit error handling with context.
Follow-up: When is panic appropriate in Go, and how does recover work in practice?Panic is appropriate only for programming errors that represent invariant violations — conditions that should never occur if the code is correct. Examples: an index out of bounds when you verified the length, a nil pointer that your type’s constructor guarantees is set, or a failed type assertion on a type you control. The standard library uses panic for these cases (slice out of bounds, nil map write, sending on a closed channel). Recover is used almost exclusively in two places: HTTP middleware that catches panics from handlers to prevent one bad request from crashing the server, and library code that converts panics into errors at API boundaries. The pattern is defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }(). Importantly, recover only works when called directly inside a deferred function, not nested deeper. And you should almost never catch and suppress panics silently — log the stack trace with debug.Stack() so the programming error can be found and fixed.
Strong Answer:
  • The scenario: a function declares var myErr *MyError (which is nil), does some processing, and returns myErr as the error return type. The caller checks if err != nil and gets true, even though myErr is nil. This happens because the interface value now contains (*MyError, nil) — the type metadata pointer is set to *MyError’s type descriptor, while the data pointer is nil. The == nil check on an interface returns true only when BOTH the type and data are nil.
  • At the memory level, an interface is two pointers. A fully nil interface has both pointers as zero. When you assign a typed nil pointer to the interface, the first pointer gets set to the itab for *MyError implementing error, and the second pointer is nil. Since the first pointer is non-zero, the interface is non-nil.
  • Prevention: the rule is simple — never return a typed nil variable as an interface. Always return the bare nil literal: if myErr != nil { return myErr }; return nil. This ensures the interface is fully nil (both pointers zero) when there is no error.
  • In code reviews, watch for functions that declare error variables of a concrete type and return them at the end. This is the pattern that produces the bug. Linters like nilerr can detect some forms of this, and the go vet tool has checks for related issues.
  • A deeper prevention strategy is to never use concrete error types as local variables in the return path. Keep your error chain as error interface throughout, and only construct concrete error types at the point of return: return &MyError{Code: 500, Msg: "failure"}.
Follow-up: Can you use errors.Is to check for a custom error type, or do you need errors.As?It depends on what you are checking. errors.Is checks for value equality, so it works for sentinel errors (var ErrNotFound = errors.New("not found")). For custom struct types, errors.Is uses == comparison, which for pointer types checks pointer identity (same object in memory), not structural equality. So if you create &MyError{Code: 404} in two different places, errors.Is will not match them because they are different pointer values. To make errors.Is work with struct types, implement a custom Is(target error) bool method on your error type that does structural comparison. Alternatively, use errors.As when you want to match by type regardless of the specific value. In practice, use sentinel errors for well-known conditions (ErrNotFound, ErrTimeout) and errors.As when you need to extract data from a typed error.