Go (Golang) — Context and Error Handling: A Deep, Comprehensive Guide
The context package and error handling — two of the most important and most misunderstood topics in idiomatic Go.
This document covers two of the most important — and most misunderstood — topics in idiomatic Go programming: the
contextpackage and error handling. It is written for developers who already know basic Go syntax and want to master these two areas deeply, including internals, patterns, pitfalls, and real-world examples.
Table of Contents
- Part I — The
contextPackage- 1. Why Context Exists
- 2. The
ContextInterface - 3. The Root Contexts:
context.Background()andcontext.TODO() - 4. Cancellation:
context.WithCancel - 5. Timeouts and Deadlines
- 6. Passing Values:
context.WithValue - 7. Context Propagation Rules
- 8. Context Trees and How Cancellation Propagates
- 9. Common Pitfalls with Context
- 10. Context in HTTP Servers and Clients
- 11. Context with Goroutines and
errgroup - 12. Best Practices Checklist for Context
- Part II — Error Handling in Go
- 1. Philosophy: Errors Are Values
- 2. The
errorInterface - 3. Creating Errors:
errors.Newandfmt.Errorf - 4. Sentinel Errors
- 5. Custom Error Types
- 6. Error Wrapping (
%w) and Unwrapping - 7.
errors.Isvserrors.As - 8. Multi-Error Handling:
errors.Join - 9. Panic and Recover
- 10. Error Handling Patterns in Practice
- 11. Logging vs Returning Errors
- 12. Common Anti-Patterns
- 13. Best Practices Checklist for Errors
- Part III — Combining Context and Errors
- Appendix: Quick Reference Tables
Part I — The context Package
1. Why Context Exists
Before Go 1.7 (when context was moved into the standard library from golang.org/x/net/context), there was no standard way to:
- Cancel long-running operations (e.g., abandon a database query if the client disconnected).
- Propagate deadlines/timeouts through a call chain spanning multiple function calls, goroutines, and even network boundaries (RPC).
- Carry request-scoped values (like trace IDs) across API boundaries without changing every function signature.
Every team invented its own ad-hoc solution: a done chan struct{}, a time.Timer, custom Cancellable interfaces, etc. This fragmentation made libraries incompatible with each other. context.Context unified this into one interface that every layer of a Go program — HTTP handlers, gRPC services, database drivers, goroutines — can understand and cooperate with.
The core idea: Context is not about data first; it’s about cancellation signaling first, and deadline propagation second. Carrying values is a distant third and is often overused.
2. The Context Interface
type Context interface {
// Deadline returns the time when work done on behalf of this context
// should be canceled. Deadline returns ok==false when no deadline is set.
Deadline() (deadline time.Time, ok bool)
// Done returns a channel that's closed when work done on behalf of this
// context should be canceled. Done may return nil if this context can
// never be canceled.
Done() <-chan struct{}
// Err returns a non-nil error explaining why Done was closed.
// If Done is not yet closed, Err returns nil.
// If Done is closed, Err returns Canceled or DeadlineExceeded.
Err() error
// Value returns the value associated with this context for key,
// or nil if none.
Value(key any) any
}
Key observations:
Contextis an interface, not a struct. Different implementations exist (emptyCtx,cancelCtx,timerCtx,valueCtx).- It is designed to be read-only and immutable from the consumer’s point of view. You never mutate a context; you always derive a new one from a parent.
Done()returns a channel, not a boolean, so it can be used directly inside aselectstatement — this is idiomatic Go concurrency.Err()tells you why the context was cancelled: eithercontext.Canceled(explicit cancellation) orcontext.DeadlineExceeded(timeout/deadline hit).
Why a channel and not a callback?
Go favors composability via channels over callback registration. A <-chan struct{} can be selected alongside other channels (I/O, timers, other done signals) without any special-casing. This is a deliberate design choice that keeps context orthogonal to the rest of Go’s concurrency primitives.
3. The Root Contexts
ctx1 := context.Background()
ctx2 := context.TODO()
context.Background(): The root of every context tree. It’s never cancelled, has no deadline, and holds no values. Used inmain(), in tests, and as the top-level context when initializing a request or a long-running process.context.TODO(): Semantically identical toBackground()at runtime, but signals intent: “I know this function should take a context, but I haven’t wired it through yet, or I’m not sure which context to use.” It’s a marker for refactoring — grep-able and meant to be temporary.
Rule of thumb: If you’re not sure which context to pass and there truly is no parent context, use TODO(). If you’re deliberately creating a top-level context (e.g., in main), use Background().
4. Cancellation: context.WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
WithCancel derives a child context from a parent. It returns a new context and a cancel function. Calling cancel():
- Closes the
Done()channel of the returned context. - Sets
Err()tocontext.Canceled. - Propagates cancellation to all descendant contexts derived from it.
- Removes the child from the parent’s tracking structures (to avoid memory leaks).
Example
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
return
default:
// do some unit of work
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
time.Sleep(1 * time.Second)
cancel() // signal all workers to stop
time.Sleep(500 * time.Millisecond)
}
Critical rule: always call cancel()
Even if the context finishes “naturally” (e.g., the operation completes successfully), you must call cancel() to release resources associated with the context — most importantly, to allow the Go runtime to garbage-collect the internal state and to stop any associated timer (in the case of WithTimeout/WithDeadline). The idiomatic pattern is:
ctx, cancel := context.WithCancel(parent)
defer cancel()
Forgetting this is one of the most common sources of goroutine and memory leaks in production Go services.
5. Timeouts and Deadlines
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
WithTimeout(parent, d)is literally implemented asWithDeadline(parent, time.Now().Add(d)).WithDeadlinecancels the context automatically when the wall-clock time reachesd, or whencancel()is called manually, or when the parent is cancelled — whichever happens first.- When the deadline is reached,
ctx.Err()returnscontext.DeadlineExceeded.
Example: HTTP call with timeout
func fetchData(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
// If the timeout fired, err will wrap context.DeadlineExceeded
return nil, fmt.Errorf("performing request: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Deadline shrinking, never growing
An important, often-missed rule: a child context’s effective deadline can never be later than its parent’s. If a parent has a 5-second deadline and you call WithTimeout(parent, 10*time.Second), the resulting context will still respect the parent’s 5-second deadline. Go automatically takes the earlier of the two.
parent, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 10*time.Second)
defer cancel2()
// child will still be cancelled after ~5 seconds, not 10.
6. Passing Values: context.WithValue
func WithValue(parent Context, key, val any) Context
This allows attaching request-scoped data — things like a request ID, an authenticated user, or a trace span — to a context so it can be read further down the call chain without changing every function signature.
The right way to use keys
Never use plain strings or built-in types as keys — they can collide across packages. Define an unexported custom type:
package requestctx
type ctxKey int
const (
userIDKey ctxKey = iota
requestIDKey
)
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
func UserID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(userIDKey).(string)
return id, ok
}
This pattern:
- Prevents collisions with keys defined in other packages (since
ctxKeyis unexported and package-scoped). - Provides type-safe accessor functions instead of forcing callers to do raw type assertions everywhere.
- Keeps the “key namespace” private to the owning package.
What WithValue should — and should NOT — be used for
Appropriate uses:
- Request-scoped metadata: trace/span IDs, request IDs, deadlines-related data.
- Cross-cutting concerns that must flow through layers you don’t control (e.g., middleware in an HTTP framework attaching an authenticated user).
Inappropriate uses (a very common anti-pattern):
- Passing optional function parameters via context (“context as a God object”).
- Passing a
*sql.DB, a logger, or other dependencies that should be explicit constructor/function arguments. - Passing business-logic data that functions actually operate on (that data should be an explicit parameter, for readability and type safety).
Rule of thumb from the Go team: “Use context Values only for request-scoped data that transits process and API boundaries, not for passing optional parameters to functions.”
7. Context Propagation Rules
The idiomatic conventions (documented directly in the context package docs) are:
- Context should be the first parameter of a function, conventionally named
ctx:func DoSomething(ctx context.Context, arg Arg) error - Never store a Context inside a struct. Instead, pass it explicitly to each method that needs it. (Rare exceptions exist, e.g., in generated code or where the struct itself represents a single request lifecycle — but this remains an exception, not the rule.)
- Never pass
nilas a Context, even if a function permits it. Usecontext.TODO()if you’re unsure of which context to use. - The context passed to a function should be the first thing checked in cases where you’re managing complex cancellation logic, but you don’t need to check
ctx.Err()at the top of every trivial function — it’s mainly relevant before starting expensive work. - A
Contextis safe for concurrent use by multiple goroutines simultaneously. You may pass the same context to many goroutines.
8. Context Trees and How Cancellation Propagates
Every call to WithCancel, WithTimeout, WithDeadline, or WithValue produces a child context that holds a reference to its parent. Internally, cancellable contexts (cancelCtx) register themselves with their parent so that:
- If the parent is cancelled, all descendants are cancelled too (cascading downward).
- If a child is cancelled, only that child and its descendants are cancelled — siblings and the parent are unaffected.
Background()
└── WithCancel (A)
├── WithTimeout (B)
│ └── WithValue (C)
└── WithCancel (D)
If A is cancelled → B, C, and D are all cancelled.
If D is cancelled → only D is cancelled; A, B, C remain unaffected.
This tree structure is what makes context so powerful for coordinating shutdown across an entire subsystem: cancel one context at the root of a request, and every goroutine, every downstream RPC call, and every DB query tied to that request can observe the cancellation and unwind cleanly.
9. Common Pitfalls with Context
Pitfall 1: Forgetting to call cancel()
// BAD — leaks the internal timer and context state
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
// GOOD
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Pitfall 2: Storing Context in a struct field
// BAD
type Service struct {
ctx context.Context
}
// GOOD — pass ctx explicitly per call
type Service struct{}
func (s *Service) DoWork(ctx context.Context) error { ... }
Pitfall 3: Using context values for essential parameters
// BAD — hides an important parameter, no compiler safety
ctx = context.WithValue(ctx, "userID", id)
func Process(ctx context.Context) { ... } // userID buried inside ctx
// GOOD
func Process(ctx context.Context, userID string) { ... }
Pitfall 4: Not respecting ctx.Done() in long loops
// BAD — ignores cancellation entirely
for i := 0; i < 1_000_000; i++ {
heavyWork(i)
}
// GOOD
for i := 0; i < 1_000_000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
heavyWork(i)
}
Pitfall 5: Using string keys instead of typed unexported keys
Leads to accidental collisions between unrelated packages using the same string key like "userID".
Pitfall 6: Creating a new Background() instead of deriving from the incoming context
// BAD — breaks the entire cancellation chain and drops trace/values
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() // wrong! should use r.Context()
doWork(ctx)
}
// GOOD
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
doWork(ctx)
}
Pitfall 7: Leaking goroutines that never observe ctx.Done()
If a goroutine performs a blocking operation (e.g., reading from a channel that’s never written to) and never selects on ctx.Done(), it can leak forever, even after the operation is logically “abandoned.”
10. Context in HTTP Servers and Clients
Server side
Every incoming *http.Request automatically carries a context, accessible via r.Context(). This context is cancelled automatically by the net/http server when:
- The client disconnects.
- The
ResponseWriter’s underlying connection closes. - (In HTTP/2) the stream is cancelled.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result, err := doExpensiveQuery(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// client went away; typically nothing to write back
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
You can also derive a stricter timeout from the request’s context using middleware:
func withTimeout(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Client side
http.NewRequestWithContext attaches a context to an outgoing request. If the context is cancelled or its deadline passes, the in-flight HTTP request is aborted and http.Client.Do returns an error wrapping the context’s error.
11. Context with Goroutines and errgroup
A very common pattern: fan out several goroutines, and if any one fails, cancel the rest. The golang.org/x/sync/errgroup package builds this on top of context:
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([][]byte, len(urls))
for i, url := range urls {
i, url := i, url // capture loop variables (pre-Go 1.22 idiom)
g.Go(func() error {
data, err := fetchData(ctx, url)
if err != nil {
return err // cancels ctx for all other goroutines in the group
}
results[i] = data
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
Here, errgroup.WithContext returns a derived context that is automatically cancelled the moment any goroutine returns a non-nil error — every other in-flight goroutine can observe this via ctx.Done() and abandon its work early, saving resources.
12. Best Practices Checklist for Context
- ✅ Always accept
context.Contextas the first parameter, namedctx. - ✅ Always call the
cancelfunction returned byWithCancel/WithTimeout/WithDeadline, typically viadefer. - ✅ Derive contexts from the incoming context (
r.Context(), parent ctx) — never fabricate a freshBackground()mid-chain. - ✅ Use unexported custom key types for
WithValue. - ✅ Reserve
WithValuefor cross-cutting, request-scoped metadata only. - ✅ Check
ctx.Done()inside long loops or before/after expensive operations. - ✅ Never store a
Contextin a struct field (pass explicitly). - ✅ Never pass
nilcontext — usecontext.TODO(). - ✅ Remember: a context is immutable — deriving a new context does not modify the parent.
- ✅ Treat
context.Canceledandcontext.DeadlineExceededas expected, “normal” outcomes to be handled gracefully, not necessarily logged as errors.
Part II — Error Handling in Go
1. Philosophy: Errors Are Values
Go deliberately does not use exceptions for regular error handling (it has panic/recover, but that’s reserved for truly exceptional, unrecoverable situations — see below). Instead, Go treats errors as ordinary values returned from functions, most idiomatically as the last return value:
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
The famous quote from Rob Pike (co-creator of Go): “Errors are values, and they can be programmed.” This means you can:
- Wrap, inspect, compare, and transform errors just like any other value.
- Build custom logic around them (retry policies, error classification, etc.) using ordinary Go control flow — no special exception-handling syntax needed.
This forces the caller to explicitly acknowledge and handle each possible failure point:
result, err := Divide(10, 0)
if err != nil {
// handle error — the compiler doesn't force this, but idiom does
log.Fatal(err)
}
fmt.Println(result)
While this leads to more verbose code (the famous if err != nil pattern repeated often), it makes control flow explicit and traceable — there is no “invisible” jump like a thrown exception that might be caught several stack frames away.
2. The error Interface
type error interface {
Error() string
}
That’s it — remarkably small. Any type that implements a method Error() string satisfies the error interface. This simplicity is intentional: it means any type — a struct, a string wrapper, an integer code, even a function type — can be an error, as long as it can describe itself as a string.
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Msg)
}
var err error = &MyError{Code: 404, Msg: "not found"}
fmt.Println(err) // error 404: not found
Nil interface gotcha
A very common bug: returning a typed nil pointer as an error interface value results in a non-nil interface, because the interface carries both a type and a value, and only the value is nil.
func mayFail() *MyError {
return nil // no error occurred
}
func doSomething() error {
var err *MyError = mayFail()
return err // DANGER: this returns a non-nil error interface!
}
func main() {
if err := doSomething(); err != nil {
fmt.Println("Got an error:", err) // prints, even though there was no real error!
}
}
Why: err in doSomething()’s return has dynamic type *MyError and dynamic value nil. When compared to the literal nil interface (type=nil, value=nil), they are NOT equal, because the type portion differs.
Fix: Return the interface error directly and explicitly return nil when there’s no error:
func doSomething() error {
if err := mayFail(); err != nil {
return err
}
return nil
}
3. Creating Errors
errors.New
err := errors.New("something went wrong")
Creates a simple, static error whose message never changes.
fmt.Errorf
err := fmt.Errorf("failed to process user %d: %v", userID, cause)
Allows building a formatted error message — but critically, fmt.Errorf also supports the %w verb (explained in section 6) to wrap an underlying error while preserving programmatic access to it.
4. Sentinel Errors
A sentinel error is a specific, pre-declared error value that callers can compare against directly:
package sql
var ErrNoRows = errors.New("sql: no rows in result set")
Usage:
row := db.QueryRow("SELECT ...")
var name string
err := row.Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
// handle "not found" case specifically
}
Guidelines for sentinel errors:
- Name them with an
Errprefix:ErrNotFound,ErrPermission,io.EOF(an exception to naming, for historical reasons). - Declare them as package-level
var, notconst(sinceerroris an interface, and interfaces can’t be constants). - Use sparingly — sentinel errors create tight coupling between packages (the caller must import your package just to compare against your specific error value). Prefer them mainly for truly universal, well-known conditions (e.g.,
io.EOF,sql.ErrNoRows,context.Canceled).
5. Custom Error Types
When you need to carry structured data alongside the error (not just a string), define a custom type:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}
Callers can extract the structured data using errors.As (see section 7):
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Println("Problem field:", verr.Field)
}
Should the receiver be a pointer or value?
Convention: implement Error() on the pointer receiver (*ValidationError) when the error type has mutable or larger internal state, or when you want identity comparison (==) between two errors to mean “the same occurrence.” Use a value receiver for small, purely immutable error types. Most real-world code uses pointer receivers for custom error structs.
6. Error Wrapping (%w) and Unwrapping
Introduced in Go 1.13, error wrapping lets you attach context to an error while preserving the ability to programmatically inspect the original underlying error.
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err)
}
_ = data
return nil
}
Here, %w wraps err (e.g., an *fs.PathError) inside a new error, while still letting the caller unwrap it. Contrast with %v, which would only preserve the message, losing the original error’s identity and type.
The Unwrap() method
Wrapping works because fmt.Errorf("...: %w", err) returns a value implementing:
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }
Any custom error type can support wrapping simply by implementing Unwrap() error:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return fmt.Sprintf("query %q failed: %v", e.Query, e.Err) }
func (e *QueryError) Unwrap() error { return e.Err }
Wrapping multiple errors (%w with more than one, Go 1.20+)
Since Go 1.20, fmt.Errorf supports multiple %w verbs:
err := fmt.Errorf("multiple failures: %w and %w", err1, err2)
This creates an error whose Unwrap() returns []error{err1, err2} (implementing the interface{ Unwrap() []error } shape introduced alongside errors.Join).
7. errors.Is vs errors.As
These two functions, both introduced in Go 1.13, are the primary tools for inspecting wrapped error chains.
errors.Is — “is this error (or does it wrap) this specific value?”
func Is(err, target error) bool
Walks the chain of wrapped errors (via repeated Unwrap() calls) and checks whether any of them is == to target, OR implements Is(error) bool and reports a match.
if errors.Is(err, sql.ErrNoRows) {
// matches even if err = fmt.Errorf("query failed: %w", sql.ErrNoRows)
}
if errors.Is(err, context.DeadlineExceeded) {
// handle timeout specifically
}
Use errors.Is for sentinel error comparisons.
errors.As — “does this error chain contain an error of this type, and if so, give it to me?”
func As(err error, target any) bool
Walks the chain and finds the first error whose concrete type matches target (a pointer to the desired type), assigning it if found.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("failed path:", pathErr.Path)
}
Use errors.As when you need to extract structured fields from a specific custom error type, regardless of how deeply it’s wrapped.
Rule of thumb
| Situation | Use |
|---|---|
| Comparing against a known, specific error value (sentinel) | errors.Is |
| Extracting a specific error type’s fields | errors.As |
| Simple equality check on unwrapped errors (rare, discouraged) | == (only when you’re sure there’s no wrapping involved) |
Implementing custom Is/As behavior
You can customize matching logic by implementing:
func (e *MyError) Is(target error) bool {
t, ok := target.(*MyError)
if !ok {
return false
}
return e.Code == t.Code // match by code, ignore message
}
8. Multi-Error Handling: errors.Join
Introduced in Go 1.20, errors.Join combines multiple errors into a single error value:
func Join(errs ...error) error
err1 := errors.New("disk full")
err2 := errors.New("network unreachable")
combined := errors.Join(err1, err2)
fmt.Println(combined)
// disk full
// network unreachable
fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true
This is extremely useful for scenarios like validating multiple fields and wanting to report all failures at once, rather than stopping at the first one:
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age must be non-negative"))
}
return errors.Join(errs...) // returns nil if errs is empty
}
Note: errors.Join(nil, nil) returns nil. errors.Join skips nil entries automatically, and if all entries are nil, the joined result itself is nil.
9. Panic and Recover
panic and recover are not a substitute for normal error handling. They are reserved for:
- Truly unrecoverable programming errors (e.g., index out of range, nil pointer dereference, failed invariant/assertion).
- Situations where continuing execution would be unsafe or meaningless.
- Framework-level “fail fast during initialization” scenarios.
Basic mechanics
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
result = a / b // panics if b == 0 (integer division by zero)
return
}
panic(v any)immediately stops normal execution of the current function, runs any deferred functions in the current goroutine (in LIFO order), and propagates up the call stack until either arecover()is called inside a deferred function, or the program crashes with a stack trace.recover()only has an effect when called directly inside a deferred function. Calling it elsewhere returnsniland does nothing.
When (not) to use panic
Appropriate:
func mustCompile(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(err) // programmer error: pattern is hardcoded and known-bad
}
return re
}
This mirrors patterns like regexp.MustCompile, template.Must — used for package-level initialization where failure indicates a bug, not a runtime condition to recover from.
Inappropriate:
// BAD — using panic for ordinary, expected failure conditions
func GetUser(id string) *User {
user, ok := db[id]
if !ok {
panic("user not found") // should return an error instead!
}
return user
}
Recovering in goroutines
A recover() in one goroutine cannot catch a panic from another goroutine. Each goroutine that might panic needs its own deferred recover:
func worker(id int) {
defer func() {
if r := recover(); r != nil {
log.Printf("worker %d recovered: %v", id, r)
}
}()
doRiskyWork()
}
go worker(1) // if this panics without a defer/recover of its own, it crashes the whole program
Important: An unrecovered panic in any goroutine crashes the entire program, not just that goroutine. This is a deliberate Go design decision — a panic usually indicates a broken invariant, and Go prefers a loud crash over silent corruption.
10. Error Handling Patterns in Practice
Pattern: Adding context while propagating up the stack
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file %q: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config file %q: %w", path, err)
}
return &cfg, nil
}
Each layer adds a little context (“what was I trying to do when this failed”) without discarding the original error’s identity, thanks to %w.
Pattern: Classifying errors for control flow (e.g., HTTP status mapping)
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
)
func handleError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, ErrUnauthorized):
http.Error(w, err.Error(), http.StatusUnauthorized)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Pattern: Retrying on transient errors
type TemporaryError struct{ Err error }
func (e *TemporaryError) Error() string { return e.Err.Error() }
func (e *TemporaryError) Unwrap() error { return e.Err }
func isTemporary(err error) bool {
var t *TemporaryError
return errors.As(err, &t)
}
func doWithRetry(ctx context.Context, fn func() error) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if err = fn(); err == nil {
return nil
}
if !isTemporary(err) {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt+1) * 200 * time.Millisecond):
}
}
return fmt.Errorf("all retries failed: %w", err)
}
Pattern: Defer-based cleanup with error handling
func processFile(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %q: %w", path, cerr)
}
}()
// ... process f ...
return nil
}
This pattern ensures that a failure to close the file is not silently dropped, but also doesn’t override a more important earlier error.
11. Logging vs Returning Errors
A key discipline in Go codebases: don’t both log AND return the same error at every layer. This causes duplicate, noisy logs (the same root cause logged 5 times as it bubbles up through 5 layers).
Guideline:
- Low-level/library code: return errors (possibly wrapped with
%w), don’t log. - High-level/boundary code (e.g., the top of an HTTP handler, a
main()function, a message-queue consumer’s entry point): log the final error once, with full context, and decide what to do (retry, respond with an HTTP status, alert, etc.).
// library layer — just return
func fetchUser(ctx context.Context, id string) (*User, error) {
u, err := db.Query(ctx, id)
if err != nil {
return nil, fmt.Errorf("fetchUser(%s): %w", id, err)
}
return u, nil
}
// boundary layer — log once, here
func handleGetUser(w http.ResponseWriter, r *http.Request) {
u, err := fetchUser(r.Context(), r.PathValue("id"))
if err != nil {
log.Printf("handleGetUser failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(u)
}
12. Common Anti-Patterns
Anti-pattern: Swallowing errors
// BAD
data, _ := os.ReadFile("config.json")
Silently discarding an error hides real failures and leads to confusing downstream bugs (e.g., data being nil).
Anti-pattern: Comparing wrapped errors with ==
// BAD — breaks the moment the error is wrapped with fmt.Errorf("...: %w", err)
if err == sql.ErrNoRows { ... }
// GOOD
if errors.Is(err, sql.ErrNoRows) { ... }
Anti-pattern: Over-wrapping / redundant context
// BAD — repetitive, unhelpful context at every layer
return fmt.Errorf("error: %w", fmt.Errorf("error: %w", fmt.Errorf("failed: %w", err)))
Each wrap should add new, useful information (what was being attempted), not just repeat generic words like “error occurred.”
Anti-pattern: Using panic for expected, recoverable conditions
Already covered in section 9 — reserve panic for programmer errors and unrecoverable states, not for things like “user input invalid” or “record not found.”
Anti-pattern: Returning naked/ungrouped errors from public APIs without stable sentinel values
If your package’s callers need to programmatically distinguish error cases, expose sentinel errors or typed errors — don’t force them to string-match err.Error().
Anti-pattern: Ignoring the “nil interface holding a nil pointer” trap
Covered in section 2 — always be careful when returning a concrete pointer type through an error-typed return.
13. Best Practices Checklist for Errors
- ✅ Return errors as the last return value; check them immediately with
if err != nil. - ✅ Use
fmt.Errorf("...: %w", err)to wrap while preserving the error chain. - ✅ Use
errors.Isfor sentinel comparisons,errors.Asfor extracting typed errors. - ✅ Define sentinel errors (
ErrXxx) for well-known, universal conditions only. - ✅ Define custom error structs when you need structured data alongside the message.
- ✅ Log errors once, at the boundary layer — not at every layer they pass through.
- ✅ Reserve
panic/recoverfor programmer errors and unrecoverable situations, not routine failures. - ✅ Never discard an error silently (
_ = err) without a deliberate, documented reason. - ✅ Add meaningful context when wrapping — say what you were doing, not just “error.”
- ✅ Watch out for the typed-nil-in-interface trap when returning concrete error types.
- ✅ For multiple independent errors, use
errors.Joinrather than only reporting the first one.
Part III — Combining Context and Errors
Context and error handling intersect constantly in real Go systems. A few essential integration points:
Detecting cancellation vs. deadline
result, err := doWork(ctx)
if err != nil {
switch {
case errors.Is(err, context.Canceled):
// caller gave up / client disconnected — usually not a "real" error to alert on
case errors.Is(err, context.DeadlineExceeded):
// operation took too long — may want to alert, retry, or return HTTP 504
default:
// some other failure
}
}
Propagating context cancellation as part of your own error chain
When a function performs work that respects ctx, and it returns early due to cancellation, it’s idiomatic to simply return ctx.Err() (or wrap it):
func longOperation(ctx context.Context) error {
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return fmt.Errorf("longOperation interrupted: %w", ctx.Err())
default:
}
// do a chunk of work
}
return nil
}
This lets the caller use errors.Is(err, context.Canceled) regardless of how deeply the cancellation happened inside a chain of wrapped calls.
Timeouts as a form of “expected” errors
Just like context.Canceled typically shouldn’t be treated as a crash-worthy error, a context.DeadlineExceeded is often a normal, expected outcome under load — treat it as a value to branch on, not necessarily something requiring a stack trace.
Appendix: Quick Reference Tables
Context functions
| Function | Purpose |
|---|---|
context.Background() | Root context, never cancelled, no values |
context.TODO() | Placeholder root context, signals “not yet decided” |
context.WithCancel(parent) | Manual cancellation |
context.WithTimeout(parent, d) | Cancels after duration d |
context.WithDeadline(parent, t) | Cancels at wall-clock time t |
context.WithValue(parent, key, val) | Attaches request-scoped data |
context.Cause(ctx) (Go 1.21+) | Returns the underlying cause, even through WithCancelCause |
context.WithCancelCause(parent) (Go 1.21+) | Cancel with a custom error reason |
Error functions
| Function | Purpose |
|---|---|
errors.New(msg) | Create a simple static error |
fmt.Errorf(format, ..., err) with %w | Wrap an error while formatting a message |
errors.Is(err, target) | Check if err matches a sentinel, anywhere in the chain |
errors.As(err, &target) | Extract a specific error type from the chain |
errors.Unwrap(err) | Manually unwrap one level |
errors.Join(errs...) (Go 1.20+) | Combine multiple errors into one |
Panic/recover quick reference
| Concept | Behavior |
|---|---|
panic(v) | Stops normal flow, runs deferred functions, propagates up |
recover() | Only effective inside a deferred function; stops the panic |
| Unrecovered panic | Crashes the entire program (all goroutines) |
| Cross-goroutine recover | Not possible; each goroutine needs its own recover |
End of document.