Go Error Handling and Resource Management

Go treats errors as ordinary values — there are no exceptions, no try/catch, and no hidden control flow. Every function that can fail returns an error alongside its result, and the caller decides what to do with it. For cleanup, Go provides defer, which guarantees a function call runs when the enclosing function returns, regardless of how it returns. Together, explicit error returns and defer give you predictable error propagation and deterministic resource management without the complexity of exception hierarchies or finalizers. In this post we cover Go’s error handling patterns — from basic checks through wrapping, sentinel errors, and context-aware error classification — then move to defer patterns including LIFO ordering, loop pitfalls, closure capture semantics, named return manipulation, and panic recovery.

Error Handling

Basic Errors

The simplest way to create an error in Go is errors.New, which returns a value that implements the error interface (any type with an Error() string method). For formatted messages, fmt.Errorf works like fmt.Sprintf but returns an error. The convention is to return a zero value alongside the error, and nil for the error on success.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	err := errors.New("connection refused")
	fmt.Printf("errors.New: %v\n", err)

	err2 := fmt.Errorf("failed to connect to %s:%d", "localhost", 5432)
	fmt.Printf("fmt.Errorf: %v\n", err2)

	result, err := divide(10, 0)
	if err != nil {
		fmt.Printf("divide error: %v\n", err)
	} else {
		fmt.Printf("result: %d\n", result)
	}

	result, err = divide(10, 3)
	if err != nil {
		fmt.Printf("divide error: %v\n", err)
	} else {
		fmt.Printf("10 / 3 = %d\n", result)
	}
}

The if err != nil pattern is the most common construct in Go code. There is no shorthand — every error is checked explicitly at the call site. This verbosity is intentional: it makes the error path visible and forces the developer to think about what should happen when something fails.

Error Wrapping with %w

When an error passes through multiple layers of a program, you want to add context (which function failed, what it was trying to do) without losing the original error. Go 1.13 introduced the %w verb in fmt.Errorf for this. It wraps the original error so that downstream code can still match it, while prepending a descriptive message.

1
2
3
4
5
6
7
var ErrNotFound = errors.New("not found")

original := ErrNotFound
wrapped := fmt.Errorf("load user: %w", original)
fmt.Printf("wrapped: %v\n", wrapped)
fmt.Printf("errors.Is(wrapped, ErrNotFound)? %t\n",
	errors.Is(wrapped, ErrNotFound)) // true — chain preserved

The critical distinction is between %w and %v. Using %v formats the error’s message into a new string but breaks the chain — the resulting error is just a string, not a wrapper around the original.

1
2
3
broken := fmt.Errorf("load user: %v", original)
fmt.Printf("errors.Is(broken, ErrNotFound)? %t\n",
	errors.Is(broken, ErrNotFound)) // false — chain lost

Use %w when you want callers to be able to inspect the underlying error. Use %v only when you intentionally want to hide the original error from programmatic inspection (for example, when crossing an API boundary where you do not want to expose internal error types).

Sentinel Errors

A sentinel error is a package-level variable that represents a specific, well-known error condition. Callers compare against it using errors.Is, which walks the entire wrap chain looking for a match.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var (
	ErrNotFound      = errors.New("not found")
	ErrInvalidFormat = errors.New("invalid format")
)

func loadConfig(path string) error {
	switch path {
	case "missing.yaml":
		return fmt.Errorf("loadConfig(%s): %w", path, ErrNotFound)
	case "bad.yaml":
		return fmt.Errorf("loadConfig(%s): %w", path, ErrInvalidFormat)
	default:
		return nil
	}
}

The caller does not need to know how many layers of wrapping sit between it and the sentinel — errors.Is unwraps automatically:

1
2
3
4
5
6
7
8
9
err := loadConfig("missing.yaml")
if errors.Is(err, ErrNotFound) {
	fmt.Println("config not found — using defaults")
}

err = loadConfig("bad.yaml")
if errors.Is(err, ErrInvalidFormat) {
	fmt.Println("config format invalid — check syntax")
}

Sentinel errors are best suited for conditions that callers genuinely need to branch on. Do not create sentinels for every possible failure — most errors should just be returned with context and eventually logged or displayed.

errors.As — Matching by Type

While errors.Is checks whether an error in the chain matches a specific value, errors.As checks whether any error in the chain matches a specific type. This is useful when the error carries structured data beyond a message string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

func validateChange(table, action string) error {
	if table == "" {
		return &ValidationError{Field: "table", Message: "cannot be empty"}
	}
	return nil
}

To extract the typed error from a (possibly wrapped) chain, declare a variable of the target type and pass its address to errors.As:

1
2
3
4
5
6
7
8
9
10
11
err := validateChange("", "INSERT")
var ve *ValidationError
if errors.As(err, &ve) {
	fmt.Printf("validation error: field=%s, message=%s\n", ve.Field, ve.Message)
}

