Functions and Methods
The previous chapter was about data sitting still — a labeled box, holding a value, refusing to change shape. This chapter is about motion: taking that data and doing something to it, in a way you can name, reuse, and trust to behave the same way every single time you call it. That's the entire idea of a function, and Go's take on it carries a few genuinely unusual design choices that are worth understanding properly, because they shape the look of nearly every piece of Go code you'll read for the rest of this book.
A function in Go is a small machine with a clearly labeled input slot and output slot — and Go, unusually, lets that output slot have more than one compartment. A method is that same machine, permanently bolted to one particular type so it always has a specific piece of data to work on.
Functions are how Go breaks a program into named, reusable pieces of behavior. Methods extend that idea by attaching a function to a type, which is how Go achieves what other languages get from classes — without classes, and without the tangled inheritance trees that come with them. This chapter covers both, along with a handful of Go-specific features (multiple return values, named returns, variadic parameters, closures) that shape how idiomatic Go code looks and reads.
Function Basics
A function declaration names its parameters and their types, and its return type, if any:
func add(a int, b int) int {
return a + b
}
When consecutive parameters share a type, Go lets you drop the repeated type name:
func add(a, b int) int {
return a + b
}
Calling it looks exactly like you'd expect:
sum := add(3, 4) // 7
Multiple Return Values
Here's where Go starts to feel genuinely different from most languages
you may have touched before, and it's worth pausing on, because this one
decision ripples through nearly every chapter left in this book. A Go
function can return more than one value, most commonly a result paired
with an error:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("divide by zero: %g / %g", a, b)
}
return a / b, nil
}
The caller receives both values and is expected to check the error before trusting the result:
result, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
Go's multiple return values, combined with its lack of exceptions, mean an error is not something exceptional that interrupts control flow — it's an ordinary value, checked at the exact point it can occur, right next to the code that produced it.
A couple of chapters ahead covers error handling in real depth; for
now, just notice the pattern, because you'll see it in literally every
networking example from here to the end of the book: the zero value of the
successful return type (0 for float64 above) is paired with a non-nil
error, so callers who forget to check err at least get a harmless zero
rather than a value that looks plausible but isn't.
Named Return Values
A function can name its return values in the signature, which
pre-declares them as zero-valued local variables and lets a bare return
send back whatever they currently hold:
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}
Named returns are most useful in short functions where the names document
intent, and in functions using defer to modify a return value (a
pattern you'll meet properly in the error-handling chapter). Overusing
them in long functions tends to hurt readability, since the bare return
no longer shows at a glance what's being returned.
return relies on the reader remembering what x and y were set to several lines earlier. In a function longer than about ten lines, this becomes a real readability cost — prefer writing return x, y explicitly once the function grows past a trivial size, even though the names remain declared in the signature.Variadic Functions
A parameter prefixed with ... accepts zero or more arguments of that
type, collected into a slice inside the function:
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum() // 0
sum(1, 2, 3) // 6
An existing slice can be passed to a variadic parameter by spreading it
with ... at the call site:
values := []int{4, 5, 6}
total := sum(values...)
fmt.Println and fmt.Printf are themselves variadic — that's how they
accept any number of arguments of any type, which you've already been
relying on since the very first "Hello, Go!" without necessarily noticing.
defer: Guaranteed Cleanup
A defer statement schedules a function call to run right before the
enclosing function returns, regardless of how it returns — normally, via
an early return, or even via a panic:
func readFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// ... read from f, possibly returning early on error ...
return nil
}
defer f.Close() runs no matter which return statement in the function
actually executes, which is exactly why "open, then immediately defer the
matching close" is one of the most common two-line idioms in Go — it puts
the cleanup right next to the setup that requires it, instead of leaving
it to be duplicated at every exit point. Once you start writing networking
code that opens files, sockets, and database connections, you'll type this
particular idiom often enough that your fingers learn it before your brain
does.
Deferred calls stack up in last-in-first-out order when a function defers
more than one, and — a detail worth knowing before it surprises you — the
deferred call's arguments are evaluated immediately when defer runs, not
when the deferred call itself later executes:
func trace(label string) {
fmt.Println("start:", label)
defer fmt.Println("end:", label) // "label" captured now, not later
}
defer conn.Close() is safe because conn itself is captured by reference (it's already a pointer-like type), but deferring a call whose arguments depend on state that changes later in the function can be surprising, since those arguments were fixed at the moment defer ran, not at the moment the deferred call executes.Functions as Values and Closures
Functions in Go are first-class values: they can be assigned to variables, passed as arguments, and returned from other functions. A function that references variables from an enclosing scope is called a closure, and it captures those variables by reference, not by copying their value at creation time:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
next := counter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3
Each call to counter() creates a fresh count variable, and the returned
closure keeps a live reference to that specific variable — calling next
repeatedly increments the same count, while a second call to counter()
would start an entirely independent one.
Methods: Functions Bound to a Type
A method is a function with an extra parameter, called the receiver, written before the function name:
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
rect := Rectangle{Width: 3, Height: 4}
fmt.Println(rect.Area()) // 12
Calling rect.Area() looks like calling a method in an object-oriented
language, but underneath it is ordinary function dispatch: the compiler
resolves rect.Area() to a call of Area with rect passed as the
receiver. There is no hidden this-like runtime lookup — Go decides which
function to call at compile time, based on the receiver's type. If you've
come from a language with heavier object machinery, this is often the
moment Go starts to feel refreshingly literal: there's no magic here, just
a function with one extra, conveniently-placed parameter.
Value Receivers vs. Pointer Receivers
The receiver in the Rectangle example above, (r Rectangle), is a
value receiver — the method receives a copy of the struct. A pointer
receiver, (r *Rectangle), receives a pointer to the original instead,
which lets the method modify the caller's actual data:
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
rect := Rectangle{Width: 3, Height: 4}
rect.Scale(2)
fmt.Println(rect) // {6 8}
Note that rect.Scale(2) works even though rect is not a pointer — Go
automatically takes its address as a convenience when calling a
pointer-receiver method on an addressable value. The reverse also happens:
a value-receiver method can be called on a pointer, which Go automatically
dereferences.
Scale above had used a value receiver, func (r Rectangle) Scale(...), it would modify only its own local copy of r, and the caller's rect would be completely unchanged after the call — with no compiler error or warning of any kind, because the code is entirely valid Go, just not doing what you probably meant.The rule of thumb: use a pointer receiver whenever the method needs to
modify the receiver, when the struct is large enough that copying it on
every call would be wasteful, or when any method on the type already uses
a pointer receiver (for consistency — mixing receiver kinds on the same
type is a common source of confusion). Use a value receiver for small,
immutable-feeling types, especially ones you want to be safely copied and
compared with ==.
| Receiver kind | Syntax | Can modify caller's data | Best for |
|---|---|---|---|
| Value | func (r Rectangle) M() |
No — operates on a copy | Small, read-only-feeling types |
| Pointer | func (r *Rectangle) M() |
Yes — operates on the original | Mutating methods, large structs, consistency with other methods |
Try It Yourself
Add a Perimeter method to Rectangle, and a second shape to compare
value versus pointer receiver behavior directly:
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c *Circle) Grow(amount float64) {
c.Radius += amount
}
- Add
Perimeter() float64toRectangle(formula:2*(Width+Height)) using a value receiver, and print it for a3x4rectangle. - Add the
Circletype and both methods shown above (remember toimport "math"), and callArea()on aCircle{Radius: 2}. - Call
Grow(1)on aCirclevalue and print itsRadiusbefore and after — confirm the change actually took effect, sinceGrowuses a pointer receiver. - Bonus: change
Growto a value receiver, call it again, and observe that the radius no longer changes — this reproduces the silent bug described above on purpose so you can recognize its symptom later.
A method with the wrong receiver kind doesn't crash — it just quietly does nothing useful, which is exactly why this mistake survives code review more often than it should.
Frequently Asked Questions
Why does Go let a function return two values instead of just throwing an exception like most languages?
Because Go's designers wanted every possible failure point visible right in the function signature, not hidden behind a catch block somewhere else in the codebase. Pairing a result with an error — as divide does in this chapter — means the caller checks failure at the exact line it can happen, which is the pattern you'll see in literally every networking example for the rest of this book.
I called Scale on my Rectangle and nothing changed — what went wrong?
You almost certainly declared Scale with a value receiver, func (r Rectangle) Scale(...), which means the method only ever modifies its own private copy of r and the caller's original struct is untouched. This is exactly the "classic silent bug" covered in this chapter: there's no compiler error because the code is perfectly valid Go, it just isn't doing what you meant. Switch the receiver to a pointer, func (r *Rectangle) Scale(...), and the mutation will stick.
Do I need to fully understand closures before I get to goroutines later in the book? It helps enormously to at least understand the core idea now: a closure captures variables by reference, not by taking a snapshot of their value. That's precisely what explains the classic loop-variable-capture bug in concurrent code, and while Go 1.22 fixed the most common version of it by giving each loop iteration its own variable, the underlying rule of "closures hold a live reference" is worth having internalized before you're debugging a goroutine that captured the wrong thing.
When should I actually use named return values instead of just writing return x, y?
Named returns pull their weight in short functions, where the names in the signature double as documentation, and in functions that use defer to adjust a return value on the way out. Once a function stretches past ten lines or so, a bare return forces the reader to remember what you set several lines earlier, so writing the values out explicitly is the better call at that point.
Why does rect.Scale(2) work even though rect isn't declared as a pointer?
Go automatically takes the address of rect for you when you call a pointer-receiver method on an addressable value — it's a convenience the compiler handles at the call site, not something that happens by magic at runtime. The same convenience runs in reverse too: calling a value-receiver method on a pointer automatically dereferences it.
Where This Goes From Here
You've now got the two ingredients Go uses to build almost everything:
functions that take data and produce results, honestly reporting failure
as a second return value instead of hiding it in an exception; and
methods, which are just functions with a permanent, dedicated piece of
data to work on. Neither of those ingredients defines what that data
looks like yet, though — a Rectangle showed up in this chapter as if
it had always existed, and it's fair to wonder how you build a type like
that in the first place.
That's exactly where the next chapter picks up: structs, for grouping data together, and interfaces, for describing behavior without caring what concrete type provides it. Between what you already know from this chapter and what's coming next, you'll have essentially everything Go offers instead of classes — and, once you see it work, you may find yourself not missing classes much at all.