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, ¬Found):
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 Cause — Wrap 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.