{} The Go Reference

Basics · Go · Beginner

Functions

Functions as first-class values — multiple and named returns, variadic params, closures, higher-order functions, and defer.

Basics Beginner ⏱ 9 min read Complete

🔧 Analogy

A function is a labeled machine: inputs go in, outputs come out. In Go that machine is also a value — you can store it in a variable, pass it to another function, and return it. That one idea (functions as first-class values) quietly powers callbacks, middleware, and most of the Strategy pattern.

The mental model: a function is a value

Most languages treat functions as a special syntactic category. Go treats them as ordinary values that happen to be callable. A function has a type (func(int) int, func(string) (int, error)), you can put one in a variable, store it in a slice or map, pass it as an argument, and return it from another function. Everything in this page follows from that single fact.

A declaration like func add(a, b int) int does two things: it creates a value of type func(int, int) int and binds the name add to it. The signature is the contract — the parameter types, the result types, and whether the last parameter is variadic. Two functions have the same type when their parameter and result types match; names don’t matter to the type.

Multiple and named returns

Go functions can return several values — most famously a result paired with an error:

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

Returns can be named, which documents them and enables a bare return:

graph LR
IN["a, b float64"] --> FN["divide"]
FN --> OUT1["float64 (quotient)"]
FN --> OUT2["error (nil on success)"]

Named results are pre-declared as zero-valued variables at the top of the function. A bare return (no values listed) ships whatever those variables currently hold. They have one genuinely useful role: a deferred closure can read and rewrite a named result after the return statement runs but before the function actually exits — the standard way to wrap or annotate an error on the way out.

⚠️ The naked-return caveat

Named returns plus bare return read nicely on a three-line function and become a liability on a thirty-line one: the reader has to scroll up to learn what the bare return actually sends back, and an early return can ship a half-set result by accident. The idiom is: name results when a defer needs to touch them or when the names genuinely document the signature; otherwise return explicit values. Never mix a long body with naked returns.

named-returns.go — editable & runnable
package main

import "fmt"

// divmod returns NAMED results. Named returns document the signature
// and allow a bare "return" that ships the current values of q and r.
func divmod(a, b int) (q, r int) {
q = a / b
r = a % b
return // bare return: returns q and r as they stand now
}

// safeDiv shows the naked-return CAVEAT mixed with defer:
// a deferred closure can read and rewrite a named result before
// it leaves the function.
func safeDiv(a, b int) (result int, err error) {
defer func() {
	if r := recover(); r != nil {
		err = fmt.Errorf("recovered: %v", r)
	}
}()
result = a / b // panics if b == 0
return
}

func main() {
q, r := divmod(17, 5)
fmt.Println("17 / 5 =", q, "remainder", r) // 3 remainder 2

if res, err := safeDiv(10, 2); err == nil {
	fmt.Println("10 / 2 =", res) // 5
}

if _, err := safeDiv(10, 0); err != nil {
	fmt.Println("error:", err) // recovered: integer divide by zero
}
}

Variadic parameters and slice spread

A trailing ...T parameter is variadic: callers pass zero or more T values, and inside the function the parameter is a []T. Only the last parameter may be variadic, and a fixed parameter can precede it. To pass an existing slice, spread it with xs... — that hands the slice straight through (no copy of the elements into a new slice). You cannot mix spread with extra positional arguments: it’s f(xs...) or f(a, b, c), never f(a, xs...).

variadic.go — editable & runnable
package main

import (
"fmt"
"strings"
)

// largest returns the biggest of its arguments, or 0 for none.
// Variadic: callers pass any number of ints, received as a []int.
func largest(nums ...int) int {
best := 0
for _, n := range nums {
	if n > best {
		best = n
	}
}
return best
}

// join mixes a fixed parameter with a variadic one:
// the variadic part must come last.
func join(sep string, parts ...string) string {
return strings.Join(parts, sep)
}

func main() {
fmt.Println(largest(3, 7, 2)) // 7
fmt.Println(largest())        // 0 — zero args is fine; nums is nil

xs := []int{4, 9, 1}
fmt.Println(largest(xs...)) // 9 — spread a slice with xs...

fmt.Println(join("-", "a", "b", "c")) // a-b-c

words := []string{"go", "is", "fun"}
fmt.Println(join(" ", words...)) // go is fun
}

Inside a variadic function, passing no arguments yields a nil slice (not an empty non-nil one), which ranges zero times — so guard with len(nums) if you need to tell “none” from “all zero.” This is exactly how fmt.Println(a ...any) and append(s, elems...) work.

First-class and higher-order functions

