Go - Error Handling

Concept: Error handling is crucial for API design and overall system reliability.

Why it matters: Error handling is part of your API contract — how you surface errors determines whether callers can recover gracefully or are forced to crash.

package main

import (
	"errors"
	"fmt"
)

// error is part of the API contract — callers know exactly what can go wrong
var (
	ErrNotFound   = errors.New("not found")
	ErrUnauthorized = errors.New("unauthorized")
)

type UserService struct{}

func (s UserService) FindUser(id int, adminToken string) (string, error) {
	if adminToken == "" {
		return "", ErrUnauthorized   // caller can check: errors.Is(err, ErrUnauthorized)
	}
	if id <= 0 {
		return "", ErrNotFound       // caller can check: errors.Is(err, ErrNotFound)
	}
	return "Harish", nil
}

func main() {
	svc := UserService{}
	_, err := svc.FindUser(1, "")
	if errors.Is(err, ErrUnauthorized) {
		fmt.Println("redirect to login")
	}
}

Gotcha: Returning fmt.Errorf("not found") instead of a sentinel variable — callers must use string comparison instead of errors.Is, which breaks on message changes.


Concept: The error interface — a single Error() string method is central to error handling in Go.

Why it matters: Because error is an interface, any type with an Error() string method participates in Go's error handling — enabling rich custom error types.

package main

import "fmt"

// error is just an interface — any type implementing Error() string satisfies it
type error interface {
	Error() string
}

// custom type satisfies the error interface
type ParseError struct {
	Line    int
	Column  int
	Message string
}

func (e ParseError) Error() string {
	return fmt.Sprintf("parse error at %d:%d: %s", e.Line, e.Column, e.Message)
}

func parse(input string) error {
	if input == "" {
		return ParseError{Line: 1, Column: 0, Message: "empty input"}
	}
	return nil
}

func main() {
	err := parse("")
	if err != nil {
		fmt.Println(err) // calls Error() automatically
	}
}

Gotcha: Printing err.Error() explicitly — fmt.Println(err) calls Error() automatically; err.Error() is redundant and verbose.


Concept: errors.New() is a factory function that returns an error interface value.

Why it matters: errors.New creates a unique error value — pointer identity guarantees two errors.New("same text") calls produce different errors for errors.Is comparison.

package main

import (
	"errors"
	"fmt"
)

// each call to errors.New creates a UNIQUE error — pointer identity, not string equality
var ErrTimeout = errors.New("timeout")
var ErrTimeout2 = errors.New("timeout") // same text, different pointer

func operation() error {
	return ErrTimeout
}

func main() {
	err := operation()

	fmt.Println(errors.Is(err, ErrTimeout))  // true  — same pointer
	fmt.Println(errors.Is(err, ErrTimeout2)) // false — different pointer, same text

	// errors.New returns *errorString — an unexported concrete type
	fmt.Printf("type: %T\n", ErrTimeout) // *errors.errorString
}

Gotcha: Comparing errors with == after wrapping — fmt.Errorf("%w", ErrTimeout) creates a new error; use errors.Is to unwrap and compare.


Concept: The error variable should be consistently used for error handling within a scope, ensuring a clear "happy path".

Why it matters: The happy path stays flush with the left margin when errors are handled and returned immediately — deep nesting signals poor error flow.

package main

import (
	"errors"
	"fmt"
)

var ErrInvalid = errors.New("invalid")

// wrong: happy path buried in nested ifs
func processWrong(id int, name string) (string, error) {
	if id > 0 {
		if name != "" {
			return fmt.Sprintf("%d:%s", id, name), nil
		} else {
			return "", ErrInvalid
		}
	} else {
		return "", ErrInvalid
	}
}

// right: errors handled and returned early — happy path stays at left margin
func processRight(id int, name string) (string, error) {
	if id <= 0 {
		return "", fmt.Errorf("processRight: %w", ErrInvalid)
	}
	if name == "" {
		return "", fmt.Errorf("processRight: %w", ErrInvalid)
	}
	return fmt.Sprintf("%d:%s", id, name), nil // happy path — clear and linear
}

