🔧 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.
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...).
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.
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.
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.
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
deferruns, not when the call fires — sodefer fmt.Println(i)capturesi’s value now, butdefer func() { fmt.Println(i) }()readsiat 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.Namecapturesuser, sogreet()callsuser.Name(). Great for passing as a callback. - A method expression leaves the receiver unbound:
f := User.Namehas typefunc(User) string, and you callf(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/failure | a trailing error return — (T, error) |
Document or defer-mutate results | named return values |
| Accept any number of args | a variadic ...T parameter |
| Forward a slice to a variadic | spread it: f(xs...) |
| Private state between calls | a closure over a local variable |
| Pluggable behavior / callbacks | a function parameter (higher-order) |
| Guaranteed cleanup | defer next to the acquire |
| Turn a method into a callback | a 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 := xon older Go). deferarguments are evaluated immediately.defer log(time.Now())records the time of thedefer, not of the function’s exit. Wrap infunc(){ ... }()to defer the evaluation too.- Naked returns in long functions hide what comes back; name results only when short or when a
deferrewrites 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.
Related topics
if with an init statement, the single for loop in all its forms, switch without fall-through, labeled break, and defer.
basicsPointers& and *, value vs reference semantics, nil, when a function needs a pointer to mutate, and why Go has no pointer arithmetic.
idiomsErrorsErrors are values you return and check — sentinels, wrapping with %w, and matching through the chain with errors.Is, As, and Join.
Check your understanding
Score: 0 / 51. 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.