Notes · Learn Go › LESSON 4 OF 8

Functions

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.

Contents
  1. Function anatomy
  2. Multiple return values
  3. Named returns
  4. Variadic functions
  5. Functions are first-class
  6. Closures
  7. Real-world examples
  8. Gotchas
  9. Glossary

Function anatomy

Every Go function follows the same shape. Once you have this shape locked in, everything else in the lesson is just variations on it.

add.go
func add(a, b int) int {
    return a + b
}
func add ( a, b int ) int { return a + b } keyword always "func" function name CamelCase if exported parameters name first, type after return type omit if no return value body in { } opening brace same line
Every Go function in one picture. The type comes after the name - the opposite of Java/C.

When multiple parameters share a type

Go lets you collapse the type annotation when consecutive params share the same type:

params.go
// 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)
}
Python - def
def add(a, b):
    return a + b

# Type hints (optional)
def add(a: int, b: int) -> int:
    return a + b
Go - func
func add(a, b int) int {
    return a + b
}

// Types are NOT optional in Go.
// The compiler enforces them always.
Type annotation direction matters. Python type hints write 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.
The 5-year-old version
A function is a recipe. The name is what you call it ("make a cake"). The parameters are the ingredients you hand over ("2 eggs, 1 cup flour"). The return type is what comes out ("one cake"). The body is the steps in between.

In Go you have to write down exactly what kind of ingredients go in AND what kind of thing comes out - no guessing. The kitchen (compiler) checks your recipe before you even start cooking.

Multiple return values

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.

divmod.go
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
}
divmod (10, 3) caller func divmod (a, b int) (int, int) q = 3 type: int r = 1 type: int use _ to discard either one quotient, _ := divmod(10, 3) Not a tuple! Go returns truly separate typed values, not one packed object.
One function, two typed outputs - each captured into its own variable. The compiler verifies both are used (or discarded with _).

The error idiom - preview

Almost every Go function that can fail returns (result, error). You will see this pattern hundreds of times:

error_idiom.go
// 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(...)
Python - tuple return
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
Go - multi-return
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
Key difference from Python tuples: Python's multi-return is one tuple object unpacked by the caller. Go's is genuinely multiple values at the ABI level - you cannot collect them into a single variable. This distinction matters for error-handling ergonomics: the compiler knows exactly how many values you got back and what type each one is.

Named returns

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_return.go
// 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
}
AspectNamed returnsUnnamed returns
Readability at call siteSignature is self-documentingCaller must check docs or IDE hover
Naked returnAllowedMust list every value explicitly
RiskEasy to lose track in long functionsExplicit - always clear what you return
Team consensusMany teams ban them for functions > 5 linesAlways acceptable
Controversy alert: Named returns with naked 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.

Variadic 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.

variadic.go
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
}
Python - *args
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
Go - ...T
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
Only the last parameter can be variadic. 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 are first-class

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.

first_class.go
// 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]
}
Python - callables & lambdas
# 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)
Go - function values
// 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 })
Go does not have lambdas - it has anonymous functions. They look verbose compared to Python's one-liner lambda, but they support multi-line bodies, multiple statements, and defer/return. The trade-off is intentional: Go prioritizes readability over terse expressions.

Closures

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.

closure.go
// 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
}
makeCounter() scope count = 0 → 1 → 2 → 3 returned func() int { count++; return count } lives here, inside the scope captures returned caller has: counter counter() // 1 counter() // 2 counter() // 3 count stays alive - held by the closure makeCounter() has returned but count is NOT gone - the closure holds a live reference to it.
The closure "grabs" the count variable by reference. Even after makeCounter returns, count lives on - owned by the closure.
Python closure
def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

c = make_counter()
c()  # 1
c()  # 2 - needs nonlocal
Go closure
func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

c := makeCounter()
c()  // 1
c()  // 2 - no nonlocal needed
Loop variable capture - a classic gotcha (pre-Go 1.22):

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:

loop_closure.go
// 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.

Real-world examples

Functions, closures, and multiple returns all appear in real Go code from day one. Three flavors:

BackendHTTP middleware factory (closure)

