Go Functions Return Two Things. That Single Decision Changes How You Think About Errors Forever.
The design decision behind every Go error you will ever write
You are on-call. A Python microservice that pulls remote configuration is silently returning stale data. Three hours later you find out why: somewhere up the call stack, a try/except block caught a network error, swallowed it, and returned a cached dict that was three deploys behind. The service never crashed. Nobody noticed.
Go looked at this class of problem and made a choice. Not exceptions. Not nulls. Not sentinel values. It said: a function that can fail should hand you both the result and the error. You decide what to do with each. Every time. No ambiguity.
That single design decision shapes how every Go program is structured.
Hi — this is Pushpit from CloudOdyssey . I write about Cloud, DevOps, Systems Design deep dives and community update around it. If you have not subscribed yet, you can subscribe here.
The Core Pattern: Two Return Values
Here is a function that fetches configuration from a remote endpoint. This is the idiomatic Go signature for anything that can fail:
// fetchConfig retrieves and parses config from a remote URL.
// Returns a pointer to Config on success, or an error explaining what went wrong.
func fetchConfig(url string) (*Config, error) {
resp, err := http.Get(url)
if err != nil {
// Network call failed — return nil for the config, describe the failure
return nil, fmt.Errorf("failed to reach config endpoint: %w", err)
}
defer resp.Body.Close()
var cfg Config
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
// Payload arrived but couldn't be parsed
return nil, fmt.Errorf("config response was malformed: %w", err)
}
// Happy path: both values returned, error is nil
return &cfg, nil
}At the call site, you get both values simultaneously:
cfg, err := fetchConfig("https://config.internal/app")
if err != nil {
// Handle it — log it, return it, wrap it, your choice
log.Fatalf("startup failed: %v", err)
}
// If you reach here, err is nil and cfg is safe to use
applyConfig(cfg)Three scenarios, all explicit:
Success:
errisnil,cfgholds your dataNetwork failure:
errdescribes what broke,cfgisnilParse failure:
errdescribes the malformed payload,cfgisnil
Nothing is hidden in a stack trace you may or may not catch.
Go does give you a way to discard a return value the blank identifier:
cfg, _ := fetchConfig(url) // ignoring the error entirelyThat underscore is Go’s way of making you deliberate about the choice. You have to write the discard. In production code, ignoring an error from a fallible function is the pattern that causes exactly the silent failure described at the top of this article. The compiler will reject an unused variable but it will not stop you ignoring an error. That decision is yours. The convention in serious Go codebases is unambiguous: if a function can fail, you handle what it returns.
Variadic Functions: When You Don’t Know the Input Count
Sometimes you don’t know how many arguments a function will receive. A structured logging function is the clearest example:
// logEvent accepts a severity level plus any number of contextual message strings
func logEvent(level string, messages ...string) {
// messages is a []string inside this function
for _, msg := range messages {
fmt.Printf("[%s] %s\n", level, msg)
}
}
// Works with one message
logEvent("INFO", "deployment started")
// Works with several
logEvent("WARN", "replica count low", "current: 1", "desired: 3")
// If you already have a slice, spread it with ...
fields := []string{"pod: nginx-abc", "namespace: production"}
logEvent("ERROR", fields...)The ...string syntax means “zero or more strings.” Inside the function, messages behaves like a regular slice. Libraries like zerolog and logrus lean on this pattern heavily for structured field logging the variadic argument is how they let you chain context without a fixed function signature.
Functions as Values: The Strategy Pattern in 10 Lines
Functions in Go are first-class values. You can pass them as arguments, store them in variables, and return them from other functions. The most practical version of this for DevOps work is the retry wrapper:
// retry attempts fn up to `attempts` times, returning the last error on failure.
// fn is a function value — retry doesn't know or care what operation it wraps.
func retry(attempts int, fn func() error) error {
var err error
for i := 0; i < attempts; i++ {
err = fn() // call whatever function was passed in
if err == nil {
return nil // success — stop retrying
}
time.Sleep(time.Second * time.Duration(i+1)) // simple backoff
}
return fmt.Errorf("failed after %d attempts: %w", attempts, err)
}
// Usage: pass any operation that matches func() error
err := retry(3, func() error {
return applyKubernetesManifest(ctx, client, manifest)
})The retry function is completely reusable. It does not know about Kubernetes, manifests, or HTTP calls. It knows about attempts and errors. This is exactly the pattern behind k8s.io/client-go/util/retry.RetryOnConflict the Kubernetes client library exposes a function that accepts a func() error and retries it when it hits a resource conflict. The caller supplies the specific operation. The library supplies the retry logic.
Closures: A Function With Memory
A closure is what you get when a function captures variables from the scope where it was created. Those variables stay alive as long as the function does.
A rate limiter is the clearest operational example:
// newRateLimiter returns a function that tracks how many times it has been called.
// Each call to newRateLimiter creates an independent counter.
func newRateLimiter(max int) func() bool {
count := 0 // this variable is captured by the returned function
return func() bool {
count++ // count persists across calls — it lives with the closure
return count <= max
}
}
// Create two independent rate limiters
apiLimit := newRateLimiter(100)
dbLimit := newRateLimiter(20)
// Each has its own counter — they do not share state
fmt.Println(apiLimit()) // true, count is 1
fmt.Println(dbLimit()) // true, this counter is also at 1count is private to each closure instance. apiLimit and dbLimit are separate functions with separate memories.
The pattern scales directly to HTTP middleware. A real-world use: an outer function receives a logger; the inner handler function captures it and uses it on every request:
// withLogging wraps any http.HandlerFunc with request logging.
// The logger is captured — every wrapped handler gets its own reference.
func withLogging(logger *slog.Logger, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Info("incoming request", "method", r.Method, "path", r.URL.Path)
next(w, r) // call the original handler
}
}Each route can be wrapped independently, each with its own logger configuration. The outer function runs once. The inner function runs on every request, carrying the logger it captured at creation.
In the Cloud Wild
The Go standard library’s
http.HandleFuncaccepts afunc(http.ResponseWriter, *http.Request)a function value. Every Go web framework, Gin, Echo, Chi, works the same way. When you writerouter.GET("/health", healthHandler), you are passing a function value. The framework stores it internally and calls it on each matching request. The closure and function-as-value patterns you see here are not advanced Go they are the mechanism underneath every HTTP router you will ever use in this ecosystem.
What’s Next
Pointers. The word that scares anyone who learned C and never fully recovered. In Go, pointers are deliberately restricted no arithmetic, garbage collected, safe by default. The next article covers exactly when you need them (hint: when you need to share state across function boundaries) and when you do not. If you understand multiple return values and closures from this article, pointers will click faster than you expect.


