When designing Go APIs, you might think it’s harmless to return (nil, nil) when an object isn’t found. Many developers do this because it feels like “not found” shouldn’t be treated as an error. However, this approach often leads to one of the most frustrating runtime crashes in Go:
panic: invalid memory address or nil pointer dereference
In this post, we’ll cover:
- The problem — how
(nil, nil)breeds fragile code - Why this design creates real-world headaches
- Two solutions:
- Best practice (recommended): return a predefined error instead
- Alternative pattern: use an explicit existence flag
- An evolved, safer version of the code
The goal isn’t just to fix mistakes, but to enhance API design to prevent misuse down the line.
The Problem: Returning (nil, nil)
Let’s imagine a function that retrieves a user:
func findUser(id string) (*User, error) {
if id == "42" {
return &User{ID: "42", Name: "Ada"}, nil
}
// user not found
return nil, nil
}
On the surface, this seems fine:
- No system error is raised.
- The user just doesn’t exist.
But consider how a caller might use this:
u, err := findUser("404")
if err != nil {
return err
}
fmt.Println(u.Name) // This will cause a panic.
Here’s the catch: when err is nil, the caller assumes that u is safe to use. But since the function returns (nil, nil) for a nonexistent user, u is actually nil. This breaks a critical contract in Go:
- If
err == nil, the returned value should be usable.
This seemingly minor design choice can lead to annoying and hard-to-find bugs.
Why This Design Is Dangerous
The main issue with returning (nil, nil) isn’t just the threat of a panic. It’s about the broken expectations that arise.
In Go, there’s a widely held assumption:
- If
err == nil, the returned value is usable.
By allowing (nil, nil), you force every caller to remember an additional rule:
- Also check if the object is
nil.
This brings about several headaches:
-
Hidden Crashes: Nil pointer dereferences can lead to unexpected crashes. These crashes occur far from the point of origin, making them tricky to debug.
-
Inconsistent Behavior: Developers may forget to check for
nil, resulting in unpredictable behavior spread throughout the codebase. -
Difficult Debugging: When a panic happens, the stack trace may not point clearly to the source, wasting time as developers try to track down the initial error.
-
Refactoring Risks: As your code evolves, forgetting to check for
nilcan lead to subtle bugs during refactoring, complicating updates and changes.
In short, while backend errors can usually be traced, nil pointer panics feel chaotic.
Solution 1 (Best Practice): Never Return (nil, nil)
The straightforward solution is to stop returning (nil, nil) and instead make the absence of an object explicit with a predefined error.
Step 1: Define a Sentinel Error
Start by defining a custom error to indicate that a resource wasn’t found:
var ErrNotFound = errors.New("user not found")
Step 2: Revamp the Function
Next, modify your function to return this error when a user isn’t found:
func findUser(id string) (*User, error) {
if id == "42" {
return &User{ID: "42", Name: "Ada"}, nil
}
return nil, ErrNotFound
}
Step 3: Simplify Caller Logic
Now the calling code becomes clearer as it handles errors more distinctly:
u, err := findUser("404")
if err != nil {
if errors.Is(err, ErrNotFound) {
// Handle missing user
fmt.Println("User not found")
return
}
return err // Handle unexpected errors
}
fmt.Println(u.Name)
This change restores the important guarantee:
- If
err == nil, the result is safe to use.
No extra nil checks clutter the logic, ensuring explicit and clear API usage.
Why This Is the Best Practice
This small change brings you some serious benefits:
-
Safety by Default: Callers can rely on the fact that they won’t need extra nil checks.
-
Easier Debugging: A clear “not found” message improves visibility, making it straightforward to understand what went wrong.
-
Consistent API Contract: This pattern aligns well with those in the Go standard library, like
sql.ErrNoRows,io.EOF, andos.IsNotExist. -
Better Team Scalability: New developers can understand and interact with the API easily, reducing cognitive load as they write code.
This is why established Go projects like Kubernetes, the Go standard library, and tools from HashiCorp prefer using sentinel or typed errors.
Solution 2 (Alternative): Explicit Existence Flag
Sometimes, a “not found” situation isn’t really an error at all. In those cases, it might be better to return an explicit existence flag along with the result:
func findUser(id string) (*User, bool, error) {
if id == "42" {
return &User{ID: "42", Name: "Ada"}, true, nil
}
return nil, false, nil
}
Usage Example
This allows the calling code to look like this:
u, ok, err := findUser("404")
if err != nil {
return err
}
if !ok {
fmt.Println("User not found")
return
}
fmt.Println(u.Name)
This mirrors how you’d typically check for existence in maps, providing clarity when absence is simply part of the business logic.
Senior-Level Takeaway
API design isn’t just about making something work today — it’s about preventing bugs tomorrow.
When you allow the return of (nil, nil), you introduce ambiguity into your API contract. A much clearer rule is:
- If
err == nil, the object must be usable.
This small design tweak can significantly reduce runtime crashes and enhance readability and maintainability throughout your codebase.
The next time you encounter (nil, nil) in your Go code, take a moment to think: can this be represented with an explicit error instead? More often than not, the answer is yes — and both you and your team will be grateful you took the time to fix it.
Enjoyed this article? Support my work with a coffee ☕ on Ko-fi.