Two Values and a Promise: Functions and defer

Functions in Go — multiple return values (the habit that makes error handling work), named returns, variadics, and closures — plus defer, the cleanup mechanism that runs in LIFO order on return, evaluates its arguments early, and keeps cleanup next to acquisition. Compiled and run against Go 1.26.5.

Functions are where a language’s ergonomics show, and Go’s have two features that flavor everything you’ll write in the language: functions can return more than one value, which is the mechanism that makes Go’s error handling work without exceptions, and defer, which schedules cleanup to run when a function returns. Neither is exotic, but both shape idiomatic Go strongly enough that you can’t read real code without them. We’ll cover the ordinary function machinery first, then spend real time on multiple returns and on defer, including the couple of edges that catch people.

Everything here is compiled and run against Go 1.26.5.

Multiple return values

A Go function can return several values, and this is not a curiosity — it’s load-bearing. Go has no exceptions. Instead, a function that can fail returns its result and an error, side by side, and the caller checks the error before trusting the result:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("42")
	if err != nil {
		fmt.Println("bad number:", err)
		return
	}
	fmt.Println("parsed:", n)

	_, err = strconv.Atoi("nope")
	fmt.Println("second err:", err)
}
$ go run .
parsed: 42
second err: strconv.Atoi: parsing "nope": invalid syntax

strconv.Atoi returns (int, error). On success the error is nil and the int is your value; on failure the error is non-nil and describes what went wrong. The if err != nil check right after the call is the single most common shape in Go — you’ll write it hundreds of times, and yes, it’s the thing people complain about. The trade is that failure is an ordinary value sitting in plain sight, not a control-flow jump you have to reason about separately. There’s nothing invisible: you can see every place a function can fail because the error is right there in its signature.

Two small notes from that snippet. The blank identifier _ in _, err = strconv.Atoi("nope") discards the int you don’t want — every return value must be accounted for, and _ is how you say “throw this one away.” And the second call uses = rather than := because err already exists; only a := with at least one new name on the left is allowed. Error handling itself — wrapping, sentinel errors, errors.Is — is a chapter of its own later; here the point is just the shape of the return.

Named return values

You can name a function’s return values in its signature. When you do, those names are declared as ordinary variables inside the function, pre-set to their zero values, and a bare return sends back whatever they currently hold:

package main

import "fmt"

// named returns: result and err are declared for you, zero-valued
func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = fmt.Errorf("divide by zero")
		return // "naked" return: sends back current result, err
	}
	result = a / b
	return
}

func main() {
	r, err := divide(10, 2)
	fmt.Println(r, err)
	r, err = divide(10, 0)
	fmt.Println(r, err)
}
$ go run .
5 <nil>
0 divide by zero

Named returns can document intent — (result int, err error) tells the reader what comes back without a comment — and they pair usefully with defer when a deferred function needs to inspect or modify the return value (a trick we’ll use for error wrapping later). But the “naked” return with no operands gets unreadable fast in a long function, where you have to scroll up to learn what’s being returned. The working guidance: name returns when it genuinely aids clarity or when a defer needs to touch them, and keep the functions short enough that a bare return is still obvious. Don’t reach for them by default.

Variadic functions

A function can accept a variable number of trailing arguments by marking the last parameter with .... Inside the function that parameter is an ordinary slice:

package main

import "fmt"

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

func main() {
	fmt.Println(sum(1, 2, 3))
	fmt.Println(sum())

	xs := []int{4, 5, 6}
	fmt.Println(sum(xs...)) // spread a slice into a variadic call
}
$ go run .
6
0
15

Call sum(1, 2, 3) and nums is []int{1, 2, 3}; call sum() with nothing and nums is an empty (nil) slice, which ranges zero times and gives 0 — the useful zero value at work again. And when you already have a slice, sum(xs...) spreads it into the call rather than passing it as a single element. This is exactly how fmt.Println and friends accept any number of arguments.

Functions are values, and closures

Functions in Go are first-class: you can assign one to a variable, pass it as an argument, return it from another function, and store it in a struct. A function that captures variables from the scope where it was created is a closure, and the captured variables live on as long as the closure does:

package main

import "fmt"

// counter returns a function that closes over its own count
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

