Free Handbook · Every example compiled & verified

Functions

Declare functions, return several values, pass functions around as values, capture state in closures, and use defer and recursion without surprises.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 03 · what you'll be able to do

  • Declare functions with typed parameters and results, and return a value and an error together
  • Use named results and variadic parameters, and spread a slice into a variadic call with ...
  • Store functions in variables, pass them as arguments and build closures that keep their own state
  • Predict exactly when deferred calls run, what values they see, and how they can change a named result
  • Write recursive functions with a clear base case, and explain the Go 1.22 loop-variable change
01

Declaring functions and parameters

A function is declared with func, a name, a parameter list and — if it returns something — a result type: func add(a int, b int) int. When neighbouring parameters share a type you can write the type once: func add(a, b int) int. Unlike Python or JavaScript, the types are part of the signature, so the compiler checks every call.

Go keeps functions deliberately plain. There are no default parameter values, no named arguments and no overloading — two functions in one package cannot share a name, even with different parameters. Arguments are always passed by value: the function gets a copy. (Slices, maps and pointers are small values that refer to shared data; Modules 04 and 05 show what that means.)

gomain.go
package main

import "fmt"

func greet(name string) {
	fmt.Println("Hello,", name)
}

func add(a, b int) int {
	return a + b
}

func area(width, height float64) float64 {
	return width * height
}

func main() {
	greet("Gopher")
	fmt.Println(add(2, 3))
	fmt.Printf("%.1f\n", area(2.5, 4))
}
Outputcompiled & run with real Go
Hello, Gopher
5
10.0
Your turn

Write func perimeter(width, height float64) float64 and print the perimeter of the same rectangle.

Order does not matter at package level
main can call add even if add is declared further down the file. Package-level functions are visible everywhere in the package, so Go code usually puts main first or last purely by taste.
Error you will hit

missing return

go
package main

import "fmt"

func sign(n int) string {
	if n > 0 {
		return "positive"
	} else if n < 0 {
		return "negative"
	}
}

func main() {
	fmt.Println(sign(-4))
}
# command-line-arguments
./main.go:11:1: missing return
Why the compiler said that

A function with a result type must end every path with a return. When n is 0, neither branch runs and the function would fall off the end with no value. The compiler does not reason about which numbers are possible — it only sees a path with no return.

The fix

Return something on the last path (or end with a plain else that returns).

go
package main

import "fmt"

func sign(n int) string {
	if n > 0 {
		return "positive"
	} else if n < 0 {
		return "negative"
	}
	return "zero"
}

func main() {
	fmt.Println(sign(-4))
}
Error you will hit

not enough arguments in call

go
package main

import "fmt"

func add(a, b int) int {
	return a + b
}

func main() {
	fmt.Println(add(1))
}
# command-line-arguments
./main.go:10:18: not enough arguments in call to add
	have (number)
	want (int, int)
Why the compiler said that

With no default values, every call must pass exactly as many arguments as there are parameters. The compiler shows what you passed (have) and what the signature needs (want).

The fix

Pass every argument. If a value is often the same, write a second, well-named function that calls the first.

go
package main

import "fmt"

func add(a, b int) int {
	return a + b
}

func main() {
	fmt.Println(add(1, 2))
}
02

Multiple return values and named results

A Go function can return several values: list the types in parentheses, func minMax(nums []int) (int, int), and return them separated by commas. The caller receives them with a multi-assignment, lo, hi := minMax(nums). A value you do not need goes to the blank identifier _.

This is how Go reports failure. Instead of exceptions, a function that can fail returns its result and an error as the last value. The caller checks if err != nil straight away. You will write this pattern hundreds of times; Module 07 covers errors in depth.

gomain.go
package main

import (
	"errors"
	"fmt"
)

func minMax(nums []int) (int, int) {
	lo, hi := nums[0], nums[0]
	for _, n := range nums[1:] {
		if n < lo {
			lo = n
		}
		if n > hi {
			hi = n
		}
	}
	return lo, hi
}

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	lo, hi := minMax([]int{7, 2, 9, 4})
	fmt.Println("min", lo, "max", hi)

	_, top := minMax([]int{1, 5})
	fmt.Println("only the max:", top)

	fmt.Println(minMax([]int{3, 8})) // results passed straight through

	for _, d := range []int{2, 0} {
		q, err := divide(10, d)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println("10 /", d, "=", q)
	}
}
Outputcompiled & run with real Go
min 2 max 9
only the max: 5
3 8
10 / 2 = 5
error: division by zero
Your turn

