Functions
Function basics
Section titled “Function basics”A Go function declares its parameter types and return types explicitly. The return type appears after the parameter list:
func add(a int, b int) int { return a + b}
// Consecutive parameters of the same type can share a type annotation.// This is a second, equivalent spelling of the same function — pick one style per function.func add(a, b int) int { return a + b}Every argument in Go is passed by value — a copy of it — with no exception. That is easy to see for an int or a struct, but it is true for a slice too: a slice argument copies its small three-word header (pointer, length, capacity), not the backing array. That is why writing to s[i] inside a function is visible to the caller, while append-ing inside the function sometimes is not — the full explanation, with the header diagram, is in Slices.
Multiple return values
Section titled “Multiple return values”Go functions can return more than one value. The idiomatic pattern is (result, error) — the caller must handle or explicitly discard the error:
func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil}
result, err := divide(10, 3)if err != nil { log.Fatal(err)}fmt.Println(result)Named return values
Section titled “Named return values”Return values can be named. Named returns pre-declare variables and allow a bare return (a return with no arguments that returns the current values of the named variables). Use them sparingly — they are clearest in short functions where the names genuinely document the result:
func bounds(nums []int) (lo, hi int) { lo, hi = nums[0], nums[0] for _, v := range nums[1:] { if v < lo { lo = v } if v > hi { hi = v } } return // bare return: returns current lo, hi}Naming named results min and max would also compile — Go 1.21’s built-in min/max functions can be shadowed by a local name — but it is a smell: it hides the built-ins from the rest of the function body for no benefit.
Variadic functions
Section titled “Variadic functions”A variadic function accepts a variable number of arguments of the same type. Declare the last parameter with ...:
func sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total}
sum(1, 2, 3) // individual argssum(1, 2, 3, 4, 5) // any number of args workssum([]int{1,2,3}...) // spread a slice with ...Inside the function, nums is a []int slice. Spreading a nil slice works too and is not a special case: var none []int; sum(none...) passes an empty slice, and the loop simply runs zero times, returning 0. A nil slice behaves like a zero-length one everywhere it is read.
defer schedules a function call to run when the surrounding function returns, regardless of which return path is taken. It is the idiomatic way to clean up resources:
func readFile(name string) error { f, err := os.Open(name) if err != nil { return err } defer f.Close() // runs when readFile returns
// ... work with f ... return nil}Multiple deferred calls execute in LIFO order (last deferred, first to run).
flowchart TB
subgraph Push["Pushed in this order"]
direction LR
P1["defer close(0)"] --> P2["defer close(1)"] --> P3["defer close(2)"]
end
subgraph Pop["Run in this order at return (LIFO)"]
direction LR
R1["close(2)"] --> R2["close(1)"] --> R3["close(0)"]
end Arguments to a deferred call are evaluated immediately, at the defer statement — not when the deferred call actually runs:
i := 0defer fmt.Println(i) // captures i's value (0) right nowi = 100// when the function returns, this prints "0", not "100"defer and named results
Section titled “defer and named results”A deferred closure can read and even rewrite a named return value, because the named result is just a variable in scope for the whole function body — including its deferred calls. This is the standard way to annotate or transform an error right before it leaves a function:
func doWork() (err error) { defer func() { if err != nil { err = fmt.Errorf("doWork: %w", err) } }() return errors.New("boom")}doWork() returns errors.New("boom"), but by the time the caller sees it, the deferred function has already run and wrapped it — the call prints doWork: boom. This only works with a named result: a deferred function closing over a plain return err cannot change what the caller receives, because there is no shared variable for it to modify.
defer inside a loop
Section titled “defer inside a loop”defer runs at function return, not at the end of the loop iteration it was written in. Deferring inside a for loop is a classic source of resource leaks — every file, connection, or lock accumulates until the whole function exits, not each time around the loop:
func leaky(n int) { for i := 0; i < n; i++ { r := openResource(i) defer r.Close() // accumulates n deferred calls, all pending until leaky returns } // if n is large, this line runs with n resources still open}If leaky is itself a long-running function — say, one that processes thousands of files in a loop — every open file descriptor stays open until the very end, and the process can run out of them long before leaky returns. The fix is to give each iteration its own scope, typically a helper function, so defer fires per iteration:
func tidy(n int) { for i := 0; i < n; i++ { func() { r := openResource(i) defer r.Close() // runs at the end of this anonymous function, every iteration // ... use r ... }() }}A closely related bug is defer f.Close() silently discarding the error Close returns. For a read-only file this is usually harmless, but for a file you wrote to, Close can be the call that actually flushes buffered data — and a failed flush is a real error you just threw away:
func saveBuggy(f *os.File, data []byte) error { defer f.Close() // any error from Close is silently discarded _, err := f.Write(data) return err}
func saveFixed(f *os.File, data []byte) (err error) { defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = cerr // only report Close's error if Write didn't already fail } }() _, err = f.Write(data) return err}Closures and first-class functions
Section titled “Closures and first-class functions”Functions are first-class values in Go. You can assign them to variables, pass them as arguments, and return them from other functions:
// Function as a value in a variabledouble := func(n int) int { return n * 2 }fmt.Println(double(5)) // 10
// Function as a parameterapply := func(f func(int) int, x int) int { return f(x) }fmt.Println(apply(double, 3)) // 6A closure is a function that captures variables from its enclosing scope. The captured variable is shared — the closure sees mutations to it:
func counter() func() int { n := 0 return func() int { n++ // captures n from counter return n }}
c := counter()fmt.Println(c()) // 1fmt.Println(c()) // 2 — state is preserved inside the closurefmt.Println(c()) // 3Each call to counter() creates an independent n, so two counters do not interfere with each other.
Closures over loop variables: before and after Go 1.22
Section titled “Closures over loop variables: before and after Go 1.22”A closure created inside a for loop captures the loop variable itself, not a snapshot of its value at that iteration. Before Go 1.22, all iterations of a for shared one variable, so every closure ended up seeing whatever the variable held when it was finally called:
var fns []func()for _, v := range []int{0, 1, 2} { fns = append(fns, func() { fmt.Println(v) })}for _, f := range fns { f()}Compiled with go 1.21 in go.mod, this program prints 2 2 2 on Go 1.26.5 — every closure shares the same v, whose final value after the loop is 2. The traditional fix, still common in code written before 1.22 (and in anything an AI trained on that code produces), is to make a fresh copy inside the loop body: v := v before using v in the closure.
Go 1.22 changed the semantics: each iteration gets its own v, so the same source line — with only go.mod’s go directive changed to 1.22 — prints 0 1 2:
go 1.21 in go.mod → 2 2 2
go 1.22 in go.mod → 0 1 2Both lines above are real output from this exact program on Go 1.26.5, changing only the go line in go.mod between runs. The interactive playground below always targets a modern toolchain, so it will show the post-1.22 behavior; the go directive is what gates this change, per module, and it cannot be toggled from inside a single program. The v := v copy is unnecessary once a module’s go.mod says go 1.22 or later — but you will still see it in older code, and recognizing it tells you the author was guarding against the old behavior.
Function values, method values, and nil
Section titled “Function values, method values, and nil”A function type is just another type — you can declare a variable of it, store it in a struct field, or pass it as a value. Its zero value is nil, and a function value can only ever be compared against nil (comparing two non-nil function values with == is a compile error):
type IntFunc func(int) int
var f IntFuncfmt.Println(f == nil) // true
f(3) // panics: runtime error: invalid memory address or nil pointer dereferenceThis is the same shape of bug as a nil pointer dereference, and it shows up most often with optional callback fields on a struct (OnError func(error)) that a caller forgot to set. Always nil-check a function value before calling it unless the type’s contract guarantees it is always set.
package main
import ( "errors" "fmt")
// Multiple return valuesfunc divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil}
// Variadicfunc sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total}
// Closurefunc counter() func() int { n := 0 return func() int { n++ return n }}
// defer demofunc deferDemo() { fmt.Println("deferDemo start") defer fmt.Println("deferred 1 (runs last)") defer fmt.Println("deferred 2 (runs second)") defer fmt.Println("deferred 3 (runs first)") fmt.Println("deferDemo end")}
func main() { // Multiple returns result, err := divide(10, 3) if err != nil { fmt.Println("error:", err) } else { fmt.Printf("10 / 3 = %.4f\n", result) }
_, err = divide(5, 0) if err != nil { fmt.Println("divide by zero:", err) }
// Variadic fmt.Println("sum(1..5) =", sum(1, 2, 3, 4, 5))
// Closure counter c := counter() fmt.Println("counter:", c(), c(), c())
// defer LIFO deferDemo()} The second playground covers everything new on this page: named results modified by defer, defer inside a loop, a swallowed Close error and its fix, and nil function values.
package main
import ( "errors" "fmt")
// doWork shows a named result modified by a deferred func — a common way to// annotate an error right before it leaves the function.func doWork() (err error) { defer func() { if err != nil { err = fmt.Errorf("doWork: %w", err) } }() return errors.New("boom")}
type resource struct{ id int }
func openResource(id int) *resource { fmt.Println("open", id) return &resource{id}}
func (r *resource) Close() { fmt.Println("close", r.id) }
// leaky defers Close inside a loop — every resource stays open until leaky// itself returns, not at the end of each iteration.func leaky(n int) { for i := 0; i < n; i++ { r := openResource(i) defer r.Close() // accumulates n deferred calls } fmt.Println("all opened, none closed yet")}
type mockFile struct{ closeErr error }
func (f *mockFile) Write(p []byte) (int, error) { return len(p), nil }func (f *mockFile) Close() error { return f.closeErr }
// saveBuggy discards whatever error Close returns.func saveBuggy(f *mockFile, data []byte) error { defer f.Close() // any error from Close is silently discarded _, err := f.Write(data) return err}
// saveFixed captures Close's error into the named result if nothing else failed.func saveFixed(f *mockFile, data []byte) (err error) { defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }() _, err = f.Write(data) return err}
type IntFunc func(int) int
func sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total}
func main() { // Named result modified by defer. fmt.Println(doWork())
// defer inside a loop: resources pile up until the function returns. leaky(3)
// defer f.Close() silently swallowing a write/flush error, and the fix. f := &mockFile{closeErr: errors.New("disk full")} fmt.Println("saveBuggy:", saveBuggy(f, []byte("data"))) fmt.Println("saveFixed:", saveFixed(f, []byte("data")))
// A nil function value compares equal to nil, and calling it panics. var nilFn IntFunc fmt.Println("nil func == nil:", nilFn == nil)
// A nil slice spread into a variadic parameter behaves like an empty one. var nilSlice []int fmt.Println("sum(nilSlice...):", sum(nilSlice...))} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Multiple return values | Explicit error handling, no hidden exceptions | The caller must check every error — more verbose than try/catch |
| Named return values | Documents intent in the signature, enables bare return, lets defer rewrite the result | A bare return in a long function makes it harder to read |
defer | Cleanup code sits right next to resource acquisition | Arguments are evaluated immediately, LIFO order can surprise you, and it runs at function exit, not loop-iteration exit |
| Closures | Encapsulate state concisely | Captured variables are often allocated on the heap (escape analysis) |
| Pass-by-value everywhere | No hidden aliasing through function boundaries by default | Passing a large struct by value copies all of it; a slice or map argument still lets you mutate what it points to |
Common Misconceptions
Section titled “Common Misconceptions”- “
deferis slow.”deferhas very low overhead (nanosecond range) — it is safe to use in production code for cleanup. - “Multiple return values hurt performance.” Go returns multiple values directly via registers or the stack, with no heap allocation — the performance impact is negligible.
- “Every closure escapes to the heap.” The compiler performs escape analysis and may allocate a closure on the stack if it never escapes its scope.
- “Functions in Go are not first-class.” Functions in Go are first-class values — you can assign them, pass them as arguments, or return them from other functions.
- “
deferinside a loop cleans up each iteration.” It does not — every deferred call is pending until the enclosing function returns, not the loop.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated function uses defer, closures, or function values, check these before trusting it:
- Does a
defersit inside aforloop that can run more than a couple of times? Every iteration’s deferred call stays pending until the function returns, not the loop. - Does
defer f.Close()follow aWrite/Flush-style call, discarding whatever errorClosereturns? Look for a named result the deferred func could attach that error to instead. - Does a closure capture a loop variable, and if so, does the module’s
go.moddeclarego 1.22or later? Below that, every closure created in the loop shares one variable. - Is a function-typed value (a field, a parameter) called without checking it against
nilfirst? - Does the code compare two function values directly with
==(not againstnil)? That is a compile error — function types support no other comparison.
🐛 Spot the bug
func leaky(n int) { for i := 0; i < n; i++ { r := openResource(i) defer r.Close() } fmt.Println("all opened, none closed yet")}Show the answer
defer schedules r.Close() to run when leaky returns — not at the end of each loop iteration. With n large, this line runs with all n resources still open, and in a long-running program that can exhaust file descriptors or connections long before leaky returns. Fix it by giving each iteration its own scope so the deferred call fires per iteration, typically with a small helper function: func() { r := openResource(i); defer r.Close(); /* use r */ }() called once per loop iteration.