func main() {
	next := counter()
	fmt.Println(next(), next(), next())

	other := counter() // independent state
	fmt.Println(other())
}
$ go run .
1 2 3
1

Each call to counter creates a fresh count, and the returned function keeps a live reference to that one — so next counts 1, 2, 3 while other, built from a separate call, starts its own count at 1. The count variable has escaped the function that declared it and now lives for as long as the closure holding it does; Go’s garbage collector tracks that automatically. Closures are how you write things like custom sort comparisons, middleware, and lazy generators without dragging in a class to hold one field of state.

defer: cleanup that runs on return

defer schedules a function call to run when the surrounding function returns — no matter how it returns, whether by reaching the end, an early return, or a panic unwinding through it. That last part is what makes it reliable: the cleanup happens on every exit path, so you write it once. Deferred calls run in last-in, first-out order:

package main

import "fmt"

func main() {
	fmt.Println("start")
	defer fmt.Println("deferred 1")
	defer fmt.Println("deferred 2")
	defer fmt.Println("deferred 3")
	fmt.Println("end")
}
$ go run .
start
end
deferred 3
deferred 2
deferred 1

start and end print during the normal flow; then, as main returns, the three deferred calls fire in reverse of the order they were registered — 3, 2, 1. LIFO is the right default for cleanup, because resources usually nest: if you open A then B, you want to close B then A, and stacking the defers gives you exactly that unwinding order for free.

Deferred arguments are evaluated now

Here’s the edge that surprises everyone once. When you write defer f(x), the arguments to f are evaluated at the moment the defer statement runs, not when the deferred call finally executes. Only the call itself is postponed:

package main

import "fmt"

func main() {
	i := 0
	defer fmt.Println("deferred sees i =", i) // argument evaluated NOW, at the defer
	i = 100
	fmt.Println("current i =", i)
}
$ go run .
current i = 100
deferred sees i = 0

The deferred Println prints 0, not 100, because i was 0 when the defer line executed and its value was captured right then. Change i afterward all you like; the deferred call already has its argument. (If you want the later value, defer a closure with no arguments — defer func() { fmt.Println(i) }() — and it reads i when it runs, giving 100. The difference between passing a value and closing over a variable is the whole story here.)

The Close idiom, and the loop pitfall

Where defer earns its keep is resource cleanup. Acquire something, then immediately defer its release, and the two lines sit next to each other so you can’t forget the second one — no matter what happens in between:

func writeStamp(path string) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close() // cleanup pinned right next to the acquire

	_, err = f.WriteString("stamped\n")
	return err
}
$ go run .
wrote "stamped\n"

Whether WriteString succeeds or the function returns early, f.Close() runs. Compared with hand-writing a close on every exit path, this is both shorter and more correct, and it’s why defer is everywhere in Go that touches a file, a lock, or a connection.

The one pitfall to know: defer is scoped to the function, not the block or the loop iteration. Defer inside a loop and nothing is released until the whole function returns:

func processAll(names []string) {
	for _, name := range names {
		fmt.Println("opening", name)
		defer fmt.Println("closing", name)
	}
	fmt.Println("loop done")
}
$ go run .
opening a
opening b
opening c
loop done
closing c
closing b
closing a

All three “opening” lines run, the loop finishes, and only then do the three “closing” defers fire. With print statements that’s just curious; with real file handles it means every file stays open until the function ends, and a long-running loop can exhaust the process’s file descriptors. The fix is to give each iteration its own function scope — pull the body into a helper, or wrap it in an immediately-called func() { ... }() — so the defer fires per iteration. When you defer inside a loop, pause and ask whether you meant “at the end of this iteration” rather than “at the end of the function.”

Final thoughts

Functions are Go’s core unit of work, and two of their traits set the tone for the language. Multiple return values put success and failure side by side, which is how Go does error handling without exceptions — visible, ordinary, checked at the call site. And defer gives you cleanup that runs on every exit path in LIFO order, with arguments captured at the defer line and a scope that is the whole function, not the loop. Name your returns only when they clarify, spread slices into variadics with ..., and reach for closures when you need a little live state without a type to hold it. Next we cover control flow — Go’s single loop keyword, its switch, and the small surprises in if and for.

Next: control flow with one loop keyword — how for does the work of while, and why switch doesn’t fall through.

Comments