Introduction to Functions in Golang
Golang 14 min read
Multiple return values are the reason Go has no exceptions, named results interact with defer in a way that enables error wrapping, and a closure over a loop variable behaves differently since Go 1.22.
Go’s function design is shaped by one decision: there are no exceptions, so errors come back as
values. Everything else: multiple returns, named results, the if err != nil shape of Go code —
follows from that, and understanding it as a consequence rather than a quirk makes the language read
better.
Written against Go 1.22.
Declaration
func add(a int, b int) int {
return a + b
}
func add(a, b int) int { // shared type, written once
return a + b
}
func greet(name string) { // no return value
fmt.Println("Hello,", name)
}
The type follows the name, which reads left to right, “a is an int”, and matters more in a
declaration like func f(g func(int) error) []byte, where the C-style ordering becomes unreadable.
Parameters are always passed by value. Passing a struct copies it; passing a slice copies the
header (the pointer, length and capacity) which is why writing to s[0] inside a function is
visible to the caller while append may not be. Maps and channels are reference types and behave
the same way.
Multiple return values
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
result, err := divide(10, 3)
if err != nil {
return fmt.Errorf("calculating ratio: %w", err)
}
This is the language’s replacement for exceptions. An error is an ordinary value in an ordinary
return slot, so it cannot be ignored by accident: the compiler rejects an unused variable, and
skipping it requires writing _ deliberately.
The convention is that the error comes last, and that the other values are unusable when it is
non-nil. Returning both a useful value and an error is legal and confusing; the two exceptions are
io.Reader, which returns bytes alongside io.EOF, and functions returning a partial result by
documented contract.
%w in fmt.Errorf wraps the original so errors.Is and errors.As can still see it. %v
formats it as text and breaks the chain.
Named return values
func divide(a, b float64) (result float64, err error) {
if b == 0 {
err = errors.New("division by zero")
return // naked return — returns the current values
}
result = a / b
return
}
Named results are pre-declared and zero-valued. The naked return sends whatever they currently
hold.
Use them sparingly. In a function longer than a few lines, a naked return forces the reader back to the signature to find out what is being returned, and the values can be modified between assignment and return.
The case where they earn their place is defer modifying the result:
func process(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // only possible because err is named
}
}()
return doWork(f)
}
A deferred closure can assign to a named result and change what the caller sees. With an anonymous
result there is nothing to assign to, and a Close error on a write is silently lost, which for a
buffered writer means losing data with no report.
The same mechanism converts a panic into an error:
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
Variadic functions
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3)
values := []int{1, 2, 3}
sum(values...) // spread — required
Inside the function nums is an []int. It is nil when no arguments are passed, which ranges
zero times, so no length check is needed.
Only one variadic parameter and it must be last. The spread form passes the slice directly without copying it, which means the function can modify the caller’s slice: a real difference from the non-spread call, which builds a fresh slice. A variadic function that keeps or mutates its argument should copy it first, or document that it takes ownership.
Functions are values
type Transform func(string) string
func apply(items []string, t Transform) []string {
out := make([]string, len(items))
for i, item := range items {
out[i] = t(item)
}
return out
}
apply(names, strings.ToUpper)
apply(names, func(s string) string { return strings.TrimSpace(s) })
A function type can be a parameter, a return value, a struct field or a map value. The named type
Transform is worth declaring when the signature appears more than once.
Returning a function creates a closure over the enclosing scope:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
next := counter()
next() // 1
next() // 2
count outlives counter, because the closure holds it: the variable escapes to the heap, which the
compiler decides automatically. That is the mechanism behind middleware, functional options and rate
limiters in Go, and it is worth knowing that each call to counter produces an independent count.
Closures over a loop variable
for _, v := range items {
go func() {
process(v) // Go < 1.22: probably the last item, repeatedly
}()
}
Before Go 1.22, the loop variable was one variable reused across iterations, so every closure captured the same one. Since 1.22 each iteration gets its own, and the code above does what it looks like.
The behaviour follows the go directive in go.mod, not the installed toolchain: a module
declaring go 1.21 still gets the old semantics on a new compiler. In a codebase spanning the
change, v := v at the top of the body is correct under both and costs nothing.
Methods are functions with a receiver
type Counter struct{ n int }
func (c *Counter) Increment() { c.n++ } // pointer receiver — can modify
func (c Counter) Value() int { return c.n } // value receiver — operates on a copy
A value receiver gets a copy, so func (c Counter) Increment() { c.n++ } compiles and does nothing —
one of the most common Go bugs, and silent.
Pick one kind per type rather than mixing them. A type with any pointer receiver should use pointer
receivers throughout, because only *Counter then satisfies an interface the methods define, and a
mixed set makes which one does confusing.
defer runs at function exit
func handler() {
defer fmt.Println("done") // runs when handler returns
for i := 0; i < 1000; i++ {
f, _ := os.Open(paths[i])
defer f.Close() // accumulates 1000 deferred calls
}
}
Deferred calls run last-in-first-out when the function returns, not at the end of a block. A
defer inside a loop holds every resource until the whole function finishes; give the loop body its
own function to fix it.
Arguments are evaluated at the point of the defer, not when it runs, defer fmt.Println(i)
captures i immediately.
More in the Golang guides, including interfaces and control flow.
Frequently asked questions
Why does Go return errors instead of throwing?
So that failure is an ordinary value in an ordinary return slot. It cannot be ignored accidentally, and the control flow is visible at the call site.
Where does the error go in the return list?
Last, by convention, and the other values should be treated as unusable when it is non-nil.
What is the difference between %w and %v when wrapping?
%w keeps the original error reachable by
errors.Is and errors.As; %v formats it as text and breaks the chain.
Should I use named return values?
Sparingly. They are genuinely useful when a deferred closure
must modify the result (capturing a Close error, or recovering a panic) and hurt readability
otherwise.
Are arguments passed by value?
Always. A struct is copied; a slice’s header is copied, so element
writes are visible to the caller but append may not be.
How do I pass a slice to a variadic function?
With the spread: sum(values...). That passes the
slice directly rather than copying, so the function can modify the caller’s data.
Why do all my goroutines see the last loop value?
On Go 1.21 and earlier the loop variable is
shared. Since 1.22 each iteration has its own, but the semantics follow the go directive in
go.mod, not the compiler version.
Why does my method not change the struct?
It has a value receiver, so it operates on a copy. Use a pointer receiver.
Should I mix value and pointer receivers?
No. Pick one per type: with pointer receivers, only the pointer type satisfies the interface, and a mixed set makes that unpredictable.
When do deferred calls run?
When the enclosing function returns, last-in-first-out. A defer
inside a loop accumulates until then; wrap the loop body in a function to release per iteration.