7.3 Named Return Values and Naked Returns
Right, let’s talk about one of Go’s most initially charming, then mildly horrifying, and ultimately pragmatic features: named return values. This is where we stop treating our function returns like an anonymous bag of data and start giving them proper names right in the function’s signature. It feels like you’re declaring the structure of your answer before you’ve even written the question.
Here’s the basic idea. Instead of this:
func GetCoordinates() (float64, float64, error) {
// ... logic
return lat, long, err
}
You can write this:
func GetCoordinates() (lat float64, long float64, err error) {
// ... logic
return lat, long, err
}
At first glance, it seems like a bit of syntactic sugar that just makes the return statement a bit clearer. And you’d be right. But that’s just the surface. The real magic, and the subsequent foot-gun, lies in what happens when you just write a naked return statement.
The Naked Return: A Tempting Shortcut
When you use named return values, Go automatically initializes them to their zero values at the start of your function. lat and long are 0.0, and err is nil. Now, because these variables are already declared, you can use a return statement without any arguments. This is called a “naked return.”
func GetCoordinates() (lat float64, long float64, err error) {
lat = 42.3601
long = -71.0589
err = nil
return // This returns the current values of lat, long, and err.
}
This looks convenient, right? Less typing. Fewer chances to mess up the order in the return statement. And for very short functions, it can be fine. But we’re not here to do “fine.” We’re here to write robust, readable code. Let me tell you why I, and most seasoned Gophers, treat naked returns like a loaded weapon: useful in specific, highly controlled circumstances, but dangerous if you wave it around carelessly.
Why Naked Returns Are a Code Smell
The problem is scope and clarity. Those named return variables are in scope for the entire function. This means any assignment anywhere in your function body is mutating what will ultimately be returned.
func ProcessData(input string) (result string, err error) {
if input == "" {
err = errors.New("empty input") // This mutates the return value `err`
return // returns "", and that error
}
// Let's do some work...
transformed := strings.ToUpper(input)
// ... imagine 50 more lines of complex logic here ...
result = transformed // This mutates the return value `result`
return // returns the transformed string and a nil error
}
See the issue? If you’re scanning the bottom of this function for the return statement to see what’s being returned, you get no information. You have to trace through every line of the function to understand what assignments have happened to result and err. This is a maintenance nightmare. It inverts the normal flow of a function, where the final return statement is the ultimate source of truth.
The defer statement amplifies this problem into a full-on super-villain. Because defer runs after the rest of the function but before the return, you can modify your named return values one last time on the way out.
func DoubleAndFormat(n int) (result int, err error) {
defer func() {
if err != nil {
result = 0 // Yikes! Modifying a return value in a defer!
}
}()
if n > 1000 {
err = errors.New("number too large")
return // This returns (n, err), but then the defer changes it to (0, err)!
}
result = n * 2
return
}
This is a powerful trick for adding last-second logging or error wrapping, but it’s also incredibly easy to create logic that is deeply surprising and hard to debug. The flow of data is no longer linear.
The Right Way: Clarity and Explicitness
So, are named return values useless? Absolutely not. They shine in two specific scenarios.
First, documentation. The names serve as excellent in-line documentation for what the function returns. A signature like func ParseConfig(path string) (config Config, err error) is instantly clear.
Second, and most importantly, they are essential for deferred error handling. This is their killer app and the one place where the slightly weird scope rules are worth the cost.
func ReadFile(filename string) (contents []byte, err error) {
f, err := os.Open(filename)
if err != nil {
return nil, err // We have to return here, no defer needed
}
defer func() {
closeErr := f.Close()
if err == nil { // If the main function didn't error...
err = closeErr // ...then make the close error our return error.
}
}()
contents, err = io.ReadAll(f)
return // returns (contents, err) which might be overridden by the defer
}
Here, the named err allows the defer to modify the error return value only if the primary operation succeeded. This is the idiomatic Go way to ensure resources are cleaned up and that a close error doesn’t mask a more important read error.
The best practice is simple: Use named returns for documentation and complex defer patterns, but avoid naked returns. Always be explicit in your final return statement.
// Do this
func Calculate(a, b int) (sum int, product int) {
sum = a + b
product = a * b
return sum, product // Explicit. Clear. Good.
}
// Not this
func Calculate(a, b int) (sum int, product int) {
sum = a + b
product = a * b
return // Naked. The reader has to scan for assignments. Bad.
}
You’ll thank yourself in six months when you’re debugging at 2 AM. Trust me. I’ve been there. The five characters you save by typing return instead of return sum, product are not worth the mental overhead. Your brilliant future self will appreciate your pedantic present self’s commitment to clarity.