Errors #

Error handling is one of the aspects that most distinguishes Go from other languages. Instead of exceptions thrown and caught anywhere, Go chooses an explicit approach: every function that can fail returns an error as an ordinary value, and the caller is responsible for checking and handling it. The errors package — together with fmt.Errorf — is the foundation of this system. Since Go 1.13, this package has been strengthened with error wrapping: wrapping the original error in a new error that adds context, while still preserving the ability to inspect the original error type using errors.Is and errors.As. Understanding the errors package well means understanding how to build a system that gives informative error messages, is easy to debug, and can be handled differently based on its type.

The Error Philosophy in Go #

Before diving into the API, it’s important to understand why Go chose this approach:

flowchart LR
    subgraph Exception["Languages with Exceptions\n(Java, Python, C++)"]
        E1["function() throws Exception"] --> E2["...many layers..."]
        E2 --> E3["catch (Exception e)"]
        E3 --> E4["Who threw it?\nFrom which layer?\nWhat context?"]
    end

    subgraph Go["Go — Error as a Value"]
        G1["err := function()"] --> G2{"err != nil?"}
        G2 -- Yes --> G3["Handle here\nor wrap and return"]
        G2 -- No --> G4["Continue"]
        G3 --> G5["errors.Is / errors.As\nfor type inspection"]
    end

    style Exception fill:#fce4ec
    style Go fill:#e8f5e9

The advantages of the Go approach:

  • Errors are always visible in the function signature — no hidden surprises
  • Every layer can add context before passing the error on
  • The caller can choose: handle, wrap, or ignore (with _, but this is an anti-pattern)
  • An error is an ordinary value — it can be stored, compared, and inspected

errors.New — Creating Simple Errors #

errors.New creates an error with a fixed message. It’s the most basic way to define sentinel errors — errors representing a specific condition that can be compared with errors.Is.

package main

import (
    "errors"
    "fmt"
)

// Sentinel errors — error variables defined at the package level
// Convention: names start with "Err"
var (
    ErrNotFound       = errors.New("not found")
    ErrNotAllowed     = errors.New("not allowed")
    ErrInvalidInput   = errors.New("invalid input")
    ErrConnectionIssue = errors.New("connection issue")
)

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrInvalidInput
    }
    if id > 1000 {
        return nil, ErrNotFound
    }
    return &User{ID: id, Name: "Budi"}, nil
}

func main() {
    _, err := findUser(-1)

    // Compare with == — ANTI-PATTERN for wrapped errors
    if err == ErrInvalidInput {
        fmt.Println("wrong input") // works but not robust
    }

    // CORRECT: use errors.Is — works even if the error is wrapped
    if errors.Is(err, ErrInvalidInput) {
        fmt.Println("wrong input") // always correct
    }
}

Why errors.Is, Not == #

flowchart TD
    subgraph Without["err == ErrNotFound"]
        T1["findUser: ErrNotFound"] --> T2["service: wrap with fmt.Errorf %w"]
        T2 --> T3["handler: err == ErrNotFound"]
        T3 --> T4["false ❌\nthe error is already wrapped,\nnot exactly equal"]
    end

    subgraph With["errors.Is(err, ErrNotFound)"]
        D1["findUser: ErrNotFound"] --> D2["service: wrap with fmt.Errorf %w"]
        D2 --> D3["handler: errors.Is(err, ErrNotFound)"]
        D3 --> D4["true ✓\nerrors.Is opens all wrapping\nlayers recursively"]
    end

    style T4 fill:#fce4ec
    style D4 fill:#e8f5e9
var ErrNotFound = errors.New("not found")

// Repository layer
func repoFindBook(id int) error {
    return ErrNotFound
}

// Service layer — adds context
func serviceFindBook(id int) error {
    if err := repoFindBook(id); err != nil {
        return fmt.Errorf("serviceFindBook %d: %w", id, err)
        // The error is now: "serviceFindBook 42: not found"
    }
    return nil
}

// Handler layer
func handlerFindBook(id int) {
    err := serviceFindBook(id)

    // == doesn't work because the error is already wrapped
    fmt.Println(err == ErrNotFound) // false

    // errors.Is opens all wrapping layers
    fmt.Println(errors.Is(err, ErrNotFound)) // true ✓
}