// Works through wrapping too
wrapped := fmt.Errorf("process change: %w", err)
if errors.As(wrapped, &ve) {
	fmt.Printf("errors.As through wrap: field=%s\n", ve.Field)
}

Use errors.Is when you are checking against a known value (sentinel). Use errors.As when you need to extract a specific error type to read its fields.

Context Errors

When a function takes a context.Context, the operation might fail because the context was cancelled or because a deadline expired. These are not real application errors — they are intentional signals from the caller that it no longer wants the result. The pattern is to check ctx.Err() after receiving an error to determine whether the failure is genuine or just a cancellation.

1
2
3
4
5
6
7
8
9
func receive(ctx context.Context, simulateError bool) error {
	if ctx.Err() != nil {
		return fmt.Errorf("receive: %w", ctx.Err())
	}
	if simulateError {
		return fmt.Errorf("receive: %w", errors.New("connection reset"))
	}
	return nil
}

At the call site, the distinction matters for how you respond:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Real error — context is fine
err := receive(ctx, true)
if err != nil && ctx.Err() == nil {
	fmt.Printf("real error: %v\n", err)
}

// Context cancelled — not a real error
cancel() // cancel the context
err = receive(ctx, true)
if err != nil && ctx.Err() == nil {
	fmt.Printf("real error: %v\n", err)
} else if err != nil {
	fmt.Printf("context cancelled — ignoring error: %v\n", err)
}

// Timeout — deadline exceeded
timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer timeoutCancel()
time.Sleep(5 * time.Millisecond)
err = receive(timeoutCtx, false)
if err != nil && timeoutCtx.Err() == nil {
	fmt.Printf("real error: %v\n", err)
} else if err != nil {
	fmt.Printf("timed out — not a real failure: %v\n", err)
}

This pattern is essential in server code, background workers, and anything using gRPC or HTTP handlers where contexts carry deadlines. Without the ctx.Err() check, you would log cancellations as failures, cluttering your error monitoring.

Multi-Layer Error Chains

In real applications, errors flow up through multiple layers — a database call fails, the repository wraps it, the service wraps it again, and the handler wraps it once more. Each layer adds context with %w, building a chain you can inspect with errors.Is, errors.As, or errors.Unwrap.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func bottomLayer() error {
	return fmt.Errorf("bottom (table lookup): %w", ErrNotFound)
}

func middleLayer() error {
	err := bottomLayer()
	if err != nil {
		return fmt.Errorf("middle (processing batch): %w", err)
	}
	return nil
}

func topLevel() error {
	err := middleLayer()
	if err != nil {
		return fmt.Errorf("top: %w", err)
	}
	return nil
}

The caller at the top gets a richly annotated error that still matches the original sentinel:

1
2
3
4
5
6
7
8
err := topLevel()
if err != nil {
	fmt.Printf("full chain: %v\n", err)
	// Output: top: middle (processing batch): bottom (table lookup): not found

	fmt.Printf("is ErrNotFound? %t\n", errors.Is(err, ErrNotFound))
	// Output: true
}

You can also walk the chain manually with errors.Unwrap to see each layer:

1
2
3
4
5
6
7
8
9
current := err
for current != nil {
	fmt.Printf("  -> %v\n", current)
	current = errors.Unwrap(current)
}
// -> top: middle (processing batch): bottom (table lookup): not found
// -> middle (processing batch): bottom (table lookup): not found
// -> bottom (table lookup): not found
// -> not found

The rule of thumb is: wrap at every layer boundary, use sentinel errors for conditions that callers branch on, and use errors.Is/errors.As to inspect the chain without breaking encapsulation.

Defer Patterns

LIFO Order

Deferred function calls execute in last-in, first-out order when the enclosing function returns. This means the last defer registered runs first. The LIFO order is deliberate — it mirrors the typical pattern of acquiring resources in sequence and releasing them in reverse order (open A, open B, close B, close A).

1
2
3
4
5
6
7
func part1_lifoOrder() {
	fmt.Println("registering defers 1, 2, 3...")
	defer fmt.Println("defer 1 (registered first — runs last)")
	defer fmt.Println("defer 2")
	defer fmt.Println("defer 3 (registered last — runs first)")
	fmt.Println("function body done — defers fire in reverse:")
}

Output:

1
2
3
4
5
registering defers 1, 2, 3...
function body done — defers fire in reverse:
defer 3 (registered last — runs first)
defer 2
defer 1 (registered first — runs last)

Defer in Loops

A common mistake is deferring a close call inside a loop. Because defer is scoped to the enclosing function (not the loop iteration), all the deferred calls pile up and only execute when the function returns. If you are opening files or database connections in a loop, they all stay open simultaneously until the function exits.