Add a third result to minMax: the sum of the numbers. Update every call site — the compiler will point at each one you miss.

The zero value goes with the error
By convention, when a function returns a non-nil error its other results are zero values (0, "", nil) and the caller must not use them. Return 0, err, not a half-computed number.
Error you will hit

assignment mismatch: 1 variable but divide returns 2 values

go
package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	q := divide(10, 2)
	fmt.Println(q)
}
# command-line-arguments
./main.go:16:7: assignment mismatch: 1 variable but divide returns 2 values
Why the compiler said that

Go never silently drops a result. A function that returns two values must be received into two variables — this is exactly what stops you from ignoring an error by accident.

The fix

Receive both values and handle the error. If you are certain you do not need a value, assign it to _ explicitly so the choice is visible in review.

go
package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	q, err := divide(10, 2)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(q)
}

Named results and naked return

Results can have names: func split(total int) (quotient, remainder int). Named results are ordinary variables declared at the top of the function and initialised to their zero values. A bare return — a naked return — returns their current values.

Use names when they document the results, especially when two results share a type ((lat, lng float64) tells the caller which is which). Avoid naked returns in anything longer than a few lines: a reader has to scroll up to find out what is being returned. The other real use of named results is letting a deferred function change the result, shown in the defer lesson below.

gomain.go
package main

import "fmt"

// split divides total items into boxes of 3.
func split(total int) (boxes, leftover int) {
	boxes = total / 3
	leftover = total % 3
	return // naked return: returns boxes, leftover
}

func stats(nums []int) (count int, sum int, mean float64) {
	count = len(nums)
	if count == 0 {
		return // all three are still zero values
	}
	for _, n := range nums {
		sum += n
	}
	mean = float64(sum) / float64(count)
	return count, sum, mean // explicit is clearer when there is logic
}

func main() {
	b, l := split(14)
	fmt.Println(b, "boxes,", l, "left over")
	fmt.Println(stats(nil))
	fmt.Println(stats([]int{2, 4, 9}))
}
Outputcompiled & run with real Go
4 boxes, 2 left over
0 0 0
3 15 5
Your turn

Rename leftover to rest. Nothing at the call site changes — the names are for the function body and the documentation only.

Shadowing a named result
Writing err := doThing() inside an inner block creates a new err that hides the named result. A naked return in that block is then rejected with result parameter err not in scope at return. Use = to assign to the named result instead of :=.
03

Variadic functions

A final parameter written nums ...int accepts any number of ints, including none. Inside the function, nums is simply a []int. fmt.Println(a ...any) is the variadic function you already use every day.

If you already have a slice, spread it into the call with a trailing ...: sum(values...). Spreading does not copy — the function receives the same backing array, so writes inside the function are visible to the caller.

gomain.go
package main

import (
	"fmt"
	"strings"
)

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

func label(prefix string, parts ...string) string {
	return prefix + ": " + strings.Join(parts, ", ")
}

func zeroFirst(nums ...int) {
	if len(nums) > 0 {
		nums[0] = 0
	}
}

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

	values := []int{4, 5, 6}
	fmt.Println(sum(values...)) // spread a slice

	fmt.Println(label("langs", "go", "rust"))

	zeroFirst(values...)
	fmt.Println(values) // the caller's slice changed
}
Outputcompiled & run with real Go
0 5 6
15
langs: go, rust
[0 5 6]
Your turn

Write func maxOf(first int, rest ...int) int. Why is taking first separately better than ...int alone? (Hint: what should maxOf() return?)

Error you will hit

cannot use values (variable of type []int) as int value in argument to sum

go
package main

import "fmt"

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

func main() {
	values := []int{4, 5, 6}
	fmt.Println(sum(values))
}
# command-line-arguments
./main.go:15:18: cannot use values (variable of type []int) as int value in argument to sum
Why the compiler said that

A variadic parameter takes individual int arguments. A []int is one value of a different type, and Go never unpacks it for you implicitly.

The fix

Add ... after the slice to spread it.