Because a function is a value, you can take one as a parameter (a higher-order function), store it, and return a brand-new function built from others. This is the engine behind callbacks, middleware, comparators (sort.Slice), and the Strategy pattern. A func(int) int in a map[string]func(int) int is just a dispatch table.

higher-order.go — editable & runnable
package main

import "fmt"

// mapInts is a higher-order function: it takes a function as data and
// applies it to every element, returning a new slice.
func mapInts(xs []int, f func(int) int) []int {
out := make([]int, len(xs))
for i, x := range xs {
	out[i] = f(x)
}
return out
}

// compose returns a NEW function built from two others — functions as
// both input and output.
func compose(f, g func(int) int) func(int) int {
return func(x int) int { return f(g(x)) }
}

func main() {
nums := []int{1, 2, 3, 4}

double := func(x int) int { return x * 2 }
square := func(x int) int { return x * x }

fmt.Println(mapInts(nums, double)) // [2 4 6 8]
fmt.Println(mapInts(nums, square)) // [1 4 9 16]

// Build "double then square" without naming the result.
dsq := compose(square, double)
fmt.Println(dsq(3)) // square(double(3)) = square(6) = 36

// A function type as a value stored in a map (a tiny dispatch table).
ops := map[string]func(int) int{
	"double": double,
	"square": square,
}
fmt.Println(ops["double"](5)) // 10
}

You can name a function type to make signatures readable: type IntOp func(int) int lets you write func compose(f, g IntOp) IntOp. Named function types are also where you hang methods on functions — that’s how http.HandlerFunc turns a plain function into something that satisfies the http.Handler interface.

Closures and exactly what they capture

A function literal can refer to variables declared in its surrounding scope; doing so makes it a closure. The crucial detail: a closure captures variables by reference, not by value. It does not snapshot the variable’s current contents — it captures the variable itself, keeps it alive as long as the closure exists, and sees every later change. Two closures that close over the same variable share it and see each other’s writes.

closure.go — editable & runnable
package main

import "fmt"

// counter returns a closure that owns its own n.
// The variable n is captured BY REFERENCE: the closure keeps it alive
// and every call mutates the same n.
func counter(start int) func() int {
n := start
return func() int {
	n++
	return n
}
}

func main() {
c := counter(10)
fmt.Println(c(), c(), c()) // 11 12 13

// A second counter has its OWN independent n.
d := counter(100)
fmt.Println(d()) // 101
fmt.Println(c()) // 14 — c's n is unaffected

// Two closures can share ONE variable — they see each other's writes.
total := 0
add := func(x int) { total += x }
get := func() int { return total }
add(3)
add(4)
fmt.Println(get()) // 7
}

Under the hood, when a captured variable outlives the function that declared it (as n does — it’s returned inside a closure), the compiler’s escape analysis moves it from the stack to the heap so it stays valid. You don’t manage that; it just works. The cost is one small allocation per closure that captures escaping state.

The classic loop-variable capture bug

By reference is exactly what you want for a counter and exactly what bit everyone in loops before Go 1.22. The trap: a loop created closures, each capturing the loop variable, and because all iterations shared one variable, every closure ended up reading its final value.

Go 1.22 fixed this: the loop variable is now a fresh copy per iteration, so each closure captures a distinct value. The example below reproduces the old trap on purpose (using a deliberately shared variable) and then shows the per-iteration fix.

🤔 loop-capture.go — predict, then run
package main

import "fmt"

func main() {
// THE CLASSIC LOOP-CAPTURE BUG (pre-Go 1.22 behavior).
// Here we DELIBERATELY share one variable to reproduce the old trap:
// every closure captures the SAME variable. What do they return?
funcs := make([]func() int, 0, 3)
shared := 0
for shared = 0; shared < 3; shared++ {
	funcs = append(funcs, func() int { return shared })
}
for _, f := range funcs {
	fmt.Print(f(), " ")
}
fmt.Println()

// THE FIX (and the Go 1.22+ default for loop variables):
// give each iteration its OWN variable, so each closure captures a
// distinct value.
fixed := make([]func() int, 0, 3)
for i := 0; i < 3; i++ {
	i := i // shadow: a fresh i per iteration (1.22 does this for you)
	fixed = append(fixed, func() int { return i })
}
for _, f := range fixed {
	fmt.Print(f(), " ")
}
fmt.Println()
}

🤔 What will this print? Commit to a prediction before revealing — type it below for an automatic check, or just decide in your head.

On Go 1.22+ a normal for i := 0; i < 3; i++ { funcs = append(funcs, func() int { return i }) } already prints 0 1 2 without the manual i := i shadow — the per-iteration copy is automatic. The shared variable above is the only way to still demonstrate the old behavior.

defer, panic, and recover