func main() {
	result, err := processRight(1, "Harish")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

Gotcha: Returning success and an error simultaneously — callers don't know which to trust; return a zero value on error, never a partial result.


Concept: Nil and Error Interface — checking error != nil determines if a concrete value is present within the error interface.

Why it matters: An interface is nil only when both the type word and data word are nil — a typed nil pointer stored in an interface is NOT nil.

package main

import "fmt"

type MyError struct{ code int }
func (e *MyError) Error() string { return fmt.Sprintf("error code %d", e.code) }

// returns error interface — nil is the zero value of the interface (both words nil)
func success() error {
	return nil // interface{nil, nil} — correctly nil
}

// returns *MyError — stored in error interface with type word set
func typed() error {
	var e *MyError // typed nil pointer
	return e       // interface{*MyError, nil} — NOT nil! type word is set
}

func main() {
	err1 := success()
	fmt.Println("success nil?", err1 == nil)  // true

	err2 := typed()
	fmt.Println("typed nil? ", err2 == nil)   // false — the interface has a type word
	fmt.Println("err2:", err2)                // <nil> printed by Error()
}

Gotcha: Returning a concrete error type from a function — always use error as the return type, never *MyError;


Concept: Error Variables for Context — when functions can return multiple error types, error variables distinguish between them.

Why it matters: Sentinel error variables give callers the ability to branch on specific error conditions without resorting to string parsing.

package main

import (
	"errors"
	"fmt"
	"net/http"
)

var (
	ErrBadRequest = errors.New("bad request")      // 400
	ErrPageMoved  = errors.New("page moved")        // 301
	ErrNotFound   = errors.New("not found")         // 404
	ErrServerErr  = errors.New("server error")      // 5xx
)

func checkStatus(code int) error {
	switch {
	case code == 400: return fmt.Errorf("checkStatus: %w", ErrBadRequest)
	case code == 301: return fmt.Errorf("checkStatus: %w", ErrPageMoved)
	case code == 404: return fmt.Errorf("checkStatus: %w", ErrNotFound)
	case code >= 500: return fmt.Errorf("checkStatus: %w", ErrServerErr)
	}
	return nil
}

func handleResponse(code int) {
	err := checkStatus(code)
	switch {
	case errors.Is(err, ErrBadRequest):
		fmt.Println("fix the request")
	case errors.Is(err, ErrPageMoved):
		fmt.Println("follow redirect")
	case errors.Is(err, ErrNotFound):
		fmt.Println("resource gone")
	case errors.Is(err, ErrServerErr):
		fmt.Println("retry later")
	case err == nil:
		fmt.Println("ok:", code)
	}
}

func main() {
	handleResponse(http.StatusOK)
	handleResponse(http.StatusNotFound)
	handleResponse(http.StatusInternalServerError)
}

Gotcha: Using switch err (value equality) instead of switch { case errors.Is(err, ...) } — value equality fails when errors are wrapped.


Concept: Naming Convention — error variables use the err prefix followed by a descriptive name; defined at package or file level.

Why it matters: Consistent naming lets readers immediately identify sentinel errors and understand their semantic without reading the definition.

package main

import (
	"errors"
	"fmt"
)

// package-level sentinel errors: err prefix + descriptive PascalCase name
var (
	ErrConnectionRefused = errors.New("connection refused")
	ErrAuthFailed        = errors.New("authentication failed")
	ErrRateLimited       = errors.New("rate limited")
	ErrDataCorrupted     = errors.New("data corrupted")
)

// unexported sentinel: only relevant within this package
var errRetryExhausted = errors.New("retry exhausted")

func connect(host string, retries int) error {
	if retries == 0 {
		return fmt.Errorf("connect %s: %w", host, errRetryExhausted)
	}
	return fmt.Errorf("connect %s: %w", host, ErrConnectionRefused)
}

func main() {
	err := connect("db.internal", 0)
	if errors.Is(err, errRetryExhausted) {
		fmt.Println("no more retries")
	}
}

Gotcha: Using lowercase err prefix for exported errors (errNotFound) — exported sentinel errors must start with Err (uppercase E) to be accessible from other packages.


Concept: Switch Statement for Handling — a switch statement effectively handles error variables by comparing the returned error.

Why it matters: A switch-based error handler is exhaustive, readable, and requires no nesting — each case handles exactly one error condition.

package main

import (
	"errors"
	"fmt"
	"net/http"
)

var (
	ErrNotFound   = errors.New("not found")
	ErrForbidden  = errors.New("forbidden")
	ErrTimeout    = errors.New("timeout")
)

func httpStatus(err error) int {
	// switch on behavior — each case maps one error to one HTTP status
	switch {
	case errors.Is(err, ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, ErrForbidden):
		return http.StatusForbidden
	case errors.Is(err, ErrTimeout):
		return http.StatusGatewayTimeout
	case err == nil:
		return http.StatusOK
	default:
		return http.StatusInternalServerError
	}
}

func main() {
	errs := []error{nil, ErrNotFound, ErrForbidden, ErrTimeout, fmt.Errorf("unknown")}
	for _, err := range errs {
		fmt.Printf("err=%-20v  status=%d\n", err, httpStatus(err))
	}
}

Gotcha: Using switch err (value switch) instead of switch { case errors.Is(...) } — value switch breaks on wrapped errors.


Concept: Custom Error Types — when built-in errorString lacks sufficient context, custom error types encapsulate richer information.

Why it matters: Custom error types carry structured data (field name, HTTP status, retry-after) that callers can extract and act on — strings can't provide this.

package main

import (
	"errors"
	"fmt"
)

// custom error type: carries structured data beyond a plain string
type HTTPError struct {
	StatusCode int
	Method     string
	URL        string
}

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

func fetch(url string) error {
	if url == "" {
		return &HTTPError{StatusCode: 400, Method: "GET", URL: url}
	}
	return &HTTPError{StatusCode: 503, Method: "GET", URL: url}
}

func main() {
	err := fetch("")

	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		fmt.Println("status:", httpErr.StatusCode)
		fmt.Println("url:   ", httpErr.URL)
		if httpErr.StatusCode >= 500 {
			fmt.Println("action: retry with backoff")
		}
	}
}

