PidokuInfra

Functions, Errors and Defer

Foundations Beginner 50 min Difficulty 2/5 Topic 03 of 05

Prerequisites 02

The idea in one minute#

Functions are values: they can be stored, passed and returned, and a function literal can capture variables from around it (a closure). Functions can return several values, and by convention the last one is an error.

Go has no exceptions for ordinary failure. An error is a value you return and check. defer schedules a call to run when the surrounding function returns — the mechanism for cleanup. panic exists for bugs and impossible states, not for expected failures.

An analogy#

A relay race. Each runner hands the baton to the next and says either “all good” or “I dropped it at the second bend”. Nobody throws the baton into the crowd and hopes someone further up the track catches it. And every runner, whatever happened, walks off the track when their leg is over — that is defer.

A picture#

flowchart TB
  CALL["load(path)"] --> OPEN["f, err := os.Open(path)"]
  OPEN --> E1{"err != nil?"}
  E1 -->|"yes"| WRAP["return fmt.Errorf('load %s: %w', path, err)"]
  E1 -->|"no"| DEF["defer f.Close()<br/>registered, not run yet"]
  DEF --> WORK["read, parse"]
  WORK --> E2{"err != nil?"}
  E2 -->|"yes"| WRAP
  E2 -->|"no"| RET["return result, nil"]
  WRAP --> RUN["deferred calls run<br/>last in, first out"]
  RET --> RUN
  RUN --> CALLER["caller checks err<br/>errors.Is / errors.As"]
  class CALL,CALLER neutral
  class OPEN,WORK,RET compute
  class E1,E2 queue
  class WRAP warn
  class DEF,RUN memory

How it really works#

Functions#

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

func sum(xs ...int) int { /* ... */ }               // variadic: xs is a []int
apply := func(f func(int) int, v int) int { return f(v) }   // functions are values

A closure captures variables by reference, not by value: the function literal and the enclosing function share the same variable.

Since Go 1.22, each iteration of a for loop has its own copy of the loop variable, so closures and goroutines started in a loop capture what you expect. Before 1.22 all iterations shared one variable — the single most common Go bug of its first decade. for i := range 10 (range over an integer) arrived in the same release.

Errors are values#

error is an ordinary interface:

Go
type error interface { Error() string }
ToolUse
errors.New("msg"), fmt.Errorf("...")Create an error
fmt.Errorf("read config: %w", err)Wrap: add context, keep the original reachable
errors.Is(err, fs.ErrNotExist)Is this error, or anything it wraps, that specific value?
errors.As(err, &target)Is there an error of this type in the chain? Extract it
errors.Join(e1, e2)Combine several errors
A sentinel: var ErrNotFound = errors.New("not found")A value callers compare against
A custom type with fieldsCarries data (which key, which status code)

Rules that keep error handling readable:

  1. Handle an error once. Either return it (with context) or deal with it (log, retry, fall back). Doing both produces duplicate log lines.
  2. Add context on the way up: fmt.Errorf("load model %q: %w", name, err). The final message reads like a stack of what was being attempted.
  3. Compare with errors.Is/errors.As, never == or string matching, because errors get wrapped.

Go 1.26 added errors.AsType, a generic form that needs no pre-declared target:

Go
if pe, ok := errors.AsType[*fs.PathError](err); ok {   // Go 1.26+
    fmt.Println("failed path:", pe.Path)
}

Defer#

defer f(x) evaluates f and x now and runs the call when the surrounding function returns — by any path, including a panic. Deferred calls run last-in, first-out.

Go
mu.Lock()
defer mu.Unlock()          // paired where you can see both

f, err := os.Open(name)
if err != nil { return err }
defer f.Close()            // only after the error check: f is nil on failure

A deferred closure can read and modify named results, which is how a function adds context to, or recovers from, its own failure. Defer is cheap — a few nanoseconds in modern Go — so use it for correctness and reach for manual cleanup only in a measured hot loop.

Panic and recover#

panic unwinds the stack, running deferred calls, and crashes the program unless a deferred function calls recover. Use it for programmer errors (an impossible state, an index out of range). A server typically recovers once at the top of each request handler so one bad request cannot take the process down, logs the stack, and returns a 500. Do not use panic as a substitute for returning errors across package boundaries.

Code#

Go
// errors.go — wrapping, inspecting, defer order, and recovering from a panic.
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

var ErrEmpty = errors.New("empty input") // a sentinel

// ParseError is an error type that carries data.
type ParseError struct {
	Line int
	Msg  string
}

func (e *ParseError) Error() string { return fmt.Sprintf("line %d: %s", e.Line, e.Msg) }

func parse(lines []string) error {
	if len(lines) == 0 {
		return ErrEmpty
	}
	for i, l := range lines {
		if l == "" {
			return &ParseError{Line: i + 1, Msg: "blank line"}
		}
	}
	return nil
}

func load(name string, lines []string) (err error) {
	// A deferred closure sees and can change the named result.
	defer func() {
		if err != nil {
			err = fmt.Errorf("load %q: %w", name, err)
		}
	}()
	if _, statErr := os.Stat(name); statErr != nil {
		return statErr
	}
	return parse(lines)
}

func safely(f func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	f()
	return nil
}

func main() {
	self, _ := os.Executable()

	for _, c := range []struct {
		name  string
		lines []string
	}{
		{"/no/such/file", []string{"a"}},
		{self, nil},
		{self, []string{"a", "", "c"}},
		{self, []string{"a", "b"}},
	} {
		err := load(c.name, c.lines)
		var pe *ParseError
		switch {
		case err == nil:
			fmt.Println("ok")
		case errors.Is(err, fs.ErrNotExist):
			fmt.Println("missing file  →", err)
		case errors.Is(err, ErrEmpty):
			fmt.Println("sentinel      →", err)
		case errors.As(err, &pe):
			fmt.Println("typed, line", pe.Line, "→", err)
		}
	}

	fmt.Print("\ndefer order: ")
	func() {
		for i := 1; i <= 3; i++ {
			defer fmt.Print(i, " ") // arguments are evaluated now, calls run in reverse
		}
	}()
	fmt.Println()

	var xs []int
	fmt.Println("panic →", safely(func() { _ = xs[5] }))

	// Since Go 1.22 each iteration has its own i.
	var fns []func() int
	for i := 0; i < 3; i++ {
		fns = append(fns, func() int { return i })
	}
	fmt.Print("closures see: ")
	for _, f := range fns {
		fmt.Print(f(), " ")
	}
	fmt.Println()
}

Remember this#

  • Functions return errors as ordinary values; the caller checks them.
  • Wrap with %w to add context; inspect with errors.Is and errors.As.
  • defer runs at function return, in reverse order, even during a panic.
  • panic is for bugs. Recover at a boundary, not everywhere.

Try it#

  1. Run errors.go. Change %w to %v in load. Which cases stop matching, and why?
  2. Write a function that opens two files and closes both correctly on every path.
  3. Set go 1.21 in a go.mod and run the closure loop. What does it print?

Check yourself#

  1. What is the difference between errors.Is and errors.As?
  2. When are a deferred call’s arguments evaluated?
  3. Why should an error be either returned or handled, but not both?

↑↓ navigate↵ openesc close