fmt.Errorf with %w — Error Wrapping #

fmt.Errorf with the %w verb is the idiomatic way to wrap an error while adding context. This differs from %v, which only converts the error to a string without unwrap capability.

// %v vs %w comparison
var ErrDB = errors.New("database error")

// With %v — the old error becomes a string, can't be unwrapped
err1 := fmt.Errorf("operation failed: %v", ErrDB)
fmt.Println(errors.Is(err1, ErrDB)) // false ❌

// With %w — the old error is wrapped, can be unwrapped
err2 := fmt.Errorf("operation failed: %w", ErrDB)
fmt.Println(errors.Is(err2, ErrDB)) // true ✓

// Naming convention in error messages
// Format: "functionName arguments: message" or "functionName: message"
func loadConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        // Include the path as context — very useful when debugging
        return fmt.Errorf("loadConfig %s: %w", path, err)
    }
    _ = data
    return nil
}

func initializeApp(configPath string) error {
    if err := loadConfig(configPath); err != nil {
        return fmt.Errorf("initializeApp: %w", err)
    }
    return nil
}

// The error formed when called with a wrong path:
// "initializeApp: loadConfig /etc/app.yaml:
//  open /etc/app.yaml: no such file or directory"

Multiple Wrapping (Go 1.20+) #

Since Go 1.20, one error can wrap several errors at once:

var (
    ErrValidation = errors.New("validation failed")
    ErrDB         = errors.New("database error")
)

// Wrap two errors at once with two %w verbs
err := fmt.Errorf("saveData: %w and %w", ErrValidation, ErrDB)
fmt.Println(err)
// saveData: validation failed and database error

fmt.Println(errors.Is(err, ErrValidation)) // true
fmt.Println(errors.Is(err, ErrDB))       // true

// errors.Join — another way to combine several errors (Go 1.20+)
errs := []error{ErrValidation, ErrDB}
joined := errors.Join(errs...)
fmt.Println(joined)
// validation failed
// database error

fmt.Println(errors.Is(joined, ErrValidation)) // true
fmt.Println(errors.Is(joined, ErrDB))       // true

errors.Is — Checking Error Types #

errors.Is checks whether an error — or any error in its wrapping chain — matches the given target.

flowchart TD
    Call["errors.Is(err, target)"] --> Step1["Check: err == target?"]
    Step1 -- Yes --> True["return true"]
    Step1 -- No --> Step2{"err has an\nIs(error) bool method?"}
    Step2 -- Yes --> Step3["Call err.Is(target)"]
    Step3 -- true --> True
    Step3 -- false --> Step4{"err has an\nUnwrap() error method?"}
    Step2 -- No --> Step4
    Step4 -- Yes --> Step5["err = err.Unwrap()\nrepeat from the start"]
    Step4 -- No --> Step6{"err has an\nUnwrap() []error method?"}
    Step6 -- Yes --> Step7["Check every error\nin the slice"]
    Step6 -- No --> False["return false"]
    Step7 --> True
    Step7 --> False

    style True fill:#e8f5e9
    style False fill:#fce4ec
import (
    "errors"
    "io"
    "io/fs"
    "os"
)

// Examples with various error types from the stdlib
func errorsIsExamples() {
    // An error from the os package
    _, err := os.Open("missing.txt")

    // Check with sentinel errors from io/fs
    if errors.Is(err, fs.ErrNotExist) {
        fmt.Println("file doesn't exist")
    }
    if errors.Is(err, fs.ErrPermission) {
        fmt.Println("no permission")
    }

    // EOF — a sentinel error from io
    _, err2 := fmt.Sscan("", new(int))
    if errors.Is(err2, io.EOF) {
        fmt.Println("no data")
    }

    // errors.Is also works with nil
    var errNil error = nil
    fmt.Println(errors.Is(errNil, nil)) // true
}

// A custom Is method — for more flexible comparisons
type HTTPError struct {
    StatusCode int
    Message    string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}

// Implement Is so errors.Is works based on the status code
func (e *HTTPError) Is(target error) bool {
    t, ok := target.(*HTTPError)
    if !ok {
        return false
    }
    // Match if the status code is the same (ignore the message)
    return e.StatusCode == t.StatusCode
}