Gotcha: Using errors.Is instead of errors.As to extract fields from a custom error — errors.Is checks identity, errors.As extracts the typed value.


Concept: error Interface Implementation — custom error types must implement Error() string to be compatible with Go's error handling.

Why it matters: Implementing the interface correctly ensures your custom error works seamlessly with fmt, log, errors.Is, errors.As, and all error handling code.

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field   string
	Value   interface{}
	Message string
}

// Error() must return a human-readable string — used by fmt, log, and error wrappers
func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation: field=%q value=%v: %s", e.Field, e.Value, e.Message)
}

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

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

	// errors.As unwraps and assigns to the target type
	var ve *ValidationError
	if errors.As(err, &ve) {
		fmt.Printf("invalid field: %s (got %v)\n", ve.Field, ve.Value)
	}

	fmt.Println(err) // calls Error() — prints the message
}

Gotcha: Implementing Error() with a value receiver on a large struct — use a pointer receiver to avoid copying the struct on every error string call.


Concept: Type Assertion and Switch Statement — errors.As and type switch enable conditional logic based on error type.

Why it matters: Type-based error handling lets callers extract richer context from errors — but it tightly couples caller code to the producer's error types.

package main

import (
	"errors"
	"fmt"
)

type NotFoundError struct{ Resource string }
type PermissionError struct{ User string }

func (e *NotFoundError) Error() string  { return e.Resource + " not found" }
func (e *PermissionError) Error() string { return e.User + " lacks permission" }

func getResource(id int, user string) error {
	if user == "guest" {
		return &PermissionError{User: user}
	}
	if id > 100 {
		return &NotFoundError{Resource: fmt.Sprintf("item-%d", id)}
	}
	return nil
}

func main() {
	err := getResource(200, "admin")

	// errors.As: unwraps error chain and assigns to typed target
	var notFound *NotFoundError
	var permErr *PermissionError

	switch {
	case errors.As(err, &notFound):
		fmt.Printf("404: %s\n", notFound.Resource)
	case errors.As(err, &permErr):
		fmt.Printf("403: %s\n", permErr.User)
	case err == nil:
		fmt.Println("200: ok")
	default:
		fmt.Println("500:", err)
	}
}

