20.2 Returning Errors: The (value, error) Convention
Alright, let’s talk about the single most brilliant and simultaneously most annoying piece of Go syntax you’ll encounter: (value, error). It’s the backbone of how we handle things going wrong, and it’s so ingrained in the language’s DNA that you’ll feel its absence when you go back to languages that just chuck exceptions around like confetti.
The core idea is devastatingly simple: any function that can fail should return both the thing you wanted and a separate, explicit error value. If the function succeeded, you get your value and a nil error. If it failed, you get a zero-value (or whatever partial result was achieved) and a non-nil error describing what went pear-shaped. This isn’t a suggestion; it’s a convention so strong it might as well be law. The compiler won’t yell at you if you don’t do it, but every other Go programmer will.
Why This is a Genuine Improvement
Forget try/catch for a moment. The beauty of this pattern is its explicitness. When you call a function, its signature screams at you: “HEY, I MIGHT FAIL. DEAL WITH IT.” There are no hidden control flows. You can’t just blissfully call result = doRiskyThing() and have your program explode three stack frames up because you forgot to catch InconceivableException. You are forced, by the very structure of the code, to at least acknowledge the error exists right there at the call site. It turns what is often an afterthought in other languages into a first-class concern, which is exactly where error handling should be.
Here’s what it looks like in its simplest, most common form:
func GetSomething(id string) (SomeStruct, error) {
if id == "" {
return SomeStruct{}, fmt.Errorf("id cannot be empty")
}
// ... work that might fail ...
result, err := somePackageCall(id)
if err != nil {
return SomeStruct{}, fmt.Errorf("somePackageCall failed for id %s: %w", id, err)
}
return result, nil // Success! Return the good stuff and a nil error.
}
And here’s how you, the caller, are compelled to deal with it:
func MyFunction() error {
valuableThing, err := GetSomething("my-id")
if err != nil {
// The party's over. Handle the error. Log it, return it, panic, whatever.
return fmt.Errorf("GetSomething failed: %w", err)
}
// Only now, because you've checked the error, can you safely use valuableThing
fmt.Println(valuableThing.ImportantField)
return nil
}
The Zero Value is Your Friend
Notice what we return on error: SomeStruct{}, fmt.Errorf(...). We’re returning the “zero value” for SomeStruct. This is crucial. If your function promises to return a string, an int, or a struct, you must return a zero value of that type on error. Why? Because the caller might decide to ignore the error (don’t do that) and use the value anyway. If you returned nil for a struct type, that caller’s code would immediately panic on a nil pointer dereference. By returning a zero value, you ensure the program remains in a somewhat safe state, even if the logic is wrong. It’s a defensive move.
The One Pitfall Everyone Steps In: Shadowing
This is the classic rookie mistake, and it will bite you when you least expect it. Behold the dreaded := shadowing bug:
func main() {
file, err := os.Open("coolfile.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Later... a new block scope, maybe in a loop or if statement
if true {
// 🚨 DANGER! ':=' here re-declares 'err', shadowing the one from outside the block!
file, err := os.Open("anotherfile.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close() // This defers the inner 'file', not the outer one!
}
// The outer 'file' and 'err' are unchanged. The inner file is already closed.
data, err := ioutil.ReadAll(file) // Reads from the FIRST file, which is still open... for now.
}
The inner block uses :=, which creates new variables file and err that only exist inside that if block. The outer file is completely different and isn’t closed by the inner defer. The fix is simple: use plain = for reassignment in inner scopes if the variables are already declared.
// ✅ Correct: use '=' to assign to the existing 'file' and 'err' variables
var err error
file, err = os.Open("anotherfile.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
Wrapping Errors for Context
The biggest weakness of the raw (value, error) pattern is that an error can lose context as it bubbles up the call stack. sql: no rows in result set is a perfectly good error from the database driver, but it’s useless if you don’t know which query caused it. This is where error wrapping comes in, introduced in Go 1.13. Instead of just returning the original error, you annotate it with context using %w verb in fmt.Errorf.
func GetUserProfile(userID string) (*Profile, error) {
profile, err := dbQueryUserProfile(userID)
if err != nil {
// This returns a new error whose Unwrap() method will return the original 'err'
return nil, fmt.Errorf("could not fetch profile for user %s: %w", userID, err)
}
return profile, nil
}
Now, when you handle the error, you can get the chain of context. The caller can check if the root cause was a specific error type (like sql.ErrNoRows) while still having the human-readable context of which user ID was involved. It’s the best of both worlds.