var ErrNotFoundHTTP = &HTTPError{StatusCode: 404}
var ErrUnauthorizedHTTP = &HTTPError{StatusCode: 401}

func checkResponse(err error) {
    if errors.Is(err, ErrNotFoundHTTP) {
        fmt.Println("resource not found")
    }
    if errors.Is(err, ErrUnauthorizedHTTP) {
        fmt.Println("authentication needed")
    }
}

errors.As — Extracting Error Types #

errors.As searches the error chain for an error of a specific type and extracts its value into a target variable. This is the way to access the fields or methods of a custom error type.

// errors.As enables access to error details
type ValidationError struct {
    Field   string
    Message string
    Value   any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed for field %q: %s (value: %v)",
        e.Field, e.Message, e.Value)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field:   "age",
            Message: "must not be negative",
            Value:   age,
        }
    }
    if age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "not realistic",
            Value:   age,
        }
    }
    return nil
}

func processRegistration(age int) error {
    if err := validateAge(age); err != nil {
        return fmt.Errorf("processRegistration: %w", err)
    }
    return nil
}

func main() {
    err := processRegistration(-5)

    // errors.Is isn't enough — we need the field and value details
    // errors.As extracts *ValidationError from inside the wrapping chain
    var errVal *ValidationError
    if errors.As(err, &errVal) {
        // Now we can access the fields of ValidationError
        fmt.Printf("Problem field: %s\n", errVal.Field)
        fmt.Printf("Message: %s\n", errVal.Message)
        fmt.Printf("Sent value: %v\n", errVal.Value)

        // Can create an appropriate HTTP response
        // http.Error(w, errVal.Message, http.StatusBadRequest)
    }
}

errors.Is vs errors.As — When to Use Each #

flowchart TD
    Q{"What do you\nwant to check?"} --> V["Is the error a\nspecific condition?\n(no details needed)"]
    Q --> D["Does the error have\na specific type and\ndo I need to access its fields?"]

    V --> Is["errors.Is(err, ErrTarget)\n\nExamples:\nerrors.Is(err, ErrNotFound)\nerrors.Is(err, io.EOF)\nerrors.Is(err, fs.ErrPermission)"]

    D --> As["errors.As(err, &target)\n\nExamples:\nerrors.As(err, &errValidation)\nerrors.As(err, &errHTTP)\nerrors.As(err, &errDB)"]

    Is --> IsEx["Use for:\n- Sentinel errors\n- Binary conditions (present/absent)\n- Control flow based on error types"]

    As --> AsEx["Use for:\n- Custom errors with extra data\n- Accessing status codes, field names, etc.\n- Responses tailored to error details"]

    style Is fill:#e3f2fd
    style As fill:#e8f5e9
// A real example: an HTTP handler with full error handling
func findBookHandler(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "invalid ID", http.StatusBadRequest)
        return
    }

    book, err := serviceFindBook(id)
    if err != nil {
        // Check the error type for the right response
        switch {
        case errors.Is(err, ErrNotFound):
            http.Error(w, "Book not found", http.StatusNotFound)

        case errors.Is(err, ErrNotAllowed):
            http.Error(w, "Access denied", http.StatusForbidden)

        default:
            // Check whether there are validation error details
            var errVal *ValidationError
            if errors.As(err, &errVal) {
                http.Error(w,
                    fmt.Sprintf("Invalid input: %s", errVal.Message),
                    http.StatusBadRequest)
                return
            }

            // Unknown error — log and return 500
            log.Printf("unexpected error: %v", err)
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }

    json.NewEncoder(w).Encode(book)
}

errors.Unwrap — Opening One Layer #

errors.Unwrap opens one wrapping layer — useful if you want to traverse the error chain manually.

err1 := errors.New("base error")
err2 := fmt.Errorf("layer 2: %w", err1)
err3 := fmt.Errorf("layer 3: %w", err2)

fmt.Println(err3)                    // layer 3: layer 2: base error
fmt.Println(errors.Unwrap(err3))     // layer 2: base error
fmt.Println(errors.Unwrap(errors.Unwrap(err3))) // base error
fmt.Println(errors.Unwrap(err1))     // nil — no more layers