Gotcha: Using a type switch on errors with case *MyError: instead of errors.As — type switch doesn't unwrap wrapped errors in the chain.


Concept: Drawbacks of Coupling — over-reliance on "type as context" couples error handling logic to concrete error types.

Why it matters: When callers check for *PostgresError, swapping PostgreSQL for MySQL means changing every caller — behavior-as-context avoids this.

package main

import (
	"errors"
	"fmt"
)

// tightly coupled: caller imports and checks a concrete DB error type
type PostgresError struct {
	Code    string
	Message string
}
func (e *PostgresError) Error() string { return fmt.Sprintf("pg[%s]: %s", e.Code, e.Message) }

// loosely coupled: caller checks behavior — works for any database error
type Retryable interface {
	Retryable() bool
}

// now both Postgres and MySQL errors can implement Retryable independently
type DBError struct {
	msg       string
	retryable bool
}
func (e *DBError) Error() string    { return e.msg }
func (e *DBError) Retryable() bool  { return e.retryable }

func queryDB() error {
	return &DBError{msg: "connection reset", retryable: true}
}

func main() {
	err := queryDB()
	var r Retryable
	if errors.As(err, &r) && r.Retryable() {
		fmt.Println("retry the query")
	}
}

Gotcha: Defining Retryable interface in the error producer package — move it to the consumer's package so the producer doesn't need to import the consumer.


Concept: "Behavior as context" — focus on the actions an error value can perform rather than its specific type.

Why it matters: Behavior-based error handling decouples caller from implementation — adding a new database driver requires zero changes in error handling code.

package main

import (
	"errors"
	"fmt"
)

// behavior interfaces — defined by the caller, not the error producer
type Temporary interface{ Temporary() bool }
type Timeout interface{ Timeout() bool }
type HasStatusCode interface{ StatusCode() int }

// error that implements multiple behaviors
type APIError struct {
	statusCode int
	message    string
	temporary  bool
}

func (e *APIError) Error() string      { return fmt.Sprintf("API %d: %s", e.statusCode, e.message) }
func (e *APIError) StatusCode() int    { return e.statusCode }
func (e *APIError) Temporary() bool    { return e.temporary }

func callAPI(endpoint string) error {
	return &APIError{statusCode: 503, message: "service unavailable", temporary: true}
}

func handleAPIError(err error) {
	if t, ok := err.(Temporary); ok && t.Temporary() {
		fmt.Println("temporary — schedule retry")
	}
	var hasCode HasStatusCode
	if errors.As(err, &hasCode) {
		fmt.Println("status code:", hasCode.StatusCode())
	}
}

func main() {
	err := callAPI("/api/users")
	if err != nil {
		handleAPIError(err)
	}
}

Gotcha: Defining behavior interfaces in the error producer — they belong in the consumer's package to achieve true decoupling.


Concept: Interfaces for Behavior Definition — an interface like Temporary declares a method indicating transient errors.

Why it matters: A Temporary() interface allows callers to implement retry logic that works for network errors, DB errors, and any future error type without code changes.

package main

import (
	"fmt"
	"time"
)

type TemporaryError interface {
	error
	Temporary() bool
}

func withRetry(maxAttempts int, fn func() error) error {
	var err error
	for i := 0; i < maxAttempts; i++ {
		err = fn()
		if err == nil {
			return nil
		}
		// retry only if the error signals it is temporary
		if te, ok := err.(TemporaryError); !ok || !te.Temporary() {
			return err // permanent error — don't retry
		}
		fmt.Printf("attempt %d failed (temporary), retrying...\n", i+1)
		time.Sleep(time.Duration(i+1) * 10 * time.Millisecond)
	}
	return fmt.Errorf("withRetry: exhausted %d attempts: %w", maxAttempts, err)
}

type flakeyError struct{ attempt int }

func (e *flakeyError) Error() string   { return fmt.Sprintf("flakey on attempt %d", e.attempt) }
func (e *flakeyError) Temporary() bool { return e.attempt < 3 }

