When you need to pass behavior
You're writing a service that talks to a flaky external API. The network drops packets. You need to retry the request three times before giving up. You could copy-paste the retry loop into every handler. Or you could write the retry logic once and pass the specific request as a callback. That callback is a function. Passing a function to another function is the core of higher-order functions in Go.
Higher-order functions let you abstract control flow. You write the loop, the error handling, and the logging once. You inject the specific action by passing a function. This pattern appears everywhere in Go: HTTP middleware, database transaction wrappers, sort comparators, and retry helpers.
Functions as values
A higher-order function is a function that accepts a function as an argument or returns a function as a result. Go treats functions as first-class values. You can store them in variables, pass them to other functions, and return them from functions, just like integers or strings.
Think of a higher-order function as a machine that takes a tool and uses it to do work. The machine provides the structure: the loop, the error handling, the logging. The tool provides the specific action. You swap tools without rebuilding the machine.
Functions are values. Treat them like variables with behavior.
Minimal example
Here's the simplest form: a function that takes another function and calls it.
package main
import "fmt"
// Run executes the provided function and prints a message before and after.
func Run(action func()) {
fmt.Println("Starting...")
action() // Call the passed function to perform the specific work.
fmt.Println("Done.")
}
func main() {
// Pass an anonymous function that prints a specific message.
Run(func() {
fmt.Println("Hello from the callback.")
})
}
The Run function has a parameter action of type func(). That type represents a function with no arguments and no return values. Inside main, you create an anonymous function and pass it directly. The compiler matches the signature. If the signatures don't match, the compiler rejects the program with cannot use func() {…} (value of type func()) as func() error value in argument.
What happens at runtime
When you compile this, the compiler sees func() as a type. It's a function type. The Run function has a parameter of that type. At runtime, main creates a closure (the anonymous function) and passes it to Run. Run stores the reference in the action variable. When action() is called, execution jumps to the code inside the anonymous function, runs it, and returns to Run.
The function value behaves like a pointer to code, but the compiler handles the details. You don't manage memory for function values. If the anonymous function captures variables from the surrounding scope, the compiler may move those variables to the heap. This is escape analysis in action. The compiler decides based on whether the closure outlives the stack frame. You don't control this. Trust the compiler to optimize memory usage.
Realistic patterns: retries and middleware
Real code usually involves return values, errors, and context. Here's a retry helper that takes a function returning an error and retries until it succeeds or hits a limit.
package main
import (
"fmt"
"time"
)
// Retry runs the operation up to maxAttempts times, waiting between failures.
// It returns the first nil error or the last error if all attempts fail.
func Retry(maxAttempts int, delay time.Duration, op func() error) error {
var lastErr error
for i := 0; i < maxAttempts; i++ {
lastErr = op() // Execute the operation.
if lastErr == nil {
return nil // Success: return immediately.
}
if i < maxAttempts-1 {
time.Sleep(delay) // Wait before the next attempt.
}
}
return lastErr // Return the error from the final attempt.
}
func main() {
// Simulate a flaky operation that fails twice then succeeds.
attempts := 0
err := Retry(3, 100*time.Millisecond, func() error {
attempts++
if attempts < 3 {
return fmt.Errorf("transient failure")
}
return nil
})
fmt.Println("Result:", err)
}
In production, your retry function should accept a context.Context as the first parameter. The convention is ctx as the name. Check ctx.Err() inside the loop to stop retrying if the caller cancels. Functions that take a context should respect cancellation and deadlines. This keeps long-running operations responsive.
Web frameworks use higher-order functions for middleware. You wrap a handler to add cross-cutting concerns like logging or auth.
package main
import (
"fmt"
"net/http"
)
// LoggingMiddleware wraps a handler to log requests.
// It returns a new handler that logs before calling the original.
func LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Request: %s %s\n", r.Method, r.URL.Path)
next(w, r) // Call the original handler.
}
}
func main() {
// Wrap the handler with middleware.
handler := LoggingMiddleware(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// In a real app, you'd pass this to http.ListenAndServe.
}
Middleware is just a function returning a function. Chain them to build pipelines.
Functions and interfaces
Functions can implement interfaces. This bridges higher-order functions with Go's interface system. If a function's signature matches an interface's method set, the function implements the interface.
package main
import "fmt"
// Greeter defines a contract for greeting.
type Greeter interface {
Greet() string
}
// SayHello returns a string.
func SayHello() string {
return "Hello"
}
func main() {
// Function implements interface via method set.
var g Greeter = SayHello
fmt.Println(g.Greet())
}
The function SayHello has the signature func() string. The interface Greeter has a method Greet() string. The signatures match, so SayHello implements Greeter. You can assign the function to a variable of the interface type and call the method. This follows the spirit of "accept interfaces, return structs" by defining a clear contract. Function types are often defined as named types for clarity. type Handler func(http.ResponseWriter, *http.Request) is better than repeating the signature everywhere.
Pitfalls and compiler errors
Function types must match exactly. If you pass a function with the wrong signature, the compiler rejects it. The error message tells you the mismatch. For example, cannot use func() int {…} (value of type func() int) as func() error value in argument. Fix the signature or wrap the function.
Closures capture variables by reference, not by value. If you create functions inside a loop, they all share the same loop variable.
// BAD: All functions print the final value of i on Go versions before 1.22.
var funcs []func()
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
fmt.Println(i)
})
}
On Go versions before 1.22, all functions print 3. The loop variable i is shared. When the loop ends, i is 3. The closures capture the reference to i, so they all see the final value.
Go 1.22 fixed this specific loop variable capture issue. The compiler now creates a new variable for each iteration automatically. If you're on Go 1.22+, the bad example works as expected. If you're on an older version, you get the bug. The compiler warns with loop variable i captured by func literal in newer versions if you try the old pattern, which became a hard error in Go 1.22+. Always check your Go version. If you need to support older versions, create a local copy inside the loop.
// GOOD: Each function captures its own copy of i.
for i := 0; i < 3; i++ {
i := i // Create a new variable scoped to this iteration.
funcs = append(funcs, func() {
fmt.Println(i)
})
}
Closures capture variables, not values. Check your Go version.
When to use higher-order functions
Use a higher-order function when you want to abstract control flow like retries, caching, or logging without duplicating the wrapper code. Use a function type as a parameter when you need to inject behavior, such as a custom comparator for sorting or a validation rule for a handler. Use a returned function when you need to create closures that capture state, like generating unique IDs or building middleware chains. Use a struct with methods when the behavior requires maintaining internal state across calls that a simple closure can't manage cleanly. Use plain sequential code when the abstraction adds complexity without reusing the pattern: the simplest thing that works is usually the right thing.
Abstract the pattern, not the noise.