// Traverse the whole error chain manually
func traceError(err error) {
    fmt.Println("=== Error Chain ===")
    for err != nil {
        fmt.Printf("  %T: %v\n", err, err)
        err = errors.Unwrap(err)
    }
}

traceError(err3)
// === Error Chain ===
//   *fmt.wrapError: layer 3: layer 2: base error
//   *fmt.wrapError: layer 2: base error
//   *errors.errorString: base error

errors.Join — Combining Several Errors (Go 1.20+) #

errors.Join is useful when doing validations that produce many errors at once, or when running several parallel operations that can each fail.

import "errors"

// Form validation — collect all errors at once
type RegistrationForm struct {
    Name     string
    Email    string
    Password string
    Age      int
}

func validateForm(form RegistrationForm) error {
    var errs []error

    if form.Name == "" {
        errs = append(errs, errors.New("name must not be empty"))
    } else if len(form.Name) < 2 {
        errs = append(errs, errors.New("name must be at least 2 characters"))
    }

    if form.Email == "" {
        errs = append(errs, errors.New("email must not be empty"))
    } else if !strings.Contains(form.Email, "@") {
        errs = append(errs, errors.New("invalid email format"))
    }

    if len(form.Password) < 8 {
        errs = append(errs, errors.New("password must be at least 8 characters"))
    }

    if form.Age < 18 {
        errs = append(errs, errors.New("age must be at least 18"))
    }

    // errors.Join combines all errors
    // returns nil if the slice is empty
    return errors.Join(errs...)
}

func main() {
    form := RegistrationForm{
        Name:     "A",
        Email:    "not-an-email",
        Password: "123",
        Age:      15,
    }

    if err := validateForm(form); err != nil {
        fmt.Println("Validation failed:")
        fmt.Println(err)
        // Validation failed:
        // name must be at least 2 characters
        // invalid email format
        // password must be at least 8 characters
        // age must be at least 18
    }
}

Running Parallel Operations with errors.Join #

import (
    "errors"
    "sync"
)

func runParallel(tasks []func() error) error {
    var (
        mu   sync.Mutex
        errs []error
        wg   sync.WaitGroup
    )

    for _, t := range tasks {
        wg.Add(1)
        go func(fn func() error) {
            defer wg.Done()
            if err := fn(); err != nil {
                mu.Lock()
                errs = append(errs, err)
                mu.Unlock()
            }
        }(t)
    }

    wg.Wait()
    return errors.Join(errs...)
}

// Usage
err := runParallel([]func() error{
    func() error { return sendEmail("[email protected]") },
    func() error { return sendNotification(123) },
    func() error { return updateCache("key") },
})

if err != nil {
    fmt.Println("Some operations failed:", err)
}

Custom Error Types #

For cases more complex than sentinel errors, you can define your own error type by implementing the error interface.

flowchart TD
    Interface["interface error {\n  Error() string\n}"] --> Basic["errors.New\nFixed message, no data"]
    Interface --> Fmt["fmt.Errorf\nDynamic message, can wrap"]
    Interface --> Custom["Custom struct\nExtra data, custom methods"]

    Custom --> C1["ValidationError\n{Field, Message, Value}"]
    Custom --> C2["HTTPError\n{StatusCode, Message}"]
    Custom --> C3["DBError\n{Query, Params, Underlying}"]
    Custom --> C4["TimeoutError\n{Operation, Duration}"]

    C1 --> When1["Need to know\nwhich field failed"]
    C2 --> When2["Need to know\nthe HTTP status code"]
    C3 --> When3["Need to know\nthe query that failed"]
    C4 --> When4["Need to know\nhow long the timeout was"]

    style Interface fill:#4f86c6,color:#fff
    style Basic fill:#e8f5e9
    style Fmt fill:#e3f2fd
    style Custom fill:#fff3e0
// A custom error type with Unwrap for wrapping compatibility
type DBError struct {
    Operation string
    Query     string
    Err       error // the underlying error
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db error during %s: %v", e.Operation, e.Err)
}

// Unwrap allows errors.Is and errors.As to reach the underlying error
func (e *DBError) Unwrap() error {
    return e.Err
}

// Usage
var ErrDBConnection = errors.New("database connection issue")