func main() {
	attempt := 0
	err := withRetry(5, func() error {
		attempt++
		if attempt < 3 {
			return &flakeyError{attempt: attempt}
		}
		return nil
	})
	fmt.Println("result:", err)
}

Gotcha: Implementing Temporary() on every error type even when the distinction isn't meaningful — the interface only adds value when the caller genuinely branches on it.


Concept: Decoupling and Flexibility — behavior-as-context allows modifications to error types without rippling effects on error handling code.

Why it matters: Error types that change without breaking callers allow library evolution — the caller's code remains valid even when the library rewrites its error types.

package main

import (
	"errors"
	"fmt"
)

// behavior interface in consumer package — stable across library changes
type Statusable interface {
	HTTPStatus() int
}

// v1 error type
type v1Error struct{ status int }
func (e *v1Error) Error() string    { return fmt.Sprintf("v1 error %d", e.status) }
func (e *v1Error) HTTPStatus() int  { return e.status }

// v2 error type — completely rewritten, still satisfies Statusable
type v2Error struct {
	code    int
	message string
	source  string
}
func (e *v2Error) Error() string    { return fmt.Sprintf("[%s] %s (%d)", e.source, e.message, e.code) }
func (e *v2Error) HTTPStatus() int  { return e.code }

// caller's code unchanged — works with both v1Error and v2Error
func respondWithStatus(err error, w func(int, string)) {
	var s Statusable
	if errors.As(err, &s) {
		w(s.HTTPStatus(), err.Error())
		return
	}
	w(500, "internal error")
}

func main() {
	respond := func(code int, msg string) { fmt.Printf("HTTP %d: %s\n", code, msg) }
	respondWithStatus(&v1Error{status: 404}, respond)
	respondWithStatus(&v2Error{code: 403, message: "forbidden", source: "auth"}, respond)
}

Gotcha: Storing the error producer's concrete type in a struct field — when the library updates the type, the consuming struct must be updated too.


Concept: Functions should always return the error interface type for errors, not concrete error types.

Why it matters: Returning a concrete type causes the nil-interface bug — a nil concrete pointer stored in an error interface is non-nil, silently bypassing if err != nil.

package main

import "fmt"

type AppError struct{ msg string }
func (e *AppError) Error() string { return e.msg }

// BUG: returns *AppError — nil *AppError stored in error interface is NOT nil
func buggyOp(fail bool) *AppError {
	if fail {
		return &AppError{"something failed"}
	}
	return nil
}

// FIX: return error interface — nil interface is truly nil
func safeOp(fail bool) error {
	if fail {
		return &AppError{"something failed"}
	}
	return nil
}

func main() {
	// bug demonstration
	var err error = buggyOp(false)   // *AppError(nil) stored in error interface
	if err != nil {
		fmt.Println("BUG: error branch taken even though no error occurred")
	}

	// fix
	err = safeOp(false)              // error interface is nil — both words nil
	if err != nil {
		fmt.Println("this won't print")
	} else {
		fmt.Println("FIX: correctly nil")
	}
}

Gotcha: Thinking return nil is safe when the return type is a concrete error type — the caller receives a non-nil interface; always return error.


Concept: nil Type Ambiguity — in Go, nil is not type-specific; it assumes the type of the context.

Why it matters: nil inside an error interface has a type — the interface value is non-nil even though the pointed-to value is nil.

package main

import "fmt"

type DBError struct{ query string }
func (e *DBError) Error() string { return "db error: " + e.query }

func runQuery(q string) error {
	var err *DBError = nil  // typed nil: *DBError with nil pointer
	if q == "" {
		err = &DBError{query: q}
	}
	// WRONG: returning err stores (*DBError, nil) in the error interface — non-nil!
	// return err

	// RIGHT: explicitly return nil or the error
	if err != nil {
		return err
	}
	return nil // returns (nil, nil) interface — correctly nil
}

func main() {
	err := runQuery("SELECT 1")
	fmt.Println("err is nil:", err == nil) // true — correct
}

Gotcha: Declaring var err *MyError and then returning it directly — always explicitly return nil (not the typed nil variable) when there's no error.


