• Error handling in Go is the practice of explicitly checking and responding to failure conditions using ordinary return values, instead of throwing and catching exceptions like most other languages. Every function that can fail returns an error as its last value, and callers are expected to check it immediately with if err != nil.

    • Example below is a natural way of dividing your application into meaningful functions. Each layer returns error to its upper layer (e.g. service layer returns error to HTTP layer which returns to user). Eventually error handling will be combined in the HTTP layer.

      Drawing Board.png

  • To make a function report an error, change its return type to error and return nil when successful and errors.New("message") when something goes wrong. If the function doesn't need to report errors, just omit the return type entirely.

    // can report errors
    func (d Dictionary) Update(word, definition string) error {
        d[word] = definition
        return nil // nil means no error
    }
    
    // no error reporting needed
    func (d Dictionary) Update(word, definition string) {
        d[word] = definition
    }
    
  • The error type is a built-in interface requiring just one method, anything with an Error() string method satisfies it automatically, just like any other interface in Go.

    type error interface {
        Error() string
    }
    
    • The error type, as with all built in types, is predeclared in the universe block.
  • You can create custom error types by implementing the error interface, or you can use the standard errors package to create simple error values. Here's an example of creating an error using the errors.New() function.

  • errors.New creates a simple error from a static string. Use it when the message never changes and needs no extra formatting.

    func Sqrt(f float64) (float64, error) {
        if f < 0 {
            return 0, errors.New("math: square root of negative number")
        }
        return math.Sqrt(f), nil
    }
    
    • fmt.Errorf works like errors.New but lets you format the message with dynamic values, using the same verbs as Printf. It's the same function used for wrapping (see below), so in practice it's the more common of the two.

      if f < 0 {
          return 0, fmt.Errorf("math: square root of negative number %g", f)
      }
      
  • Common Error Handling Patterns:

    • Returning errors: It’s a common pattern to return errors as the last value in the function signature. If a function can return an error, its signature should include an error as the last return value.

      func divide(a, b float64) (float64, error) {
          if b == 0 {
              return 0, errors.New("division by zero is not allowed")
          }
          return a / b, nil
      }
      
    • Handling errors: When calling a function that returns an error, it’s essential to check the error and handle it appropriately. The idiomatic way to do this in Go is to use the if err != nil pattern.

      result, err := divide(10, 0)
      
      if err != nil {
          fmt.Println("Error:", err)
      } else {
          fmt.Println("Result:", result)
      }
      
    • Wrapping errors: You may want to add additional context to an error before returning it to the caller. You can do this using the fmt.Errorf() function, which allows you to create a formatted error message and wrap the original error.

      func processFile(filename string) error {
          file, err := os.Open(filename)
          if err != nil {
              return fmt.Errorf("failed to open file: %w", err)
          }
          defer file.Close()
          // Process the file here
          return nil
      }
      
  • Custom error types allows you to provide additional information about the error and makes it easier for the caller to handle specific error cases. They are structs implementing the error interface, used when the caller needs to extract structured data from the error rather than just read a message. Prefer this over errors.New/fmt.Errorf whenever a caller might need to programmatically react to specific fields of the failure.

    type ValidationError struct {
        Field string
        Msg   string
    }
    
    func (e *ValidationError) Error() string {
        return fmt.Sprintf("%s: %s", e.Field, e.Msg)
    }
    
  • Sentinel errors are predeclared error values that callers check against directly, typically for well-known, expected failure states (e.g. sql.ErrNoRows, io.EOF), where the caller only cares that a specific condition occurred, not any extra data about it. Use it when you want to allow the caller to compare errors directly using the == operator, making error handling more explicit and easier to understand.

    var ErrDivisionByZero = errors.New("division by zero is not allowed")
    
    func divide(a, b float64) (float64, error) {
        if b == 0 {
            return 0, ErrDivisionByZero
        }
        return a / b, nil
    }
    
    // Then, you can check for this specific error when handling the error:
    result, err := divide(10, 0)
    if err == ErrDivisionByZero {
        fmt.Println("Error: Division by zero")
    } else if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
    
    • Prior to Go 1.13, comparing with == broke the moment the error was wrapped for extra context. errors.Is fixes this, since it unwraps the whole chain, so a sentinel still matches even after being wrapped.
    • Convention: name them Err... (e.g. ErrNotFound, ErrDivisionByZero) so they're recognizable at a glance, and declare them as package-level vars so callers can reference and compare against them.
    • Downside: sentinels create a compile-time dependency on the package that declares them, and become part of your public API the moment external callers start checking for them. Be more cautious exporting them from public/shared packages than from internal ones.
  • Go 1.13 introduced the errors.Is() and errors.As() functions to make it easier to work with wrapped errors. The errors.Is() function checks if a specific error is present in the error chain, while the errors.As() function allows you to extract a specific error type from the error chain.

    package main
    
    import (
        "errors"
        "fmt"
        "os"
    )
    
    func main() {
        err := processFile("nonexistent.txt")
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("File does not exist")
        } else if err != nil {
            fmt.Println("Error:", err)
        } else {
            fmt.Println("File processed successfully")
        }
    
        var validationErr *ValidationError
        if errors.As(err, &validationErr) {
            fmt.Printf("Validation error: field=%s, message=%s\n", validationErr.Field, validationErr.Msg)
        }
    }
    
  • Choosing between them: use a sentinel when the caller just needs to know "did this specific condition happen" (not found, EOF, canceled). Use a custom type when the caller needs to pull data out of the failure (which field failed, what the invalid value was). Use plain errors.New/fmt.Errorf when nobody upstream needs to programmatically inspect the error at all, it's just going to be logged or displayed.

  • Wrapping attaches context to an error while preserving the original so it can still be inspected later, forming a chain (e3 → e2 → e1) that traces back to the root cause. Use %w in fmt.Errorf, not %v, since %v just stringifies the error and permanently discards the original, breaking the chain.

    func processFile(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
            return fmt.Errorf("failed to open file: %w", err)
        }
        defer file.Close()
        return nil
    }
    
    • Don't wrap indiscriminately at every layer. Redundant chains like "failed to get user: failed to query db: failed to open connection: connection refused" add noise without adding information. Wrap when you're adding a genuinely new fact (which file, which record); just return err unchanged when a layer has nothing new to say.
  • Unwrapping walks back up the chain to find a specific error or its root cause, without needing to know the chain's exact shape. Prefer these over manual type assertions or == comparisons:

    • errors.Is(err, target) checks if target appears anywhere in err's chain. Use for sentinel errors.
    • errors.As(err, &target) checks if any error in the chain matches a given type, and if so assigns it into target. Use for custom error types when you need their fields.
    • errors.Unwrap(err) returns just the next error in the chain (what Is/As use internally). Rarely called directly.
    err := processFile("nonexistent.txt")
    
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File does not exist")
    }
    
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        fmt.Println("Field:", validationErr.Field)
    }
    
    • For errors.Is/As to see through a custom error type, that type must implement Unwrap() error (returning the wrapped error). Without it, the chain stops there regardless of %w.
    • Recommended read: https://medium.com/@vajahatkareem/golang-error-wrapping-multierror-759d04bdbfaf
  • errors.Join (Go 1.20+) combines multiple independent errors into one, useful when several unrelated things can fail in the same operation (e.g. validating several fields in a loop) and you want to report all of them at once rather than stopping at the first.

    var errs []error
    if name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    return errors.Join(errs...) // nil if errs is empty
    
    • errors.Is/As still work on a joined error, they check against every error in the group.
  • The nil-interface gotcha: a nil pointer of a concrete error type, once assigned to an error interface variable, is no longer == nil, because the interface value holds both a type and a value, and the type is non-nil even though the value is. This is one of the most common bugs beginners hit.

    func mayFail() *MyError { return nil } // no error occurred
    
    func doSomething() error {
        var err *MyError = mayFail()
        return err // BUG: returns a non-nil error interface, even though err looks like nil
    }
    
    • Fix: return the concrete nil directly, or only assign to the interface when you know there's an actual error, don't let a typed nil pointer flow into an error-typed return.
  • Handle errors once, at the layer that can actually do something about them. A common anti-pattern is logging an error and then also returning it up the stack, causing the same failure to be logged multiple times as it bubbles through layers. Either log and stop (if you're at the top), or wrap and return (if a caller further up needs to react), not both.