Automate the Boring Stuff with Go, Ch. 4: Functions
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
Updated for Go 1.27 in October 2026. What changed: every snippet is now a file that compiles, the error-handling section teaches the shape the rest of the book uses (return the error, wrap it, decide once in main), and there is a short introduction to type parameters.
You’ve seen func many times so far. Let’s take this chapter to understand functions better and when to use them when automating work.
Functions are reusable units of work, and they save programmers from repeating themselves.
You might hear this called D.R.Y. when speaking with other coders: “Don’t repeat yourself”.
Functions are like small programs within your program. And a Go program must always have func main() in package main: that is where the computer starts reading. Everything in this chapter is in the repo under ch4/examples.
I. Parts of a function
Let’s start with a basic function and name its parts.
// ex. 1 parts of a function
package main
import "fmt"
func NameIs(name string) { // (1)
fmt.Printf("Your name is: %s\n", name)
}
func main() {
NameIs("Gopher") // (2)
}
(1) The word after func is the function’s name, NameIs. The part in parentheses, name string, is its parameter: the data it accepts, with its type.
Note: a function can take more than one parameter.
(2) This is the function call: the function being used, or “invoked”.
I.a When to get func-e (funky)?
When you’re writing code you might get it working on the first pass, and that is a fine and noble goal. On the second pass, right after it works, it’s time to refactor: reduce repetition by pulling it into functions.
I.b Example
Suppose we say hello to a bunch of readers without a function. Here’s how repetitive that looks:
// ex. 2 repetition without a function
package main
import "fmt"
func main() {
fmt.Printf("Hello %s\n", "Gopher")
fmt.Printf("Hello %s\n", "Gopher2")
fmt.Printf("Hello %s\n", "Gopher3")
fmt.Printf("Hello %s\n", "Gopher4")
fmt.Printf("Hello %s\n", "Gopher5")
fmt.Printf("Hello %s\n", "Gopher6")
fmt.Printf("Hello %s\n", "Gopher7")
fmt.Printf("Hello %s\n", "Gopher8")
fmt.Printf("Hello %s\n", "Gopher9")
fmt.Printf("Hello %s\n", "Gopher10")
}
Let’s refactor to make it DRYer:
// ex. 3 the same thing, refactored
package main
import "fmt"
func greetReaders(names []string) {
for _, name := range names {
fmt.Printf("Hello %s\n", name)
}
}
func main() {
readers := []string{
"Gopher", "Gopher2", "Gopher3", "Gopher4", "Gopher5",
"Gopher6", "Gopher7", "Gopher8", "Gopher9", "Gopher10",
}
greetReaders(readers)
}
II. Parameters of different types
Suppose we want to print a reader’s name and age. We need a string and a number. In Go the parameters can mix types freely:
// ex. 4 arguments of different types
package main
import "fmt"
func HappyBirthday(name string, age int) {
fmt.Printf("Happy birthday %s, you're turning %d\n", name, age)
}
func main() {
HappyBirthday("Go", 20)
}
III. Return values
So far the functions have only done something with the data passed in. The return keyword hands data back to the caller.
Let’s go back to even and odd, and sort a slice of integers into two slices:
// ex. 5 returning values
package main
import "fmt"
func EvenOdd(numbers []int) ([]int, []int) { // (1)
var odd, even []int
for _, n := range numbers {
if n%2 == 0 {
even = append(even, n)
} else {
odd = append(odd, n)
}
}
return odd, even // (2)
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
odd, even := EvenOdd(numbers) // (3)
fmt.Println("Odd: ", odd)
fmt.Println("Even:", even)
}
(1) The second set of parentheses, ([]int, []int), is required when a function returns more than one value. It tells the reader what to expect back.
(2) return says: at the end of this call, give these values to the caller.
(3) The returned values can be assigned to variables and passed further along the program. Go lets you return several values at once, and you will use that constantly: the second value is very often an error, which is the subject of section V.
You can return values of different types too:
// ex. 6 returning values of different types
package main
import "fmt"
func nameAndAge(name string, age int) (string, int) { // (1)
return name, age
}
func main() {
name, age := nameAndAge("Gopher", 20)
fmt.Printf("name: %s, age: %d\n", name, age)
}
As your programs grow past twenty lines, functions that return data make it much simpler to keep track of what is going on, for you and for other readers.
IV. Local and global variables
So far we’ve used local variables. A local variable exists only inside the block of code where it was declared. A global (package-level) variable can be read and changed from any function in the package. Globals are frowned upon in Go, and here is why:
// ex. 7 globals are tricky
package main
import "fmt"
// package-level ("global") variables
var Author = "Gopher"
var Age = 20
func LocalAuthor(name string, age int) {
Author = name // this changes the global for everyone
Age = age
fmt.Printf("Author: %s, Age: %d\n", name, age)
}
func main() {
LocalAuthor("Gopher2", 40)
fmt.Printf("Author: %s, Age: %d\n", Author, Age) // not what the globals said at the top
}
LocalAuthor reassigns the globals, so the last line prints “Gopher2” and 40, which might surprise another part of the program that expected “Gopher” and 20.
To get the expected behaviour, work with local variables inside the function:
// ex. 8 locals are preferred
package main
import "fmt"
var Author = "Gopher"
var Age = 20
func LocalAuthor(name string, age int) {
n := name // local copies; the globals are untouched
a := age
fmt.Printf("Author: %s, Age: %d\n", n, a)
}
func main() {
LocalAuthor("Gopher2", 40)
fmt.Printf("Author: %s, Age: %d\n", Author, Age)
}
V. Error handling
Go gets complaints about error handling because it is verbose: errors are values, you check them where they happen, and that is a lot of if err != nil. The upside is that every failure is handled on purpose, in plain sight. Here is the shape used by every project in this book from chapter 7 on:
// ex. 9 errors: return them, wrap them, check them once at the top
package main
import (
"errors"
"fmt"
"os"
)
// ErrEmptyName is a sentinel: a named error value callers can test for.
var ErrEmptyName = errors.New("empty name")
// Hello returns a greeting, or an error when there is nobody to greet.
func Hello(name string) (string, error) {
if name == "" {
return "", ErrEmptyName
}
return fmt.Sprintf("Hi, %s. Welcome!", name), nil
}
// greetAll stops at the first problem and says which name caused it. The %w verb
// wraps the original error so the caller can still recognise it.
func greetAll(names []string) error {
for i, name := range names {
msg, err := Hello(name)
if err != nil {
return fmt.Errorf("guest %d: %w", i+1, err)
}
fmt.Println(msg)
}
return nil
}
// run holds the program's logic and returns its error instead of exiting. main is the
// only place that decides what an error means for the process.
func run() error {
return greetAll([]string{"Ada", "", "Linus"})
}
func main() {
if err := run(); err != nil {
if errors.Is(err, ErrEmptyName) {
fmt.Fprintln(os.Stderr, "someone forgot to fill in a name:", err)
} else {
fmt.Fprintln(os.Stderr, "error:", err)
}
os.Exit(1)
}
}
Four ideas, and they carry the rest of the book:
- A function that can fail returns an error as its last value.
Helloreturns(string, error). When nothing went wrong the error isnil. - Wrap errors with
%was they travel up.greetAlldoes not know how to fix an empty name, so it returns the error to its caller, adding what it knows (which guest).fmt.Errorfwith the%wverb keeps the original error inside the new one. - Check for a particular error with
errors.Is. Because of the wrapping,maincan still ask “was this the empty-name problem?” even though the message now readsguest 2: empty name. - Decide once, in
main. Everything else returns errors; onlymainprints and exits. Putting the logic inrun() errorkeepsmaintiny and makes the logic testable.os.Exit(1)tells the shell the program failed, which matters the moment you put a program in a script.
Two more tools you will meet: errors.As checks whether an error is of a certain type (not just a certain value), and errors.Join bundles several errors into one when you want to report all of them rather than stop at the first.
When you are hacking something together for yourself, printing the error and quitting is fine. When you share code with a team, with the world, or with your future self, this shape is the difference between a program that says “guest 2: empty name” and one that says “something went wrong”.
VI. Type parameters, when you need them
Sometimes the same function should work for several types. Before Go 1.18 you wrote it twice or reached for tricks. Now you can write it once with a type parameter:
// ex. 10 type parameters, for when the same function should work on several types
package main
import "fmt"
// Number is a constraint: any of these types is allowed.
type Number interface {
~int | ~int64 | ~float64
}
// Sum works for a slice of any Number. T is a type parameter.
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
// Map applies f to every element and returns the results. The two type parameters
// are independent: a []string can become a []int.
func Map[T, U any](values []T, f func(T) U) []U {
out := make([]U, 0, len(values))
for _, v := range values {
out = append(out, f(v))
}
return out
}
func main() {
fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum([]float64{1.5, 2.25}))
lengths := Map([]string{"go", "gopher", "generics"}, func(s string) int { return len(s) })
fmt.Println(lengths)
}
Sum[T Number] says: T is some type from the Number constraint, and the slice and the result are both T. The compiler fills T in from what you pass. Map[T, U any] has two type parameters and turns a slice of one type into a slice of another.
You will not write many of these as a beginner, and that is fine. You will use them all the time without noticing: the slices and maps packages in the next two chapters are built on them.
Conclusion
Functions are an essential paradigm in any programming language; some languages are purely functional. The key takeaway: as a reader of a function you mostly care about what it takes, what it returns, and whether it can fail, not the lines inside.