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 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:
- Multiple
defers run last-in-first-out. - Arguments are evaluated at the
deferstatement, not when the deferred call runs:
x := 1
defer fmt.Println(x) // prints 1, not 2
x = 2
- 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,
nilfor success. - Wrap with
fmt.Errorf("context: %w", err); inspect witherrors.Isanderrors.As. - Middle layers add context; the top layer decides to recover or stop.
deferis LIFO with immediately-evaluated arguments;os.Exitskips it.panicis for impossible states,recoveronly 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.