Concept: Zero Values and Non-Nil Interfaces — a concrete error type set to nil results in a non-nil error interface.

Why it matters: This is one of the most common Go bugs — experienced developers still hit it; understanding the two-word interface structure is the only reliable prevention.

package main

import "fmt"

// demonstrates the interface two-word structure
// word 1: type pointer (set when any concrete type is assigned, even nil pointer)
// word 2: data pointer (nil when concrete value is nil)

type IOError struct{ path string }
func (e *IOError) Error() string { return "io error: " + e.path }

func readFile(path string, shouldFail bool) error {
	var e *IOError // e is nil, but it is *IOError-typed nil
	if shouldFail {
		e = &IOError{path: path}
	}

	// this is the bug: returning e stores the type word — interface is non-nil
	// return e // DO NOT DO THIS

	// correct: evaluate the concrete value before returning
	if e != nil {
		return e
	}
	return nil
}

func main() {
	err := readFile("/etc/hosts", false)
	if err != nil {
		fmt.Println("BUG: shouldn't get here")
	} else {
		fmt.Println("CORRECT: no error")
	}
}

Gotcha: Fixing the bug by returning (*IOError)(nil) — that's still a typed nil in an interface; the only fix is returning the untyped nil.


Concept: Wrapping errors — embedding the original error within a new error with additional contextual information.

Why it matters: Wrapping preserves the original cause while adding call-site context — the entire error chain can be inspected at the top level without re-logging at every layer.

package main

import (
	"errors"
	"fmt"
)

var ErrDatabase = errors.New("database unavailable")

func queryUsers() error {
	return fmt.Errorf("queryUsers: %w", ErrDatabase) // wrap: add function context
}

func loadDashboard() error {
	if err := queryUsers(); err != nil {
		return fmt.Errorf("loadDashboard: %w", err) // wrap again: add layer context
	}
	return nil
}

func main() {
	err := loadDashboard()

	fmt.Println(err)
	// loadDashboard: queryUsers: database unavailable

	// errors.Is unwraps the entire chain
	fmt.Println(errors.Is(err, ErrDatabase)) // true

	// errors.Unwrap peels one layer at a time
	fmt.Println(errors.Unwrap(err))          // queryUsers: database unavailable
	fmt.Println(errors.Unwrap(errors.Unwrap(err))) // database unavailable
}

Gotcha: Wrapping errors in a loop — you get wrap: wrap: wrap: wrap: base which makes the chain unreadable; wrap once per logical layer, not per iteration.


Concept: Avoiding Redundant Logging — by wrapping errors, redundant logging at multiple levels is avoided.

Why it matters: Logging the same error at every layer produces duplicate entries in logs — wrap and propagate up, log once at the top.

package main

import (
	"errors"
	"fmt"
)

var ErrDBConn = errors.New("db connection failed")

// wrong: logs at every layer — same error appears 3 times in logs
func dbQueryWrong() error { return ErrDBConn }
func serviceWrong() error {
	err := dbQueryWrong()
	if err != nil {
		fmt.Println("LOG service:", err)  // duplicate log
		return fmt.Errorf("service: %w", err)
	}
	return nil
}
func handlerWrong() error {
	err := serviceWrong()
	if err != nil {
		fmt.Println("LOG handler:", err) // duplicate log
		return err
	}
	return nil
}

// right: wrap and propagate — log ONCE at the boundary
func dbQuery() error { return ErrDBConn }
func service() error {
	if err := dbQuery(); err != nil {
		return fmt.Errorf("service: %w", err) // wrap, don't log
	}
	return nil
}
func handler() error {
	if err := service(); err != nil {
		return fmt.Errorf("handler: %w", err) // wrap, don't log
	}
	return nil
}

func main() {
	if err := handler(); err != nil {
		fmt.Println("LOG once at top:", err) // single log with full context
		fmt.Println("is db error:", errors.Is(err, ErrDBConn)) // true
	}
}

Gotcha: Not logging the error anywhere because "wrapping propagates it" — wrap at each layer, log at the program boundary (main or HTTP handler).


Concept: fmt.Errorf with %w wraps an error; errors.Is and errors.As unwrap the chain.