1
2
3
4
5
6
7
8
// BAD — all defers pile up until function returns
filenames := []string{"a.txt", "b.txt", "c.txt"}
for _, name := range filenames {
	f, err := os.Open(name)
	if err != nil { continue }
	defer f.Close() // won't run until the surrounding function returns
	// process f...
}

The fix is to wrap the loop body in its own function (either a named function or an anonymous closure), so defer fires at the end of each iteration:

1
2
3
4
5
6
7
8
9
// FIX — wrap in a function so defer runs each iteration
for _, name := range filenames {
	func(n string) {
		f, err := os.Open(n)
		if err != nil { return }
		defer f.Close()
		// process f...
	}(name)
}

Alternatively, extract the body into a named function like processFile(name). Either approach ensures resources are released promptly.

Defer with Closures

Deferred closures capture variables by reference, not by value. This means a deferred closure sees the final value of any variable from the enclosing scope, not the value at the time defer was called.

1
2
3
4
5
6
7
8
9
10
11
func part3_deferWithClosures() {
	x := 0
	defer func() {
		fmt.Printf("deferred closure sees x = %d (final value, not 0)\n", x)
	}()
	x = 42
	fmt.Printf("x set to %d\n", x)
}
// Output:
// x set to 42
// deferred closure sees x = 42 (final value, not 0)

If you need to capture the value at the time of the defer, pass it as a parameter. Function parameters are evaluated immediately when defer is executed, so the value is frozen:

1
2
3
4
5
6
7
8
9
y := 0
defer func(val int) {
	fmt.Printf("deferred closure(val) sees val = %d (frozen at defer time)\n", val)
}(y) // y is evaluated now, val = 0
y = 99
fmt.Printf("y set to %d, but deferred param already captured 0\n", y)
// Output:
// y set to 99, but deferred param already captured 0
// deferred closure(val) sees val = 0 (frozen at defer time)

The reference-capture behaviour is what makes named-return error wrapping work (covered next), but it can also be a source of bugs when you unintentionally capture a loop variable.

Named Returns with Defer

One of the more powerful defer patterns uses named return values to modify the return result in a deferred function. Because the deferred closure captures the named return by reference, it can inspect and change the value that the caller ultimately receives. This is especially useful for wrapping errors with context or for flushing buffers on the way out.

1
2
3
4
5
6
7
8
9
10
11
12
func readConfig(path string) (content string, err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("readConfig(%s): %w", path, err)
		}
	}()

	if path == "missing.conf" {
		return "", os.ErrNotExist
	}
	return "key=value", nil
}

When readConfig("missing.conf") returns os.ErrNotExist, the deferred function wraps it into readConfig(missing.conf): file does not exist before the caller sees it. This avoids duplicating the wrapping logic at every return statement in the function.

The same technique works for capturing flush errors that would otherwise be silently ignored:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func writeWithFlush() (n int, err error) {
	n = 128
	defer func() {
		flushErr := simulateFlush()
		if err == nil {
			err = flushErr
		}
	}()
	return n, nil
}

func simulateFlush() error {
	return errors.New("flush: disk full")
}

Here the write itself succeeds, but the deferred flush fails. Without the named return pattern, the flush error would be lost because return n, nil has already been executed. The deferred function overwrites err before the caller receives it.

Panic and Recover

Go’s panic is not for normal error handling — it is reserved for truly unrecoverable situations (programming errors, violated invariants, corrupted state). When a panic occurs, the runtime begins unwinding the call stack, executing deferred functions in each frame. If no deferred function calls recover(), the program crashes with a stack trace.

recover() stops the panic and returns the value that was passed to panic. It only works inside a deferred function — calling recover() in normal code always returns nil.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("caught panic: %v", r)
		}
	}()
	return a / b, nil // panics if b == 0 (integer division by zero)
}

result, err := safeDivide(10, 0)
fmt.Printf("result=%d, err=%v\n", result, err)
// result=0, err=caught panic: runtime error: integer division by zero

result, err = safeDivide(10, 3)
fmt.Printf("result=%d, err=%v\n", result, err)
// result=3, err=<nil>

During a panic unwind, all deferred functions in the call stack still run in LIFO order. This is important — it means cleanup code (closing files, releasing locks) still executes even during a panic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func panicChain() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("defer 1 (outermost): recovered panic: %v\n", r)
		}
	}()
	defer fmt.Println("defer 2: I still run during panic unwind")
	defer fmt.Println("defer 3: I run first during panic unwind")

	panic("something went wrong")
}
// Output:
// defer 3: I run first during panic unwind
// defer 2: I still run during panic unwind
// defer 1 (outermost): recovered panic: something went wrong

The general guidance is: return errors, do not panic. Use panic only for programming bugs (like indexing out of bounds or an impossible state). Use recover at API boundaries (HTTP handlers, goroutine entry points) to convert panics into errors so that one bad request does not bring down the entire server.