Multiple return values are the secret weapon - once you see how Go functions pass back both a result and an error in one clean statement, you'll wonder how you lived without it.
Every Go function follows the same shape. Once you have this shape locked in, everything else in the lesson is just variations on it.
func add(a, b int) int {
return a + b
}
Go lets you collapse the type annotation when consecutive params share the same type:
// Verbose - each param states its own type
func add(a int, b int) int { return a + b }
// Idiomatic - shared type written once at the end
func add(a, b int) int { return a + b }
// Mixed - only adjacent same-type params can share
func greet(name string, age, score int) string {
return fmt.Sprintf("%s: %d pts", name, score)
}
def add(a, b):
return a + b
# Type hints (optional)
def add(a: int, b: int) -> int:
return a + b
func add(a, b int) int {
return a + b
}
// Types are NOT optional in Go.
// The compiler enforces them always.
a: int. Go writes a int - no colon. And the return type is after the closing paren: func f() int, not func f() -> int. It is Go's own syntax, not Python's and not Java's.
This is Go's single biggest ergonomic win over most other languages. A function can return more than one value - and the compiler tracks all of them separately.
func divmod(a, b int) (int, int) {
return a / b, a % b
}
func main() {
q, r := divmod(10, 3)
fmt.Println(q, r) // 3 1
// Discard the remainder with _
quotient, _ := divmod(10, 3)
fmt.Println(quotient) // 3
}
Almost every Go function that can fail returns (result, error). You will see this pattern hundreds of times:
// The canonical Go error pattern
data, err := os.ReadFile("config.json")
if err != nil {
log.Fatal(err)
}
// data is []byte, safe to use here
// Same pattern in stdlib, k8s client, gRPC - everywhere
resp, err := http.Get("https://example.com")
conn, err := net.Dial("tcp", "localhost:8080")
pod, err := client.CoreV1().Pods("default").Get(...)
def divmod_py(a, b):
return a // b, a % b
# Python returns a tuple
result = divmod_py(10, 3)
# result is (3, 1) - one object
q, r = divmod_py(10, 3)
# unpacking works too
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// Go returns two separate values
q, r := divmod(10, 3)
// q == 3 (int), r == 1 (int)
// Can't store as one thing:
// x := divmod(10,3) -- compile error
Go lets you give names to return values right in the signature. The function body then treats them as pre-declared variables, and a bare return (called a "naked return") sends back whatever those variables hold at that point.
// Named returns - note the parenthesized return types with names
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // naked return: sends back x and y
}
// More useful: named returns document what each value means
func parseConfig(path string) (cfg *Config, err error) {
data, err := os.ReadFile(path)
if err != nil {
return // returns nil, err
}
cfg = &Config{}
err = json.Unmarshal(data, cfg)
return // returns cfg, err
}
| Aspect | Named returns | Unnamed returns |
|---|---|---|
| Readability at call site | Signature is self-documenting | Caller must check docs or IDE hover |
Naked return | Allowed | Must list every value explicitly |
| Risk | Easy to lose track in long functions | Explicit - always clear what you return |
| Team consensus | Many teams ban them for functions > 5 lines | Always acceptable |
return in functions longer than ~20 lines are widely considered a readability problem. The Go standard library uses them selectively. A good rule of thumb: use named returns for short utility functions where the names genuinely add meaning (e.g., (n int, err error)), and always write the full return list in longer functions.
A variadic function accepts any number of arguments of a given type. The ... prefix in the parameter type marks it as variadic. Inside the function, the parameter is just a slice.
func sum(nums ...int) int {
total := 0
for _, n := range nums { // nums is []int inside here
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum(1, 2, 3, 4, 5)) // 15
// Spread an existing slice with ...
xs := []int{10, 20, 30}
fmt.Println(sum(xs...)) // 60
}
def sum_nums(*args):
return sum(args)
sum_nums(1, 2, 3) # 6
xs = [10, 20, 30]
sum_nums(*xs) # spread
# *args is untyped - any type
func sumNums(nums ...int) int {
// nums is []int - typed slice
...
}
sumNums(1, 2, 3) // 6
xs := []int{10, 20, 30}
sumNums(xs...) // spread
// ...int = typed; compiler enforces it
func f(a int, rest ...string) is valid. func f(rest ...string, a int) is not. You can mix fixed and variadic, but variadic always comes last. This is unlike Python where *args can come before keyword args.
Functions in Go are values. You can store them in variables, pass them as arguments, and return them from other functions - the same way you handle integers or strings.
// Store a function in a variable
double := func(n int) int { return n * 2 }
fmt.Println(double(5)) // 10
// Function type declaration (common in HTTP frameworks)
type HandlerFunc func(http.ResponseWriter, *http.Request)
// Higher-order function: accepts a function, returns a function
func Map(slice []int, fn func(int) int) []int {
result := make([]int, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
func main() {
nums := []int{1, 2, 3, 4}
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled) // [2 4 6 8]
}
# Store in a variable
double = lambda n: n * 2
double(5) # 10
# Higher-order function
def my_map(lst, fn):
return [fn(x) for x in lst]
my_map([1,2,3], lambda x: x*2)
// Store in a variable (anonymous func)
double := func(n int) int { return n * 2 }
double(5) // 10
// Higher-order function
func Map(s []int, fn func(int) int) []int
Map([]int{1,2,3},
func(x int) int { return x * 2 })
lambda, but they support multi-line bodies, multiple statements, and defer/return. The trade-off is intentional: Go prioritizes readability over terse expressions.
A closure is a function that "closes over" variables from its surrounding scope. The inner function holds a live reference to those variables - they stay alive as long as the closure does.
// makeCounter returns a function that remembers its own count
func makeCounter() func() int {
count := 0 // this variable is captured by the closure below
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3
// Each call to makeCounter() gets its own independent count
other := makeCounter()
fmt.Println(other()) // 1 - starts fresh
}
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
c() # 1
c() # 2 - needs nonlocal
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
c := makeCounter()
c() // 1
c() // 2 - no nonlocal needed
Before Go 1.22, all loop iterations shared the same loop variable. Closures inside the loop all captured the same variable, which held the final loop value by the time they ran:
// Pre-Go 1.22 surprise: all goroutines print the SAME i
xs := []string{"a", "b", "c"}
for _, v := range xs {
go func() { fmt.Println(v) }() // all print "c"!
}
// Fix pre-1.22: shadow the variable inside the loop
for _, v := range xs {
v := v // new v per iteration
go func() { fmt.Println(v) }()
}
// Go 1.22+: each iteration automatically gets its own variable
// The old gotcha no longer applies in modern Go.
Functions, closures, and multiple returns all appear in real Go code from day one. Three flavors:
A logging middleware wraps any HTTP handler using a closure. The inner function captures the outer handler and adds logging around it:
package main
import (
"log"
"net/http"
"time"
)
// withLogging returns a new handler that logs every request
func withLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r) // call the wrapped handler
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}
}
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!"))
}
func main() {
http.HandleFunc("/", withLogging(hello))
http.ListenAndServe(":8080", nil)
}
withLogging captures next (a function value) and returns a new function that wraps it. This is how every Go HTTP middleware library works - from the stdlib's net/http to Chi and Gin.
A CLI with subcommands often stores the handler for each command in a map[string]func():
package main
import (
"fmt"
"os"
)
func cmdGet() { fmt.Println("get: fetching resource") }
func cmdApply() { fmt.Println("apply: applying manifest") }
func cmdDelete() { fmt.Println("delete: deleting resource") }
func main() {
commands := map[string]func(){
"get": cmdGet,
"apply": cmdApply,
"delete": cmdDelete,
}
if len(os.Args) < 2 {
fmt.Println("Usage: mytool <command>")
os.Exit(1)
}
cmd, ok := commands[os.Args[1]]
if !ok {
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
cmd()
}
$ go run cli.go get
get: fetching resource
$ go run cli.go apply
apply: applying manifest
Storing functions in a map is a clean alternative to a large switch statement. kubectl, terraform, and most production Go CLIs use this pattern.
A Kubernetes controller's core loop is a function that returns (Result, error) - a perfect showcase of Go's multiple return values and error idiom:
package controller
import (
"context"
"fmt"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Reconcile is called every time a watched object changes.
// It returns (ctrl.Result, error) - the canonical k8s controller signature.
func (r *MyReconciler) Reconcile(
ctx context.Context,
req ctrl.Request,
) (ctrl.Result, error) {
// Fetch the object - multi-return + error check
obj := &MyResource{}
if err := r.Client.Get(ctx, req.NamespacedName, obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Do work...
if err := r.doWork(ctx, obj); err != nil {
return ctrl.Result{Requeue: true}, fmt.Errorf("doWork: %w", err)
}
return ctrl.Result{}, nil // success, no requeue
}
The entire controller-runtime framework is built on this two-value return convention. Every error path returns (Result{}, err); success returns (Result{}, nil). It is the same pattern you learned in the "multiple return values" section, applied at scale.
x, err := f().
The compiler will catch an unused variable, but not an ignored error. If you write result, _ := f(), you have silently swallowed a possible failure. Use _ only when you genuinely do not care about the error - and add a comment explaining why. Linters like errcheck or staticcheck will flag this for you in CI.
Go's signature is the contract. If you declare func f() (int, error), every return in the body must give back exactly two values. You cannot return just nil and hope Go fills in the rest. The compiler enforces this with no exceptions.
return in long functions.
A naked return buried 40 lines into a function is a maintenance hazard - it is not obvious what values are being returned. Go's own standard library avoids this pattern in complex functions. Keep named returns to short utility functions, or skip them entirely.
Before Go 1.22, every iteration of a for loop shared one loop variable. Closures launched inside the loop (goroutines, deferred calls, callbacks) all referenced the same memory address, not a snapshot of the value. This led to every closure seeing the final loop value. Go 1.22+ fixes this by giving each iteration its own variable. Check your Go version with go version.
func (r *MyReconciler) Reconcile(...) looks like a function but is technically a method: a function attached to a type. The receiver (r *MyReconciler) is just a special first parameter. Everything you learned in this lesson applies to methods - the only new concept is the receiver syntax, which Lesson 6 covers in depth.
| Term | Meaning |
|---|---|
func | The keyword that declares a function. Every function definition starts with it. |
| Parameter | A variable declared in the function signature that receives an input value. In func add(a, b int), a and b are parameters. |
| Argument | The actual value passed by the caller. In add(2, 3), 2 and 3 are arguments. Parameters are in the definition; arguments are at the call site. |
| Return value | The value(s) a function sends back to the caller via return. Go functions can return more than one. |
| Named return | A return value given a name in the signature: func f() (n int, err error). The names become pre-declared variables inside the function body. |
| Naked return | A bare return statement with no explicit values. Only valid in functions with named returns - returns whatever the named variables currently hold. |
| Variadic | A function that accepts any number of arguments for its last parameter: func f(nums ...int). Inside the function, nums is a []int. |
| Closure | A function that references variables from its enclosing scope. Those variables stay alive as long as the closure is reachable. |
| First-class function | Functions that can be stored in variables, passed as arguments, and returned from other functions - just like any other value. |
| Function value | A function stored in a variable or passed as an argument. Its type is written as func(arg types) return types. |
| Higher-order function | A function that takes a function as a parameter or returns a function as its result. Map, Filter, and middleware factories are common examples. |
Backstory and pitfalls that make this chapter stick.
type Handler func(req Request) Response.func divide(a, b int) (q, r int) { q = a/b; r = a%b; return }....T accepts variable args. Inside the function it's a slice. func sum(nums ...int) int.defer has surprising semantics around evaluated args. defer fmt.Println(x) captures the value of x at the time the defer is REGISTERED, not when it runs. So if you mutate x after the defer line, the printed value is still the original. Use a closure to defer lazy capture: defer func() { fmt.Println(x) }(). Go's standard library uses defer ~9000 times - idiomatic code defers cleanup almost universally.
Will the output be: 1 2 3 then 4, OR 1 2 3 then 1?
package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
a := makeCounter()
b := makeCounter()
fmt.Println(a(), a(), a())
fmt.Println(b())
}Output: 1 2 3 then 1.
Each call to makeCounter() creates a new closure over a new count variable. The closures are independent: a and b each have their own counter. This is the classic closure pattern - and a clean way to create stateful generators without classes/structs.
A backend service handling file uploads had this pattern:
for _, file := range files {
f, err := os.Open(file.Path)
if err != nil { return err }
defer f.Close() // runs at function exit, NOT end of iteration
// process f...
}With 10K files in a batch, the function held 10K open file descriptors before any closed. The process hit the kernel's per-process FD limit (1024 default) and started crashing requests with "too many open files". Cost: 8-hour partial outage for the storage team.
Lesson: defer is per-function, not per-loop-iteration. For loop-scoped cleanup, wrap the work in an anonymous function: func() { defer f.Close(); /* work */ }(). Each call has its own defer scope.
Don't peek. Think first, then click to reveal each answer.
1. A Go function can return how many values?
func main()). Multi-return is idiomatic: (result, error) is the universal pattern.
2. defer foo(x) evaluates the argument x...
defer statement is reacheddefer func() { foo(x) }() if you want lazy evaluation.
3. recover() only works inside...
deferred function in the goroutine that panickedpanic block itselfrecover works only inside a deferred function, and only catches a panic in the same goroutine. Calling recover() at the top of main does nothing.
4. A variadic function called as sum(1, 2, 3) - what's the type of nums inside func sum(nums ...int) int?
int[3]int (fixed array)[]int (slice)interface{}nums is []int{1, 2, 3}. To pass an existing slice: sum(mySlice...) with the trailing dots.
5. Returning a pointer to a local variable from a Go function...
new()return &x is safe and idiomatic.