Why it matters: %w is the standard mechanism for error wrapping since Go 1.13 — it integrates with the entire errors package for chain inspection.

package main

import (
	"errors"
	"fmt"
)

type ResourceError struct {
	Resource string
	Code     int
}

func (e *ResourceError) Error() string {
	return fmt.Sprintf("resource %s: code %d", e.Resource, e.Code)
}

var ErrNotFound = errors.New("not found")

func fetchResource(id string) error {
	return fmt.Errorf("fetchResource %s: %w", id,
		&ResourceError{Resource: id, Code: 404})
}

func loadPage(id string) error {
	if err := fetchResource(id); err != nil {
		return fmt.Errorf("loadPage: %w", err)
	}
	return nil
}

func main() {
	err := loadPage("widget-99")

	// errors.As: finds and assigns *ResourceError anywhere in the chain
	var resErr *ResourceError
	if errors.As(err, &resErr) {
		fmt.Printf("resource=%s code=%d\n", resErr.Resource, resErr.Code)
	}

	// full chain printed
	fmt.Println(err)
}

Gotcha: Using %v instead of %w in fmt.Errorf%v embeds the error message as a string, breaking errors.Is and errors.As chain traversal.


Concept: Dave Cheney's errors package provides Wrap and CauseWrap adds context, Cause retrieves the root cause.

Why it matters: Before Go 1.13, github.com/pkg/errors was the standard for wrapping with stack traces — understanding it explains patterns in older codebases.

package main

import (
	"fmt"

	// demonstrates the pattern; stdlib equivalent since Go 1.13
	// import "github.com/pkg/errors" in real code
)

// stdlib equivalent of pkg/errors pattern using Go 1.13 wrapping
var ErrDiskFull = fmt.Errorf("disk full")

func writeLog(data string) error {
	return fmt.Errorf("writeLog: %w", ErrDiskFull) // pkg/errors: errors.Wrap(ErrDiskFull, "writeLog")
}

func flushBuffer(buf string) error {
	if err := writeLog(buf); err != nil {
		return fmt.Errorf("flushBuffer: %w", err) // pkg/errors: errors.Wrap(err, "flushBuffer")
	}
	return nil
}

func causeOf(err error) error {
	// pkg/errors.Cause() equivalent: unwrap until no more Unwrap
	for {
		unwrapped := fmt.Errorf("%w", err) // illustrative — use errors.Unwrap in practice
		_ = unwrapped
		break
	}
	return err
}

func main() {
	err := flushBuffer("log data")
	fmt.Println("full chain:", err)
	// pkg/errors: fmt.Println("root cause:", errors.Cause(err))
	fmt.Println("is disk full:", fmt.Sprintf("%s", err) != "")
}

Gotcha: Mixing pkg/errors.Wrap and fmt.Errorf %w in the same codebase — errors.Cause and errors.Unwrap have different traversal semantics.


Concept: Wrapped errors can include call context (line number, function name) and user-defined context.

Why it matters: Adding both automatic call context (from runtime.Callers) and semantic context gives operators everything needed to diagnose a production issue.

package main

import (
	"errors"
	"fmt"
	"runtime"
)

// user-defined context: semantic meaning added at wrap site
func withContext(err error, ctx string) error {
	return fmt.Errorf("%s: %w", ctx, err)
}

// call context: automatic location from the runtime
func callerInfo() string {
	_, file, line, ok := runtime.Caller(1)
	if !ok {
		return "unknown"
	}
	return fmt.Sprintf("%s:%d", file, line)
}

var ErrPayment = errors.New("payment declined")

func chargeCard(amount float64) error {
	return fmt.Errorf("%s: %.2f: %w", callerInfo(), amount, ErrPayment)
}

func processOrder(orderID string) error {
	if err := chargeCard(99.99); err != nil {
		return withContext(err, fmt.Sprintf("processOrder[%s]", orderID))
	}
	return nil
}

func main() {
	err := processOrder("ORD-001")
	fmt.Println(err)
	fmt.Println("is payment error:", errors.Is(err, ErrPayment))
}

Gotcha: Manually including file:line in every error message — use a structured logging library that captures call context automatically instead.