func queryDB(query string) error {
    // Simulate a connection error
    return &DBError{
        Operation: "SELECT",
        Query:     query,
        Err:       ErrDBConnection,
    }
}

func main() {
    err := queryDB("SELECT * FROM users WHERE id = 1")

    // errors.As to access DBError details
    var errDB *DBError
    if errors.As(err, &errDB) {
        fmt.Printf("Operation: %s\n", errDB.Operation)
        fmt.Printf("Query: %s\n", errDB.Query)
    }

    // errors.Is can still find ErrDBConnection
    // because DBError implements Unwrap
    if errors.Is(err, ErrDBConnection) {
        fmt.Println("Database connection issue — try reconnecting")
    }
}

Errors with Codes for APIs #

// An error carrying an HTTP status code and an API error code
type AppError struct {
    Code       string // code for the client: "USER_NOT_FOUND", "INVALID_INPUT"
    Message    string // a message that can be shown to the user
    StatusHTTP int    // the matching HTTP status code
    Err        error  // the underlying error (for logging, not sent to the client)
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

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

// Constructors for common errors
func ErrUserNotFound(id int) *AppError {
    return &AppError{
        Code:       "USER_NOT_FOUND",
        Message:    fmt.Sprintf("user with ID %d not found", id),
        StatusHTTP: 404,
    }
}

func ErrInvalidInputApp(field, message string) *AppError {
    return &AppError{
        Code:       "INVALID_INPUT",
        Message:    fmt.Sprintf("field %s: %s", field, message),
        StatusHTTP: 400,
    }
}

// A handler using AppError
func genericHandler(w http.ResponseWriter, r *http.Request, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(appErr.StatusHTTP)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   appErr.Code,
            "message": appErr.Message,
        })
        return
    }

    // An unknown error
    log.Printf("unexpected error: %v", err)
    http.Error(w, "internal server error", 500)
}

Anti-Patterns to Avoid #

// ✗ ANTI-PATTERN 1: Ignore errors
data, _ := os.ReadFile("config.yaml") // data could be nil!
// If ReadFile fails, data is nil and subsequent usage will panic

// ✓ CORRECT: always check errors
data, err := os.ReadFile("config.yaml")
if err != nil {
    return fmt.Errorf("loadConfig: %w", err)
}

// ✗ ANTI-PATTERN 2: Only log the error but keep going
data, err = os.ReadFile("config.yaml")
if err != nil {
    log.Println("error:", err) // logs but doesn't return!
}
// The code below uses data, which might be nil

// ✓ CORRECT: log AND return (or handle properly)
data, err = os.ReadFile("config.yaml")
if err != nil {
    log.Printf("failed to read config: %v", err)
    return err // or use a default, or exit
}

// ✗ ANTI-PATTERN 3: Wrapping without added value
func findUser2(id int) error {
    err := db.Query(id)
    if err != nil {
        return fmt.Errorf("error: %w", err) // "error:" adds no context at all!
    }
    return nil
}

// ✓ CORRECT: wrap with meaningful context
func findUser3(id int) error {
    err := db.Query(id)
    if err != nil {
        return fmt.Errorf("findUser id=%d: %w", id, err) // function name + arguments
    }
    return nil
}

// ✗ ANTI-PATTERN 4: Wrapping an error that already has enough context
func findUser4(id int) (*User, error) {
    u, err := findUser3(id)
    if err != nil {
        // findUser3 already added good context
        // wrapping again only adds noise
        return nil, fmt.Errorf("findUser4 called with id %d and failed because: %w", id, err)
    }
    return u, nil
}

// ✓ CORRECT: wrap briefly or return directly if the context is already enough
func findUser5(id int) (*User, error) {
    u, err := findUser3(id)
    if err != nil {
        return nil, fmt.Errorf("findUser5: %w", err) // concise, not redundant
    }
    return u, nil
}
// ✗ ANTI-PATTERN 5: Use panic for errors that can be handled
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero") // don't panic for predictable conditions!
    }
    return a / b
}

// ✓ CORRECT: return an error for conditions that can occur
func divideGood(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("can't divide by zero")
    }
    return a / b, nil
}

Production Usage Patterns #

Error Registry — Centralized Sentinel Errors #

// errors.go — one file for all sentinel errors in the package
package app

import "errors"

