t0ul

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:

  1. A function that can fail returns an error as its last value. Hello returns (string, error). When nothing went wrong the error is nil.
  2. Wrap errors with %w as they travel up. greetAll does not know how to fix an empty name, so it returns the error to its caller, adding what it knows (which guest). fmt.Errorf with the %w verb keeps the original error inside the new one.
  3. Check for a particular error with errors.Is. Because of the wrapping, main can still ask “was this the empty-name problem?” even though the message now reads guest 2: empty name.
  4. Decide once, in main. Everything else returns errors; only main prints and exits. Putting the logic in run() error keeps main tiny 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.

← Back to the blog