go
package main

import "fmt"

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

func main() {
	values := []int{4, 5, 6}
	fmt.Println(sum(values...))
}
04

Functions as values and anonymous functions

Functions are first-class values in Go. A function has a type — func(int, int) int — so you can store one in a variable, put it in a map or slice, pass it as an argument and return it from another function. A type declaration gives a function type a readable name.

An anonymous function (a function literal) is written inline without a name: func(a, b int) int { return a * b }. Add () after the closing brace to call it immediately. The standard library is full of functions that take other functions: slices.SortFunc, strings.FieldsFunc, http.HandleFunc.

gomain.go
package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

type binaryOp func(int, int) int

func apply(op binaryOp, a, b int) int {
	return op(a, b)
}

func main() {
	add := func(a, b int) int { return a + b }
	fmt.Println(apply(add, 3, 4))
	fmt.Println(apply(func(a, b int) int { return a * b }, 3, 4))

	ops := map[string]binaryOp{
		"+":   add,
		"-":   func(a, b int) int { return a - b },
		"max": func(a, b int) int { return max(a, b) },
	}
	for _, name := range slices.Sorted(maps.Keys(ops)) { // sorted: map order is random
		fmt.Println(name, ops[name](10, 3))
	}

	isSep := func(r rune) bool { return r == ',' || r == ';' }
	fmt.Println(strings.FieldsFunc("a,b;c", isSep))

	func() {
		fmt.Println("called immediately")
	}()
}
Outputcompiled & run with real Go
7
12
+ 13
- 7
max 10
[a b c]
called immediately
Your turn

Add a "/" entry to ops. Where does it appear in the sorted output, and why?

Function values can only be compared to nil
The zero value of a function type is nil. You can test if f != nil, but f == g does not compile — Go does not define equality between two functions.
Error you will hit

panic: runtime error: invalid memory address or nil pointer dereference

go
package main

import "fmt"

func main() {
	var onDone func(string)
	fmt.Println("finishing")
	onDone("ok")
}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x100163580]

goroutine 1 [running]:
main.main()
	./main.go:8 +0x50
exit status 2
Why the compiler said that

A function variable that was never assigned is nil. Calling a nil function compiles fine — the type is right — but at run time there is no code to jump to, so the program panics. This shows up with optional callbacks and struct fields of function type.

The fix

Assign a function before calling, or guard optional callbacks with a nil check.

go
package main

import "fmt"

func main() {
	var onDone func(string)
	fmt.Println("finishing")
	if onDone != nil {
		onDone("ok")
	}
}
05

Closures: functions that remember

An anonymous function can use variables from the function around it. When it does, it captures the variable itself, not a copy of its value — and the variable lives on for as long as the closure does, even after the outer function has returned. A function bundled with the variables it captured is called a closure.

The classic example is a counter factory. Each call to makeCounter creates a new count variable and a new closure over it, so two counters never share state.

gomain.go
package main

import "fmt"

func makeCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

func multiplier(factor int) func(int) int {
	return func(n int) int { return n * factor }
}

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

	other := makeCounter() // a brand-new count
	fmt.Println(other())
	fmt.Println(next()) // the first counter kept going

	double, triple := multiplier(2), multiplier(3)
	fmt.Println(double(5), triple(5))
}
Outputcompiled & run with real Go
1 2 3
1
4
10 15
Your turn

Change makeCounter to take a step int parameter and count by that step.

VisualizeWhat the counter closure doesStep 1 / 11
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
next := makeCounter()
a := next()
b := next()
fmt.Println(a, b)
Line 9

makeCounter() is called.

Variables now

nothing yet

All 11 steps as a table
StepLineWhat happenedVariables now
19makeCounter() is called.
22A new variable count is created, starting at 0.count = 0
33The function literal is returned. It holds a reference to count, so count survives after makeCounter returns.count = 0
49next now holds the closure.count = 0 next = closure over count
510Calling next() runs the closure body.count = 0 next = closure over count
64count++ changes the captured variable itself.count = 1 next = closure over count
75Returns 1.count = 1 next = closure over count a = 1
811Second call: the same count is still there.count = 1 next = closure over count a = 1
94count++count = 2 next = closure over count a = 1
105Returns 2.count = 2 next = closure over count a = 1 b = 2
1112Print both results.count = 2 next = closure over count a = 1 b = 2
Where closures show up at work
HTTP middleware (func logging(next http.Handler) http.Handler), retry helpers that take the operation as a func() error, sync.Once.Do(func() { ... }), and every goroutine started with go func() { ... }() in Module 08.
06