// Authentication errors
var (
    ErrTokenExpired   = errors.New("token expired")
    ErrTokenInvalid   = errors.New("invalid token")
    ErrSessionNotFound = errors.New("session not found")
)

// Data errors
var (
    ErrUserNotFound    = errors.New("user not found")
    ErrProductNotFound = errors.New("product not found")
    ErrOutOfStock      = errors.New("out of stock")
    ErrDuplicateEmail  = errors.New("email already registered")
)

// System errors
var (
    ErrDatabase     = errors.New("database unavailable")
    ErrCache        = errors.New("cache unavailable")
    ErrExternalSvc  = errors.New("external service unavailable")
)

An HTTP Error Handler Middleware #

// Middleware that converts AppError into consistent JSON responses
type ErrorResponse struct {
    Code   string `json:"code"`
    Message string `json:"message"`
    Detail any    `json:"detail,omitempty"`
}

func errorHandler(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Use panic recovery to catch unexpected panics
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(500)
                json.NewEncoder(w).Encode(ErrorResponse{
                    Code:    "INTERNAL_ERROR",
                    Message: "an internal error occurred",
                })
            }
        }()

        next(w, r)
    }
}

func sendError(w http.ResponseWriter, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(appErr.StatusHTTP)
        json.NewEncoder(w).Encode(ErrorResponse{
            Code:    appErr.Code,
            Message: appErr.Message,
        })
        return
    }

    // Check common sentinel errors
    switch {
    case errors.Is(err, ErrUserNotFound):
        w.WriteHeader(404)
        json.NewEncoder(w).Encode(ErrorResponse{
            Code:    "NOT_FOUND",
            Message: "data not found",
        })
    case errors.Is(err, ErrTokenInvalid),
        errors.Is(err, ErrTokenExpired):
        w.WriteHeader(401)
        json.NewEncoder(w).Encode(ErrorResponse{
            Code:    "UNAUTHORIZED",
            Message: "authentication required",
        })
    default:
        log.Printf("unhandled error: %v", err)
        w.WriteHeader(500)
        json.NewEncoder(w).Encode(ErrorResponse{
            Code:    "INTERNAL_ERROR",
            Message: "an internal error occurred",
        })
    }
}

When to Switch to Alternatives #

Keep using the errors package + fmt.Errorf if:
  ✓ Standard error handling in all Go code
  ✓ Defining sentinel errors with errors.New
  ✓ Wrapping errors with context using fmt.Errorf + %w
  ✓ Checking error types with errors.Is and errors.As
  ✓ Combining several errors with errors.Join

Consider panic + recover if:
  ✗ Conditions that truly can't happen (programmer errors)
  ✗ Failed initialization at startup where the program should stop anyway
  ✗ Internal implementations that can't return errors
     (e.g. functions called from interfaces without an error return)

Consider external libraries if:
  ✗ Detailed per-error stack traces → pkg/errors or Go 1.21+ runtime/debug
  ✗ Errors with automatic structured logging → zap or slog with error fields
  ✗ More advanced error aggregation → hashicorp/go-multierror

Summary #

  • Sentinel errors with errors.New are comparable error constants — define them at the package level with ErrXxx names and check them with errors.Is (not ==).
  • fmt.Errorf with %w is the idiomatic way to add context to an error — use %w rather than %v so errors.Is and errors.As keep working through the wrapping chain.
  • Error naming convention: "functionName arguments: message" — include the function name and relevant arguments so error messages are easy to trace without a stack trace.
  • errors.Is traverses the entire wrapping chain to find a matching error — use it for control flow based on error types (404, 401, etc.).
  • errors.As extracts a specifically-typed error from the chain — use it when you need to access the fields or methods of a custom error type.
  • errors.Join (Go 1.20+) combines several errors into one — useful for validations collecting all errors at once or parallel operations.
  • Custom error types with an Unwrap() error method allow errors.Is and errors.As to reach the underlying error — always implement Unwrap if a custom error wraps another error.
  • Don’t ignore errorsdata, _ := ... is almost always wrong. If you deliberately ignore one, add a comment explaining why.
  • Don’t use panic for predictable error conditions — panic is for programmer errors; return errors for runtime errors that can occur.

← Previous: Strconv   Next: Encoding Json →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact