Go Functions and Error Handling

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Go has no exceptions. Errors are ordinary return values, which means every failure point is visible in the signature and every caller decides what to do about it.

Definition and multiple results

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

Callers must deal with both values (or explicitly discard one):

result, err := divide(10, 2)
if err != nil {
	log.Printf("compute failed: %v", err)
	return
}
fmt.Println(result)   // 5

Parameters of the same type can share a type keyword: a, b float64. When a function needs to return more than two values, that is usually a signal to define a struct instead of stacking results.

Named results

func parse(raw string) (n int, err error) {
	n, err = strconv.Atoi(raw)
	if err != nil {
		err = fmt.Errorf("parsing %q: %w", raw, err)
	}
	return   // naked return: returns the current n and err
}

Named results are useful when a defer needs to modify the result — a common pattern for transaction wrappers and instrumentation. But a naked return hides what is being returned: past about 20 lines, name the values and return them explicitly.

What error actually is

type error interface {
	Error() string
}

One method. Any type implementing Error() string is an error, so custom error types are cheap:

type ConfigError struct {
	Path string
}

func (e *ConfigError) Error() string {
	return "config file unreadable: " + e.Path
}

Return it as a pointer (&ConfigError{}). Returning the value type breaks errors.As, because the interface then holds a copy that does not match *ConfigError.

Three conventions that keep errors useful

1. The error is the last return value, with nil meaning success:

func load(path string) ([]byte, error)

2. Wrap with %w to preserve the chain:

data, err := os.ReadFile(path)
if err != nil {
	return nil, fmt.Errorf("reading config %s: %w", path, err)
}

%v keeps only the text. %w keeps the original error inside the new one, which is what allows callers to inspect it later.

3. Inspect with errors.Is / errors.As, never by comparing strings:

if errors.Is(err, os.ErrNotExist) {
	// file missing
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
	fmt.Println("failed path:", pathErr.Path)
}

errors.Is walks the chain comparing against a target value (respecting a custom Is method if the type defines one). errors.As walks the chain looking for a type and assigns it. err.Error() == "..." stops working the moment someone wraps the error.

Passing errors up, deciding at the top

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("loadConfig: %w", err)
	}

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return &cfg, nil
}

The rule: intermediate layers add context and never swallow. The decision to continue or abort belongs at the top of the call chain:

cfg, err := loadConfig("config.json")
if err != nil {
	if errors.Is(err, os.ErrNotExist) {
		cfg = defaultConfig()   // recoverable: fall back
	} else {
		log.Fatal(err)          // not recoverable: stop
	}
}

Adding context at every hop is what turns no such file or directory into loadConfig: parsing app.json: open app.json: no such file or directory.

Variadic parameters and functions as values

func sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

sum(1, 2, 3)      // 6
nums := []int{4, 5}
sum(nums...)      // 9 — spread the slice

Functions are first-class values, so decorators are just functions returning functions:

type Middleware func(http.Handler) http.Handler

func apply(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

The reverse loop matters: wrapping in reverse order means the first middleware listed sees the request first.

defer: cleanup you cannot forget

func readHead(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()   // runs on every return path

	buf := make([]byte, 64)
	n, err := f.Read(buf)
	return buf[:n], err
}

Three rules:

  1. Multiple defers run last-in-first-out.
  2. Arguments are evaluated at the defer statement, not when the deferred call runs:
x := 1
defer fmt.Println(x)   // prints 1, not 2
x = 2
  1. To read the latest value, defer a closure:
defer func() { fmt.Println(x) }()   // prints 2

defer runs on normal returns and on panics. It does not run when os.Exit is called — that terminates the process immediately. If you defer f.Close() inside a loop, the file stays open until the function returns; extract the per-iteration work into a function, or collect the handles and close them together.

panic and recover

panic is for states the program cannot continue from — a missing required config, an impossible branch. It is not error handling:

func mustLoad(path string) *Config {
	cfg, err := loadConfig(path)
	if err != nil {
		panic(err)   // stops immediately, prints the stack
	}
	return cfg
}

recover only works inside a deferred function, and only makes sense at a process or request boundary such as an HTTP middleware or a worker loop:

defer func() {
	if r := recover(); r != nil {
		log.Printf("recovered: %v", r)
	}
}()

Rule of thumb: library code never recovers. Recovering hides the bug from the caller, who may have been perfectly able to handle it. Business failures are error values; panics are for programmer mistakes.

Common mistakes

1. Discarding errors with _

data, _ := os.ReadFile(p) turns a failure into a silent bug. At minimum log it.

2. Wrapping with %v instead of %w

The chain is broken and errors.Is stops matching.

3. Comparing error strings

Wrapping changes the text. Use errors.Is / errors.As.

4. Returning a custom error by value

errors.As(err, &target) will not find it. Return a pointer.

5. Expecting the deferred argument to be re-evaluated

It is captured at the defer statement. Use a closure.

6. Using panic for recoverable failures

Callers cannot branch on a panic, and the process dies.

7. Forgetting defer f.Close()

File descriptor leaks — especially dangerous inside loops.

Summary

  • Errors are values, returned last, nil for success.
  • Wrap with fmt.Errorf("context: %w", err); inspect with errors.Is and errors.As.
  • Middle layers add context; the top layer decides to recover or stop.
  • defer is LIFO with immediately-evaluated arguments; os.Exit skips it.
  • panic is for impossible states, recover only at process boundaries.

Next: slices and arrays — one is a value with a length in its type, the other is a descriptor over shared memory.