defer: LIFO, arguments and return values

Module 02 introduced defer: the call runs when the surrounding function returns, deferred calls run last-in, first-out, and the arguments are evaluated at the defer line. Two more rules complete the picture.

  • A deferred closure sees variables as they are when it runs, not when it was deferred — because it captured the variables, not their values. defer fmt.Println(x) freezes x; defer func() { fmt.Println(x) }() does not.
  • return is not the last thing that happens. return 21 first stores 21 in the result, then the deferred calls run, then the function actually exits. A deferred closure can therefore read and change a named result.
  • defer is per function, not per block. A defer inside a loop does not run at the end of each iteration — every deferred call waits until the whole function returns.
gomain.go
package main

import "fmt"

func double() (result int) {
	defer func() { result *= 2 }() // runs after "return 21" stored 21
	return 21
}

func trace(name string) func() {
	fmt.Println("enter", name)
	return func() { fmt.Println("exit", name) }
}

func work() {
	defer trace("work")() // trace runs NOW, the func it returns runs at exit
	fmt.Println("  working")
}

func main() {
	x := 1
	defer fmt.Println("argument frozen at defer:", x)
	defer func() { fmt.Println("closure sees latest:", x) }()
	x = 2

	fmt.Println(double())
	work()
}
Outputcompiled & run with real Go
42
enter work
  working
exit work
closure sees latest: 2
argument frozen at defer: 1
Your turn

Change double to use an unnamed result, func double() int, and a local result variable. Why does it now print 21?

VisualizeHow defer changes a named resultStep 1 / 6
func double() (result int) {
defer func() { result *= 2 }()
return 21
}
fmt.Println(double())
Line 6

double() is called.

Variables now

nothing yet

All 6 steps as a table
StepLineWhat happenedVariables now
16double() is called.
21The named result result starts at its zero value.result = 0
32The closure is registered to run at exit. Nothing is computed yet.result = 0
43return 21 stores 21 into result.result = 21
52Before the function exits, the deferred closure runs and doubles the captured result.result = 42
66The caller receives the final value of result.result = 42
defer in a loop keeps resources open
Opening 1,000 files in a loop with defer f.Close() inside keeps all 1,000 open until the function returns — and can hit the operating system's file limit. Move the loop body into its own function (processFile(path)) so each defer runs when that call returns.
07

Recursion

A recursive function calls itself on a smaller version of the problem. It needs two parts: a base case that answers directly without recursing, and a recursive case that moves towards the base case. Forget the base case and the calls never stop.

Recursion shines on data that is itself nested — trees, directory structures, JSON — where a loop would need an explicit stack. For simple counting, a loop is clearer and faster in Go: the compiler does not do tail-call optimisation, so every call adds a stack frame.

gomain.go
package main

import "fmt"

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

type node struct {
	name     string
	children []node
}

func countNodes(n node) int {
	total := 1
	for _, c := range n.children {
		total += countNodes(c)
	}
	return total
}

func main() {
	fmt.Println(factorial(5))

	tree := node{"root", []node{
		{"a", []node{{"a1", nil}, {"a2", nil}}},
		{"b", nil},
	}}
	fmt.Println(countNodes(tree), "nodes")
}
Outputcompiled & run with real Go
120
5 nodes
Your turn

Write func depth(n node) int that returns 1 for a node with no children and 1 + the deepest child otherwise. The tree above has depth 3.

Visualizefactorial(3), call by callStep 1 / 7
func factorial(n int) int {
if n <= 1 {
return 1
}
return n * factorial(n-1)
}
fmt.Println(factorial(3))
Line 8

Call factorial(3).

