Error Handling: error, panic and recover
Error Handling: error, panic and recover
Of all programming language features, error handling design most clearly reveals a language's philosophy. Java chose checked exceptions — forcing callers to declare possible failures. Rust chose Result<T, E> — encoding error possibility in the type system. Go chose the most straightforward approach: a function returns an error value, and the caller explicitly checks it.
This isn't a compromise. It's a deliberate design decision. Rob Pike said at GopherCon 2015: "Errors are values." Not exceptions. Not control flow jumps. Just ordinary values that can be programmed, composed, and handled like any other data. That deceptively simple statement defines Go's entire error handling paradigm.
This chapter starts from the fundamental error interface, progresses through error wrapping and chain inspection, compares Go's philosophy with Java and Rust, and concludes with the correct usage of panic/recover.
Level 1: What You Need to Know
The error Interface
In Go, error is a built-in interface defined in the builtin package:
type error interface {
Error() string
}
Any type that implements the Error() string method satisfies the error interface. This is one of Go's simplest interfaces — just one method. This minimalist design means:
- Any type can be an error — just implement
Error() - Errors are ordinary values — they can be stored, passed, compared, and composed
- No special syntax mechanism — no
try/catch/finallyneeded
Three Ways to Create Errors
Method 1: errors.New
The simplest approach, for static error messages:
import "errors"
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
errors.New returns a pointer to the internal type *errorString, whose definition is extremely simple:
// Actual implementation in the errors package
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
Notice it returns a pointer *errorString, not a value errorString. Why? Because two errorString values with the same content should be different errors — each errors.New("same message") call should produce an independent instance. Using a pointer ensures that even with identical messages, == comparison returns false.
Method 2: fmt.Errorf
Used when you need dynamically formatted error messages:
func openFile(path string) (*File, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
}
return f, nil
}
fmt.Errorf essentially uses fmt.Sprintf to format the string, then decides whether to return a plain error or a wrapping error based on whether the %w verb is used (wrapping is covered in detail in Level 2).
Method 3: Custom Error Types
When you need to carry additional context information, define your own error type:
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q (value: %v): %s",
e.Field, e.Value, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Value: age,
Message: "must be between 0 and 150",
}
}
return nil
}
The advantage of custom error types is that callers can extract detailed information through type assertions:
if err := validateAge(-1); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("field %s validation failed\n", ve.Field)
}
}
The if err != nil Pattern
The most common pattern in Go code:
result, err := someFunction()
if err != nil {
return fmt.Errorf("context: %w", err)
}
// use result normally
The core idea: handle errors immediately where they occur. There's no magic propagation of errors up the call stack — if you don't check err, it's simply discarded.
A complete practical example:
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config JSON: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validating config: %w", err)
}
return &cfg, nil
}
Every operation can fail. Every one is explicitly checked. This is Go's style — no hidden control flow, all error paths clearly visible.
Common Mistakes and Fixes
Mistake 1: Ignoring errors
// Wrong: ignoring Close's error
file, _ := os.Open("data.txt")
defer file.Close()
// Right: at minimum, log the error
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer func() {
if cerr := file.Close(); cerr != nil {
log.Printf("warning: failed to close file: %v", cerr)
}
}()
Mistake 2: Using return values before nil check
// Wrong: result might be zero value
result, err := compute()
fmt.Println(result.Value) // if err != nil, this might panic
if err != nil {
return err
}
// Right: check error first
result, err := compute()
if err != nil {
return err
}
fmt.Println(result.Value)
Mistake 3: String comparison for error checking
// Wrong: brittle string comparison
if err.Error() == "file not found" {
// ...
}
// Right: use errors.Is or errors.As
if errors.Is(err, os.ErrNotExist) {
// ...
}
Mistake 4: Returning a typed nil as interface
// Wrong: this function never returns nil!
func getError() error {
var p *MyError = nil
return p // returns (*MyError)(nil), not nil error
}
// Right: return nil directly
func getError() error {
return nil
}
This is a classic Go interface trap: an error interface value contains two parts — a type pointer and a value pointer. When the type pointer is non-nil, the interface value is non-nil even if the value pointer is nil.
Fundamental Principles of Error Handling
- Handle an error only once — either return it or log it, never both
- Add context — bare
return errloses call stack information - Translate errors at package boundaries — don't expose internal implementation details
- nil is a valid error value — it means "no error"
// Anti-pattern: both logging and returning
func bad() error {
err := doSomething()
if err != nil {
log.Printf("error: %v", err) // logged
return err // also returned — caller may log again
}
return nil
}
// Right: do only one thing
func good() error {
err := doSomething()
if err != nil {
return fmt.Errorf("doing something: %w", err) // only return
}
return nil
}
Level 2: How It Works Under the Hood
Error Wrapping
Go 1.13 (released September 2019) introduced error wrapping via the %w verb in fmt.Errorf:
originalErr := errors.New("connection refused")
wrappedErr := fmt.Errorf("connecting to database: %w", originalErr)
The %w verb tells fmt.Errorf: don't just concatenate the error message — preserve a reference to the original error. The returned error implements the Unwrap() error method:
// Approximate internal implementation in fmt package
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
Comparing %v (format only, lose reference) vs %w (wrap, preserve reference):
err := os.ErrNotExist
// %v: only formats the message, loses original error reference
e1 := fmt.Errorf("file problem: %v", err)
fmt.Println(errors.Is(e1, os.ErrNotExist)) // false
// %w: preserves original error reference
e2 := fmt.Errorf("file problem: %w", err)
fmt.Println(errors.Is(e2, os.ErrNotExist)) // true
errors.Is and errors.As
Go 1.13 also introduced two critical functions for searching within error chains.
errors.Is: Value Matching
errors.Is(err, target) traverses the error chain checking for equality with target:
func errors.Is(err, target error) bool
The search process:
- Check
err == target - If err implements
Is(error) bool, call it - If err implements
Unwrap() error, recursively check the unwrapped error - If err implements
Unwrap() []error(Go 1.20+), recursively check each child error
// Practical usage
err := fmt.Errorf("open config: %w",
fmt.Errorf("reading file: %w", os.ErrNotExist))
// Found even through two layers of wrapping
fmt.Println(errors.Is(err, os.ErrNotExist)) // true
errors.As: Type Matching
errors.As(err, target) finds the first error in the chain assignable to target:
func errors.As(err error, target interface{}) bool
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string {
return e.Name + ": not found"
}
// Find a specific type in the error chain
err := fmt.Errorf("loading user: %w", &NotFoundError{Name: "alice"})
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Println(nfe.Name) // "alice"
}
Note that target must be a pointer to a pointer to the error type (**NotFoundError), because errors.As needs to set the value through it.
Custom Is and Unwrap
Your error types can customize matching logic:
type TimeoutError struct {
Duration time.Duration
Op string
}
func (e *TimeoutError) Error() string {
return fmt.Sprintf("%s timed out after %v", e.Op, e.Duration)
}
// Custom Is: match any TimeoutError
func (e *TimeoutError) Is(target error) bool {
_, ok := target.(*TimeoutError)
return ok
}
Three Error Strategies
The Go community has identified three major error definition strategies, systematically described by Dave Cheney in his 2016 blog post "Don't just check errors, handle them gracefully":
1. Sentinel Errors
Predefined global error variables representing specific, known error conditions:
// Sentinel errors in the standard library
var (
ErrNotExist = errors.New("file does not exist")
ErrPermission = errors.New("permission denied")
ErrClosed = errors.New("use of closed connection")
)
// io package
var EOF = errors.New("EOF")
Check with errors.Is:
data, err := io.ReadAll(reader)
if errors.Is(err, io.EOF) {
// normal end of stream
}
if errors.Is(err, os.ErrNotExist) {
// file does not exist
}
Pros: Simple, efficient, matchable with errors.Is.
Cons: Becomes part of the package's public API — hard to change once exposed; cannot carry contextual information.
Naming convention: Err prefix + descriptive name, e.g., ErrNotFound, ErrInvalidInput.
2. Error Types
Custom structs implementing the error interface, carrying rich context:
type PathError struct {
Op string // "open", "read", "write"
Path string // file path
Err error // underlying error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
func (e *PathError) Unwrap() error {
return e.Err
}
Check with errors.As:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("operation %s failed, path: %s\n", pathErr.Op, pathErr.Path)
}
Pros: Carries structured context, extractable with errors.As.
Cons: Exposes internal types as public API, increases API surface area.
3. Opaque Errors
Only expose behavior (via interfaces), not concrete types or values:
// Define behavior interfaces only
type temporary interface {
Temporary() bool
}
type timeout interface {
Timeout() bool
}
// Check behavior, not concrete type
func IsTemporary(err error) bool {
var t temporary
return errors.As(err, &t) && t.Temporary()
}
func IsTimeout(err error) bool {
var t timeout
return errors.As(err, &t) && t.Timeout()
}
Pros: Maximum decoupling — callers don't depend on specific error types or variables. Cons: Flexibility can be confusing; the Go community has mixed opinions on this pattern.
Error Chain Data Structure
When you repeatedly wrap errors with %w, you form a linked list:
wrappedErr3 → wrappedErr2 → wrappedErr1 → originalErr
| | | |
Unwrap() Unwrap() Unwrap() nil
errors.Is and errors.As are essentially linear traversals of this linked list (since Go 1.20, Unwrap() []error supports returning multiple child errors, forming a tree structure with depth-first search traversal).
Go 1.20 introduced multi-error wrapping:
// Go 1.20+: errors.Join combines multiple errors
err := errors.Join(err1, err2, err3)
// Or custom types implementing Unwrap() []error
type multiError struct {
errs []error
}
func (e *multiError) Unwrap() []error {
return e.errs
}
Performance Considerations
Error handling performance matters in hot paths:
// Benchmark: error creation overhead
// errors.New: ~40ns (allocates errorString + string)
// fmt.Errorf %v: ~200ns (formatting + allocation)
// fmt.Errorf %w: ~250ns (formatting + allocation + wrapError)
// Custom types: depends on field count
For hot paths, preallocate sentinel errors to avoid repeated allocations:
// Good: predefined, zero-allocation check
var ErrBufferFull = errors.New("buffer full")
func write(data []byte) error {
if len(buf) + len(data) > cap(buf) {
return ErrBufferFull // zero allocation
}
// ...
}
// Bad: allocates every call
func write(data []byte) error {
if len(buf) + len(data) > cap(buf) {
return fmt.Errorf("buffer full, need %d bytes", len(data)) // allocates each time
}
// ...
}
Error Handling in defer
A common advanced pattern — using named return values to handle errors in defer:
func writeToFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer func() {
cerr := f.Close()
if err == nil {
err = cerr // only use Close's error if no other error occurred
}
}()
_, err = f.Write(data)
if err != nil {
return fmt.Errorf("writing data: %w", err)
}
return nil
}
This pattern uses the named return value err to let the defer function modify the function's return value. f.Close() is especially important for write operations — it flushes the buffer to disk. If the flush fails (e.g., disk full), ignoring this error means silent data loss.
Level 3: What the Specification Says
Go's Error Handling Design Philosophy
Go's error handling design is deeply influenced by several principles traceable to Go's creators — Rob Pike, Ken Thompson, and Robert Griesemer — and their decades of experience with the Plan 9 operating system and C language.
Source 1: C's Return Code Tradition
In C, errors are communicated through return values:
FILE *f = fopen("data.txt", "r");
if (f == NULL) {
perror("fopen failed");
return -1;
}
Go inherited this explicitness but improved upon it:
- C returns
-1orNULLwith unclear semantics — Go returns a separateerrorvalue - C's
errnois a global variable, not thread-safe — Go's error is a local value - C doesn't force checking return values — Go's multiple return values make ignoring errors more visible (
_must be written explicitly)
Source 2: Plan 9's Error Strings
Ken Thompson and Rob Pike used string error messages in the Plan 9 operating system. Go's error interface is essentially something that can return an error description string — directly continuing Plan 9's philosophy.
Comparison with Java Exceptions
Java's checked exception mechanism (designed by James Gosling, 1996):
public void readFile(String path) throws IOException, ParseException {
// ...
}
Problems with Java exceptions (from Go team's perspective):
- Hidden control flow: Exceptions can jump to any catch block at any moment; callers cannot determine which lines might throw by reading the code
- Catch-all anti-pattern: Developers tire of handling checked exceptions and write
catch (Exception e) {} - Exception hierarchy explosion: Java's standard library has hundreds of exception classes with often unclear use cases
- Performance overhead: Constructing exception objects requires capturing the call stack, even if the exception is never thrown
Rob Pike wrote in his 2012 paper "Go at Google":
"We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional."
Comparison with Rust's Result<T, E>
Rust's Result<T, E> (inspired by Haskell's Either type):
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
// Using the ? operator to propagate errors
fn process() -> Result<(), Box<dyn Error>> {
let result = divide(10.0, 0.0)?;
Ok(())
}
Rust vs Go error handling comparison:
| Dimension | Go | Rust |
|---|---|---|
| Error representation | Interface value (runtime polymorphism) | Generic enum (compile-time polymorphism) |
| Propagation syntax | if err != nil { return err } |
? operator |
| Compiler enforcement | Not enforced (can _ ignore) |
Enforced (Result must be used, otherwise warning) |
| Error composition | errors.Join (Go 1.20+) |
anyhow, thiserror crates |
| Pattern matching | errors.Is / errors.As |
match expression |
| Performance | Interface boxing has indirection overhead | Zero-cost abstraction (enum inlined) |
Why is Go's choice reasonable?
Go's design goals are simplicity and readability, targeting engineering practice in large teams. The Go team believes:
- Explicit > implicit:
if err != nilis verbose but every error path is clearly visible - Simple > clever: No need to learn monads,
?operators, or other concepts - Uniform > flexible: One error handling style, everyone writes similar code
- Engineering > theory: In Google-scale codebases, readability matters more than conciseness
History of the Go 1.13 Error Wrapping Proposal
The error wrapping mechanism introduced in Go 1.13 (2019) originated from Marcel van Lohuizen's proposal (issue #29934). Before this, the community widely used third-party packages like github.com/pkg/errors (developed by Dave Cheney, 2016).
Core features of pkg/errors:
// Wrap error with stack trace
err = errors.Wrap(err, "reading config")
// Get original error
cause := errors.Cause(err)
// Print error with stack trace
fmt.Printf("%+v", err)
The standard library ultimately borrowed the wrapping concept but simplified it:
- Only the
Unwrap()mechanism, no automatic stack trace recording (stack traces are expensive and often unnecessary) - The
%wverb instead of a separateWrapfunction errors.Is/Asreplacing direct comparison viaerrors.Cause
The Go 2 Error Handling Draft
In August 2018, the Go team published the Go 2 Draft Designs, including a check/handle error handling proposal (authored by Russ Cox):
// Proposed syntax (never implemented)
func CopyFile(src, dst string) error {
handle err {
return fmt.Errorf("copy %s %s: %w", src, dst, err)
}
r := check os.Open(src)
defer r.Close()
w := check os.Create(dst)
handle err {
w.Close()
os.Remove(dst)
}
check io.Copy(w, r)
check w.Close()
return nil
}
This proposal was ultimately shelved (officially closed in 2023). Community votes and discussions showed that most Go developers preferred keeping the explicit if err != nil style over introducing new control flow keywords. Reasons include:
- Readability:
if err != nilis verbose but clear - Grepability: searching
if err != nilfinds all error handling points - Consistency: no additional learning burden
- Flexibility: the
if err != nilblock can do anything (logging, metrics, retry, cleanup)
The error Interface Specification
The Go Language Specification defines error as:
The predeclared type
erroris defined astype error interface { Error() string }It is the conventional interface for representing an error condition, with the nil value representing no error.
Key point: the specification only says nil represents no error, with no other semantics prescribed for non-nil errors. This minimal specification gives the community maximum flexibility.
Evolution Timeline of the errors Package
| Version | Year | Change |
|---|---|---|
| Go 1.0 | 2012 | errors.New, error interface |
| — | 2016 | github.com/pkg/errors gains popularity |
| Go 1.13 | 2019 | fmt.Errorf %w, errors.Is, errors.As, errors.Unwrap |
| Go 1.20 | 2023 | errors.Join, Unwrap() []error multi-error trees |
Level 4: Edge Cases and Pitfalls
Correct Usage Scenarios for panic/recover
panic and recover are Go's exception mechanism — but they're deliberately designed to be rarely-used tools.
panic Behavior
func main() {
fmt.Println("start")
panic("something went wrong")
fmt.Println("end") // never executes
}
When panic occurs:
- The current function immediately stops executing
- All registered defer functions execute in LIFO order
- Control passes to the caller, whose defers also execute
- Propagation continues up to the goroutine stack top
- If no recover is encountered, the program crashes with a stack trace
recover Behavior
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
return a / b, nil // if b == 0, runtime panic: integer divide by zero
}
recover() only works inside a defer function. If called in a non-defer context, or if the current goroutine isn't panicking, it returns nil.
When to Use panic
Legitimate scenario 1: Unrecoverable errors during program initialization
func init() {
if os.Getenv("DATABASE_URL") == "" {
panic("DATABASE_URL environment variable not set")
}
}
// Or the standard library's regexp.MustCompile
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
The Must* function convention: if a compile-time constant is invalid, it indicates a bug in the code itself — panic is the correct response.
Legitimate scenario 2: Boundary protection inside standard library
// Runtime automatically panics on slice out of bounds
s := []int{1, 2, 3}
_ = s[10] // panic: runtime error: index out of range [10] with length 3
Legitimate scenario 3: Unreachable code paths
func direction(d int) string {
switch d {
case 0:
return "north"
case 1:
return "south"
case 2:
return "east"
case 3:
return "west"
default:
panic(fmt.Sprintf("invalid direction: %d", d))
}
}
Anti-patterns with panic
Anti-pattern 1: Using panic instead of returning an error
// Wrong! Do not do this
func findUser(id int) *User {
user, err := db.Query(id)
if err != nil {
panic(err) // should return error
}
return user
}
Anti-pattern 2: Using panic/recover for control flow
// Wrong! This is not try/catch
func process(items []Item) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("processing failed: %v", r)
}
}()
for _, item := range items {
if !item.Valid() {
panic("invalid item") // should return error
}
}
return nil
}
Problems with this pattern:
- Poor performance — panic requires unwinding the call stack
- Poor readability — readers don't know where panics might occur
- Unsafe — if the defer with recover is accidentally removed, the program crashes
Anti-pattern 3: Cross-goroutine recover
// recover cannot catch panics from other goroutines
func main() {
defer func() {
recover() // cannot catch child goroutine's panic
}()
go func() {
panic("boom") // program crashes immediately
}()
time.Sleep(time.Second)
}
Each goroutine must handle its own recovery. Panics do not propagate across goroutines.
Legitimate Use of recover: HTTP Handler Protection
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// Log error and stack trace
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
log.Printf("panic recovered: %v\n%s", err, buf[:n])
// Return 500 instead of crashing the entire service
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
This is the most classic legitimate use of recover — preventing one request's panic from killing the entire service process. Go's standard library net/http package also has built-in similar recovery mechanisms.
Interview Questions
Question 1: What does the following code output?
func main() {
fmt.Println(f())
}
func f() (result int) {
defer func() {
if r := recover(); r != nil {
result = -1
}
}()
panic("oops")
return 0
}
Answer: Outputs -1. The panic triggers defer, recover catches the panic, and the named return value result is modified to -1.
Question 2: The nil trap with error interfaces
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func getErr(fail bool) error {
var err *MyError
if fail {
err = &MyError{"failed"}
}
return err // trap!
}
func main() {
err := getErr(false)
fmt.Println(err == nil) // what's the output?
}
Answer: Outputs false. getErr(false) returns (*MyError)(nil) — the interface's dynamic type is *MyError (non-nil), even though the dynamic value is nil. Correct approach:
func getErr(fail bool) error {
if fail {
return &MyError{"failed"}
}
return nil // return nil directly
}
Question 3: Recursive matching with errors.Is
var ErrBase = errors.New("base")
err1 := fmt.Errorf("layer 1: %w", ErrBase)
err2 := fmt.Errorf("layer 2: %w", err1)
err3 := fmt.Errorf("layer 3: %w", err2)
fmt.Println(errors.Is(err3, ErrBase)) // ?
fmt.Println(errors.Is(err3, err1)) // ?
fmt.Println(errors.Is(err1, err3)) // ?
Answer: true, true, false. errors.Is can only search downward (in the Unwrap direction), not in reverse.
Question 4: defer execution order during panic
func main() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
panic("crash")
}
Answer: Outputs 3, 2, 1, then prints the panic message. Defers execute in LIFO (last-in, first-out) order.
Real-World Bug Cases
Case 1: Kubernetes Error Wrapping Compatibility Incident
In 2020, Kubernetes upgraded its error handling by changing some return values from bare error to fmt.Errorf("%w", err) wrapping. This caused downstream code that checked sentinel errors with == to break — because wrapped errors no longer equal the original sentinel error.
Fix: Change all err == SomeError to errors.Is(err, SomeError).
Lesson: When introducing error wrapping, you must simultaneously update all error checking code.
Case 2: Secondary Panic in recover
defer func() {
if r := recover(); r != nil {
// If log.Fatal panics internally, there's no second recover
log.Fatalf("panic: %v", r) // log.Fatalf calls os.Exit, not panic
}
}()
A more dangerous situation:
defer func() {
if r := recover(); r != nil {
panic(fmt.Sprintf("recovered: %v", r)) // secondary panic!
}
}()
A secondary panic won't be caught by the same defer's recover — it continues propagating upward.
Case 3: Goroutine Leak from Ignored Errors
func processAsync(ctx context.Context) {
ch := make(chan result)
go func() {
res, err := longOperation()
if err != nil {
return // doesn't send to ch — main goroutine blocks forever!
}
ch <- res
}()
select {
case r := <-ch:
handle(r)
case <-ctx.Done():
return // context cancelled, but child goroutine may still be running
}
}
Fix: Ensure all paths send to the channel (or close it), and use context to control child goroutine lifetime.
Error Handling Best Practices Summary
- Use
%wto wrap errors — preserve the error chain for upstreamerrors.Is/Aschecks - Translate errors at package boundaries — don't expose internals (e.g., whether you use Redis or MySQL underneath)
- Predefine sentinel errors — for stable, API-level error conditions
- Use custom error types — when structured context information is needed
- Don't use panic for normal control flow — panic is only for unrecoverable errors and programming bugs
- Every goroutine should have recover protection — at least top-level goroutines
- Don't both log and return errors — choose one handling approach
- Test error paths — don't only test the happy path; error path behavior must also be verified