Go · lesson 7 of 25
Functions in Go
Go functions run for real: multiple and named returns, variadic calls, closures and the loop-variable change, defer order and arguments, and init.
By SkillByExample · Updated
A Go function can return several values, take a variable number of arguments, be stored in a variable, and schedule cleanup with defer. Every example is a full program.
Parameters and multiple returns
Consecutive parameters of the same type can share it, and a function can return more than one value.
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
if q, err := divide(10, 4); err == nil {
fmt.Println("10/4 =", q)
}
q, err := divide(1, 0)
fmt.Println(q, err)
}
$ go run multi.go
10/4 = 2.5
0 division by zero
(float64, error) is the most common signature in Go: a result plus an error that is nil on success. The caller must receive both values (or discard one with _) — q := divide(1, 0) does not compile, which makes ignoring an error a visible choice.
Named results
Results can be named. They start at their zero values, and a bare return returns their current values.
package main
import "fmt"
func stats(nums []int) (minV, maxV, sum int) {
if len(nums) == 0 {
return // all three are 0
}
minV, maxV = nums[0], nums[0]
for _, n := range nums {
minV = min(minV, n)
maxV = max(maxV, n)
sum += n
}
return
}
func main() {
fmt.Println(stats([]int{4, 9, 1, 7}))
fmt.Println(stats(nil))
}
$ go run named.go
1 9 21
0 0 0
Names document what each result means, which helps when they share a type. Bare returns save little in longer functions and make it harder to see what is returned; most Go style guides keep them for short functions like this one.
Variadic functions
...T as the last parameter accepts zero or more T values, which arrive as a []T.
package main
import "fmt"
func sum(label string, nums ...int) {
total := 0
for _, n := range nums {
total += n
}
fmt.Println(label, len(nums), total, nums == nil)
}
func main() {
sum("none:")
sum("three:", 1, 2, 3)
scores := []int{10, 20}
sum("spread:", scores...)
}
$ go run variadic.go
none: 0 0 true
three: 3 6 false
spread: 2 30 false
With no arguments, nums is a nil slice — and ranging over it is fine. scores... passes an existing slice directly instead of listing its elements; the function then shares that slice’s backing array, so if it modified nums[0], scores[0] would change too.
Functions as values
A function is a value with a type such as func(int, int) int, so it can be stored, passed and returned.
package main
import "fmt"
func apply(nums []int, f func(int) int) []int {
out := make([]int, 0, len(nums))
for _, n := range nums {
out = append(out, f(n))
}
return out
}
func main() {
ops := map[string]func(int, int) int{
"add": func(a, b int) int { return a + b },
"mul": func(a, b int) int { return a * b },
}
fmt.Println(ops["add"](3, 4), ops["mul"](3, 4))
square := func(n int) int { return n * n }
fmt.Println(apply([]int{1, 2, 3}, square))
fmt.Printf("%T\n", square)
}
$ go run values.go
7 12
[1 4 9]
func(int) int
The map holds functions keyed by name, and apply takes one as a parameter. The zero value of a function type is nil; calling a nil function panics.
Closures
A function literal that uses variables from its surrounding scope captures the variable, not a copy of its value.
package main
import "fmt"
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
a, b := counter(), counter()
fmt.Println(a(), a(), a())
fmt.Println(b())
}
$ go run closure.go
1 2 3
1
count outlives the call to counter because the returned function still refers to it. Each call to counter creates a separate count, so b starts again at 1.
Because closures capture variables, the loop variable matters. Since Go 1.22, each iteration gets its own copy. This program lives in a module, so go run . builds it under the version in go.mod:
package main
import "fmt"
func main() {
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Print(i, " ") })
}
for _, f := range fns {
f()
}
fmt.Println()
}
$ cat go.mod
module example.com/loopvar
go 1.24
$ go run .
0 1 2
$ cat go.mod
module example.com/loopvar
go 1.21
$ go run .
3 3 3
The same source prints 0 1 2 under the Go 1.22+ rules and 3 3 3 when the module’s go line says 1.21: the language version, not the compiler version, decides the semantics. Under the old rules all three closures shared one i, which had reached 3 by the time they ran. You’ll still meet the i := i workaround in older code; it is harmless but no longer needed.
defer
defer schedules a call to run when the surrounding function returns. Deferred calls run last-in, first-out, and their arguments are evaluated at the defer statement.
package main
import "fmt"
func main() {
x := 1
defer fmt.Println("deferred with x =", x)
defer func() { fmt.Println("closure sees x =", x) }()
for i := range 3 {
defer fmt.Println("loop", i)
}
x = 99
fmt.Println("end of main")
}
$ go run defer.go
end of main
loop 2
loop 1
loop 0
closure sees x = 99
deferred with x = 1
Read the output bottom-up against the code. The loop’s defers were registered last, so they run first, in reverse order. The closure prints 99 because it reads x when it runs; the first defer prints 1 because x was evaluated when the defer statement executed. That difference is the classic defer surprise.
defer is how Go guarantees cleanup — closing a file, unlocking a mutex — even when the function returns early or panics:
package main
import "fmt"
func risky(n int) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
defer fmt.Println(" cleanup runs for", n)
nums := []int{1, 2, 3}
return fmt.Errorf("value %d", nums[n])
}
func main() {
fmt.Println(risky(1))
fmt.Println(risky(5))
}
$ go run deferpanic.go
cleanup runs for 1
value 2
cleanup runs for 5
recovered: runtime error: index out of range [5] with length 3
nums[5] panics, yet both deferred calls still run. The recovering closure assigns to the named result err, which is the only way a deferred function can change what the caller receives. Use recover sparingly — the error handling lesson covers when panics are appropriate.
init
Each file can declare func init(). It runs after package-level variables are initialised and before main, and cannot be called directly.
package main
import "fmt"
var config = load()
func load() string {
fmt.Println("1. package variable initialised")
return "prod"
}
func init() {
fmt.Println("2. init runs, config =", config)
}
func main() {
fmt.Println("3. main runs")
}
$ go run initfn.go
1. package variable initialised
2. init runs, config = prod
3. main runs
The order is fixed: imported packages first, then this package’s variables, then its init functions in file order, then main. Keep init small — hidden work at startup is hard to test and slows every program that imports the package.
Recursion
A function can call itself; Go grows goroutine stacks on demand, so moderate recursion depth is cheap.
package main
import "fmt"
func fib(n int, memo map[int]int) int {
if n < 2 {
return n
}
if v, ok := memo[n]; ok {
return v
}
memo[n] = fib(n-1, memo) + fib(n-2, memo)
return memo[n]
}
func main() {
memo := map[int]int{}
fmt.Println(fib(10, memo), fib(90, memo))
}
$ go run recur.go
55 2880067194370816120
Without the memo map, fib(90) would make on the order of 10¹⁸ calls; with it, each value is computed once. Maps are reference-like, so every recursive call shares the same memo.
Practice
1. Write minMax(nums ...int) (int, int, bool) that returns false as the third value when called with no arguments.
Solution
package main
import "fmt"
func minMax(nums ...int) (lo, hi int, ok bool) {
if len(nums) == 0 {
return 0, 0, false
}
lo, hi = nums[0], nums[0]
for _, n := range nums[1:] {
lo, hi = min(lo, n), max(hi, n)
}
return lo, hi, true
}
func main() {
fmt.Println(minMax(3, -2, 8))
fmt.Println(minMax())
}
$ go run p1.go
-2 8 true
0 0 false
The ok result follows the comma-ok convention, letting the caller tell “no data” apart from a genuine 0.
2. Write makeMultiplier(k int) func(int) int and use it to build double and triple.
Solution
package main
import "fmt"
func makeMultiplier(k int) func(int) int {
return func(n int) int { return n * k }
}
func main() {
double, triple := makeMultiplier(2), makeMultiplier(3)
fmt.Println(double(5), triple(5))
}
$ go run p2.go
10 15
Each returned closure captures its own k.
3. Write a timer(name string) func() helper so that defer timer("work")() prints start work immediately and end work when the function returns.
Solution
package main
import "fmt"
func timer(name string) func() {
fmt.Println("start", name)
return func() { fmt.Println("end", name) }
}
func work() {
defer timer("work")()
fmt.Println("working")
}
func main() { work() }
$ go run p3.go
start work
working
end work
timer("work") is evaluated at the defer statement — like any deferred call’s arguments — so start prints first; only the returned function is deferred. A real version would record time.Now() and print the elapsed time.
4. Write safeDiv(a, b int) (q int, err error) that uses defer and recover to turn the integer divide-by-zero panic into an error.
Solution
package main
import "fmt"
func safeDiv(a, b int) (q int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("safeDiv(%d, %d): %v", a, b, r)
}
}()
return a / b, nil
}
func main() {
fmt.Println(safeDiv(7, 2))
fmt.Println(safeDiv(7, 0))
}
$ go run p4.go
3 <nil>
0 safeDiv(7, 0): runtime error: integer divide by zero
Integer division by zero panics at run time (float division gives +Inf). In real code, check b == 0 up front; this exercise shows the mechanics of recover.