Variables now
n3
All 7 steps as a table
StepLineWhat happenedVariables now
18Call factorial(3).n = 3
253 is not ≤ 1, so it needs factorial(2) before it can multiply. This call waits on the stack.n = 3 stack = f(3)
35Inside factorial(2): needs factorial(1).n = 2 stack = f(3) → f(2)
43Inside factorial(1): base case, return 1.n = 1 stack = f(3) → f(2) → f(1)
55Back in factorial(2): 2 * 1 = 2, return 2.n = 2 stack = f(3) → f(2)
65Back in factorial(3): 3 * 2 = 6, return 6.n = 3 stack = f(3)
78Print the result.stack = (empty)
Stack overflow in Go
Goroutine stacks start small and grow as needed, so Go handles much deeper recursion than C or Java. But runaway recursion eventually hits the limit (1 GB on 64-bit systems) and the program dies with runtime: goroutine stack exceeds 1000000000-byte limit followed by fatal error: stack overflow. That is always a missing or unreachable base case.
08

Closures in loops: the Go 1.22 change

Before Go 1.22, a for loop declared its variable once and reused it on every iteration. A closure created in the loop captured that single variable, so after the loop every closure saw its final value. It was one of the most common bugs in Go code, especially with goroutines.

Since Go 1.22, each iteration gets a fresh variable. The language version is chosen by the go line in go.mod (and can be set per file with a //go:build go1.21 constraint), which is how the next two programs show both behaviours on the same compiler.

gomain.go
package main

import "fmt"

func main() {
	var funcs []func()
	for i := 0; i < 3; i++ {
		funcs = append(funcs, func() { fmt.Print(i, " ") })
	}
	for _, f := range funcs {
		f()
	}
	fmt.Println()
}
Outputcompiled & run with real Go
0 1 2

Go 1.22 and later: each closure captured its own i.

gomain.go
//go:build go1.21

package main

import "fmt"

func main() {
	var funcs []func()
	for i := 0; i < 3; i++ {
		funcs = append(funcs, func() { fmt.Print(i, " ") })
	}
	for _, f := range funcs {
		f()
	}
	fmt.Println()
}
Outputcompiled & run with real Go
3 3 3

The same code compiled with Go 1.21 semantics: one shared i, which the loop left at 3.

In older code you will see the workaround i := i as the first line of the loop body — it declares a new per-iteration variable by hand. It is harmless under Go 1.22, and tools like go fix can remove it. The change only affects variables declared by the loop. A variable declared outside the loop is still one variable, and every closure shares it:

gomain.go
package main

import "fmt"

func main() {
	total := 0
	var readers []func() int
	for i := range 3 {
		total += i
		readers = append(readers, func() int { return total })
	}
	for _, r := range readers {
		fmt.Print(r(), " ") // all read the same total
	}
	fmt.Println()
}
Outputcompiled & run with real Go
3 3 3
Your turn

Make each closure remember the running total at the moment it was created. (Hint: copy total into a new variable inside the loop body and capture that.)

Signature
A function's parameter types and result types, e.g. func(string, int) (bool, error). Two functions with the same signature have the same type.
Multiple return values
A function returning several results, like (int, error); the caller must receive all of them or discard with _.
Named result
A result given a name in the signature. It is a local variable starting at its zero value and can be returned with a naked return.
Variadic parameter
A final parameter xs ...T that accepts zero or more arguments and is a []T inside the function. Spread a slice into it with s....
Function literal
An anonymous function written inline: func(x int) int { return x * 2 }.
Closure
A function value together with the variables it captured from the surrounding scope; it keeps those variables alive.
Base case
The condition in a recursive function that returns without recursing, stopping the chain of calls.
Quick check

What does f() return? func f() (n int) { defer func() { n++ }(); return 5 }

Quick check

Two counters are created with a := makeCounter() and b := makeCounter(). You call a() three times, then b() once. What does that b() return?

Frequently asked questions

Does Go support default parameters or function overloading?
No. Every call passes every argument, and a package cannot have two functions with the same name. Go code uses separate well-named functions, a config struct, or variadic options instead.
Why do Go functions return an error instead of throwing an exception?
Returning (result, error) makes failure part of the signature, so the caller sees and handles it at the call site. Go has panic for truly unrecoverable bugs, but normal failures such as a missing file or bad input are returned as error values.
What changed about loop variables in Go 1.22?
Each iteration of a for loop now gets its own copy of the loop variable, so closures and goroutines created in the loop see the value from their iteration instead of the final value. It applies to modules whose go.mod says go 1.22 or later.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.