A defer statement schedules a call to run when the surrounding function returns — whether it returns normally or unwinds through a panic. This is Go’s answer to try/finally: pair an acquire with its release right next to it (f, _ := os.Open(...), then defer f.Close()) so cleanup can’t be forgotten. Key rules:

  • Deferred calls run in LIFO order (last deferred, first run).
  • A deferred call’s arguments are evaluated when defer runs, not when the call fires — so defer fmt.Println(i) captures i’s value now, but defer func() { fmt.Println(i) }() reads i at exit.
  • A deferred closure can read and modify named return values — the basis of recover-and-wrap.

A panic unwinds the stack, running deferreds as it goes; recover, called inside a deferred function, stops that unwinding and returns the panic value. Reserve this for truly exceptional situations (or for turning a library’s panic into an error at a boundary). For ordinary failure, return an error — see errors and the deeper panic & recover page. The safeDiv example above is a complete recover-to-error pattern.

Recursion and stack depth

A function may call itself. Go gives each goroutine a small growable stack (it starts around a few kilobytes and the runtime grows it on demand), so reasonable recursion is fine — but Go does not guarantee tail-call optimization, so deeply or infinitely recursive code grows the stack until it hits the limit and the program panics with a stack-overflow error. For data of unbounded depth, prefer an explicit stack/loop, or ensure the recursion depth is bounded by your input size.

func factorial(n int) int {
	if n <= 1 { // base case stops the recursion
		return 1
	}
	return n * factorial(n-1)
}

Methods, method values, and method expressions

A method is a function with a special receiver parameter — func (u User) Name() string. That’s how Go attaches behavior to types without classes; the full story lives in methods. Two function-flavored forms are worth knowing here:

  • A method value binds a method to a specific receiver, producing a plain function value: greet := user.Name captures user, so greet() calls user.Name(). Great for passing as a callback.
  • A method expression leaves the receiver unbound: f := User.Name has type func(User) string, and you call f(user).

Both turn methods into ordinary function values you can store and pass — closing the loop back to “a function is a value.”

When to use what

You want…Reach for
Report success/failurea trailing error return — (T, error)
Document or defer-mutate resultsnamed return values
Accept any number of argsa variadic ...T parameter
Forward a slice to a variadicspread it: f(xs...)
Private state between callsa closure over a local variable
Pluggable behavior / callbacksa function parameter (higher-order)
Guaranteed cleanupdefer next to the acquire
Turn a method into a callbacka method value (x.M)

🐹 Common function pitfalls

  • Closures capture variables, not values. A closure created in a loop sees the variable’s final value unless each iteration has its own copy (automatic since Go 1.22; use x := x on older Go).
  • defer arguments are evaluated immediately. defer log(time.Now()) records the time of the defer, not of the function’s exit. Wrap in func(){ ... }() to defer the evaluation too.
  • Naked returns in long functions hide what comes back; name results only when short or when a defer rewrites them.
  • No tail-call optimization — unbounded recursion overflows the stack; loop or bound the depth.
  • A nil func value panics when called — its zero value is nil; check before invoking an optional callback.

See also

  • Pointers — value vs reference semantics, and why a pointer lets a function mutate its caller’s data.
  • Methods — functions with receivers, value vs pointer receivers, method values and expressions.
  • Errors — the (result, error) convention these functions return.
  • Panic & recover — the full mechanics behind defer/recover.
  • Closures in the Strategy pattern — first-class functions as pluggable behavior.

Next: values, addresses, and the difference between them — pointers.

Check your understanding

Score: 0 / 5

1. What is Go's idiomatic way to report whether a function succeeded?

Go functions return errors as ordinary values, conventionally the last return value. The caller checks if err != nil. There are no exceptions for normal error handling; panic/recover is reserved for truly exceptional conditions.

2. What does a closure capture?

A closure is a function value that captures variables from where it was defined, by reference. It keeps them alive and sees their updates, which is the basis of stateful function values like counters.

3. What does ...int mean in func sum(nums ...int)?

A trailing ...T parameter is variadic: callers pass zero or more T values, and inside the function nums is a []int. Spread an existing slice with sum(xs...).

4. In Go 1.22+, what happens to the loop variable on each iteration of a for loop?

Go 1.22 changed for-loop semantics so the loop variable is per-iteration. This fixes the classic bug where every closure created in a loop captured the same variable and all saw its final value.

5. When does a deferred call run, and in what order for multiple defers?

Deferred calls run as the enclosing function unwinds — on a normal return or a panic — in LIFO order. Their arguments are evaluated when defer executes, but the call itself is delayed.

Comments

Sign in with GitHub to join the discussion.