A logging middleware wraps any HTTP handler using a closure. The inner function captures the outer handler and adds logging around it:

middleware.go
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.

CLISubcommand dispatcher using a map of functions

A CLI with subcommands often stores the handler for each command in a map[string]func():

cli.go
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()
}
terminal
$ 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.

k8sController Reconcile - multi-return + error idiom

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:

reconciler.go
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.

Gotchas (read twice)

1. Forgetting to handle the error in 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.

2. Can't return more or fewer values than declared.

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.

3. Named returns + naked 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.

4. Closure capture-by-reference can surprise you (pre-Go 1.22 loop).

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.

5. Methods are functions with a receiver - covered in Lesson 6.

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.

Glossary

TermMeaning
funcThe keyword that declares a function. Every function definition starts with it.
ParameterA variable declared in the function signature that receives an input value. In func add(a, b int), a and b are parameters.
ArgumentThe 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 valueThe value(s) a function sends back to the caller via return. Go functions can return more than one.
Named returnA 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 returnA bare return statement with no explicit values. Only valid in functions with named returns - returns whatever the named variables currently hold.
VariadicA function that accepts any number of arguments for its last parameter: func f(nums ...int). Inside the function, nums is a []int.
ClosureA function that references variables from its enclosing scope. Those variables stay alive as long as the closure is reachable.
First-class functionFunctions that can be stored in variables, passed as arguments, and returned from other functions - just like any other value.
Function valueA function stored in a variable or passed as an argument. Its type is written as func(arg types) return types.
Higher-order functionA function that takes a function as a parameter or returns a function as its result. Map, Filter, and middleware factories are common examples.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
func
Function declaration. Functions are first-class values: assignable to variables, passable as args. type Handler func(req Request) Response.
return
Explicit return. Multiple return values are first-class. Named returns let you assign and return implicitly: func divide(a, b int) (q, r int) { q = a/b; r = a%b; return }.
defer
Schedules a call at function exit. LIFO order. Famously used for cleanup (file.Close, mutex.Unlock). Arguments evaluated when defer is reached, not when it runs.
recover
The only way to catch a panic. Must be called from within a deferred function. Returns the panic value or nil. Use sparingly.
panic
Immediate stack unwind. For truly unrecoverable bugs, NOT errors. Idiomatic Go prefers returning errors.
closure
Anonymous function capturing variables from its enclosing scope. Equivalent to Python lambdas but multi-line and statically-typed.
variadic
Last parameter as ...T accepts variable args. Inside the function it's a slice. func sum(nums ...int) int.
Fun fact 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.
Bug hunt

Will the output be: 1 2 3 then 4, OR 1 2 3 then 1?

closure-counter.go
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())
}
Click to reveal the bug

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.

Real-world incident defer in a loop ate the FD limit 8-hour partial outage, ~$50K SLA

A backend service handling file uploads had this pattern:

upload.go
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.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. A Go function can return how many values?

  • Exactly 1
  • 0, 1, or 2
  • Any number, including 0
  • Up to 5
Show answer
Answer: c. Functions can return any number of values, including 0 (e.g., func main()). Multi-return is idiomatic: (result, error) is the universal pattern.

2. defer foo(x) evaluates the argument x...

  • When the deferred call runs
  • When the defer statement is reached
  • Lazily, on first reference inside foo
  • Never - it's a closure reference
Show answer
Answer: b. Arguments are evaluated immediately at the defer statement. Only the call itself is deferred. Use defer func() { foo(x) }() if you want lazy evaluation.

3. recover() only works inside...

  • Any function in the goroutine that panicked
  • A deferred function in the goroutine that panicked
  • The panic block itself
  • A goroutine started AFTER the panic
Show answer
Answer: b. recover 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{}
Show answer
Answer: c. Variadic params become slices inside the function. 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...

  • Causes a crash - the variable goes out of scope
  • Is fine - Go's escape analysis moves it to the heap
  • Requires explicit new()
  • Is a compile error
Show answer
Answer: b. Unlike C, Go's compiler tracks escape. If a local variable's address escapes the function, the compiler promotes it to the heap automatically. return &x is safe and idiomatic.