The hidden variable trap
You write a function that divides two numbers. You add a defer to clean up resources or attach context to an error. You test it. It works. Then you deploy it, and suddenly your error handling overwrites a successful result, or your cleanup code mutates a value you already returned. The code compiles. The tests pass in isolation. Production returns the wrong value. The culprit isn't a race condition or a memory leak. It is a quiet interaction between named return values and defer.
How named returns actually work
Go functions can declare return values by name. When you write func calculate() (total int, err error), the compiler does not just create a signature. It allocates two local variables, total and err, and initializes them to their zero values immediately when the function starts. They live on the stack alongside your other local variables. Every return statement, even a bare return with no values, simply reads those named variables and hands them back to the caller.
Think of named returns like a pre-printed form you fill out at a desk. The form sits in front of you from the moment you sit down. You can write on it, erase it, and pass it around. When you finally slide it across the counter, whatever is written on it goes out the door. The form exists before you finish your work. That early existence is what makes defer dangerous.
The compiler lays out the stack frame for the function. Local variables and named returns share the same memory space. There is no special "return register" that isolates the output from the rest of the function body. The named variables are just locals with a different lifecycle. They survive until the function frame unwinds. defer hooks into that unwinding process.
Named returns are stack variables, not magic portals. Treat them like any other local state.
The defer collision
Here is the simplest collision. A division function uses named returns and a deferred cleanup step.
func divide(a, b int) (result int, err error) {
// defer registers a closure that runs after the function body completes
defer func() {
if err != nil {
result = 0 // resets the result when an error occurs
}
}()
if b == 0 {
err = fmt.Errorf("division by zero")
return
}
result = a / b
return
}
Watch the execution order. The function starts. result and err are both zero. The defer registers a closure to run later. The division happens. result gets the quotient. The bare return triggers. Before the caller receives the values, Go runs the deferred closure. The closure checks err. It is nil, so it does nothing. The function returns the correct quotient.
Now change the input to divide(10, 0). The function starts. result and err are zero. The defer registers. The if b == 0 branch sets err. The bare return triggers. The deferred closure runs. It sees err is not nil. It sets result = 0. The function returns 0 and the error. That looks correct.
The trap appears when you add a second path. Suppose you want to log a warning when the divisor is negative, but still return the mathematical result. You set err to a warning, calculate result, and return. The deferred closure sees a non-nil err and overwrites your valid calculation with zero. The closure has no way to distinguish between a fatal error and a warning. It mutates a variable that already holds a valid answer.
The compiler will not stop you. Named returns are just local variables. The compiler complains with result declared and not used only if you never assign to them. It trusts you to manage the stack frame.
Never let defer rewrite a value that already holds a valid answer.
Real-world pattern: error wrapping
Production code rarely divides integers. It opens files, talks to databases, and wraps errors. Named returns shine when you need to attach context to an error without losing the original value. Here is a realistic pattern that avoids the trap.
func fetchConfig(path string) (cfg *Config, err error) {
// capture the path in a local variable so we can reference it in defer
target := path
defer func() {
// wrap the error with file context only if something went wrong
if err != nil {
err = fmt.Errorf("reading %s: %w", target, err)
}
}()
data, readErr := os.ReadFile(target)
if readErr != nil {
err = readErr
return
}
cfg = &Config{}
parseErr := json.Unmarshal(data, cfg)
if parseErr != nil {
err = parseErr
return
}
return
}
This pattern works because the deferred closure only touches err. It never touches cfg. The cfg variable stays untouched after assignment. The error gets wrapped exactly once, right before the function exits. This is the standard Go idiom for error enrichment. The community accepts the slight verbosity because it keeps the failure path explicit and prevents silent data loss.
Notice the if err != nil { return err } pattern repeated throughout the function. Go favors explicit error handling over implicit control flow. The boilerplate makes the unhappy path visible at every step. You can trace exactly where a failure originated without reading the entire stack trace.
Error wrapping in defer is plumbing. Run it through every long-lived call site.
When the compiler catches you
The most common mistake is assuming defer runs after the return values are sent to the caller. It does not. defer runs before the stack frame unwinds. Any mutation to a named return inside defer becomes the final value.
Another trap is shadowing. If you declare a new variable with the same name inside the function body, the deferred closure captures the inner variable, not the named return. The compiler rejects this with cannot use deferred assignment to named return value in some contexts, but more often it silently compiles and returns the zero value because the closure mutated the wrong variable. Always check your scope.
Panic recovery changes the rules slightly. A defer with recover() can inspect and modify named returns before the function exits normally. The recovered panic value replaces the normal return flow, but the named variables still hold whatever state existed at the panic site. If you mutate them in the recovery block, those mutations become the return values. This is intentional. It lets you convert a panic into a structured error response.
func safeParse(input string) (val int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parser panicked: %v", r)
val = 0
}
}()
val, _ = strconv.Atoi(input)
return
}
The _ discards the second return value from Atoi because we handle the error path through panic recovery in this specific wrapper. In real code, you would usually just return the error directly instead of relying on panic recovery for control flow. Panics are for unrecoverable states, not routine validation.
Multiple defer statements execute in reverse order. The last one registered runs first. If you register three defers that all mutate the same named return, only the first one to run determines the final value. The others overwrite it. This behavior is consistent but easy to misread.
The compiler trusts you to manage the stack frame. Do not fight it with hidden mutations.
Choosing your return strategy
Use named return values when you need to wrap errors in a defer block without losing the original error chain. Use named return values when a function has multiple exit points and you want to avoid repeating the same variable assignments. Use anonymous return values when the function has a single return statement or when the return types are obvious from the signature. Use local variables with explicit assignment when you need to mutate intermediate results before the final return. Reach for defer only for cleanup, logging, or error wrapping. Never use defer to implement core business logic.
Go favors explicit over implicit. Named returns reduce boilerplate but increase cognitive load. The standard library uses them sparingly. You will see them in net/http and encoding/json where error wrapping is frequent. Most application code prefers anonymous returns with explicit assignments. Trust the compiler to catch unused variables. Trust your readers to follow a straight line of code.
Named returns are a tool, not a default. Pick the shape that matches the function.