The Secret Life of Go: Error Handling
The Secret Life of Go: Error Handling
Sentinel Errors, Wrapping, and The String Trap
#Golang #ErrorHandling #SoftwareEngineering #BackendDev
Eleanor is a senior software engineer. Ethan is her junior colleague. They work in a beautiful beaux arts library in Lower Manhattan — the kind of place where coding languages are discussed like poetry.
Episode 30
Ethan was reviewing an HTTP handler he had just written. He wanted to return a 404 Not Found if a database query failed, and a 500 Internal Server Error for anything else.
"How does this look?" he asked, pointing to his screen.
user, err := db.GetUser(id)
if err != nil {
// If the error message contains the words "not found", it's a 404
if strings.Contains(err.Error(), "not found") {
return respondWithError(w, 404, "User not found")
}
// Otherwise, it's a real server error
return respondWithError(w, 500, "Internal server error")
}
Eleanor leaned in, her eyes scanning the strings.Contains line. "That," she said softly, "is a ticking time bomb."
"Why?" Ethan asked. "It works perfectly in my tests."
"It works today," Eleanor corrected. "But what happens next month when we update our database driver, and the library authors change their error message from 'record not found' to 'no rows in result set'?"
Ethan blinked. "My strings.Contains would fail. The app would start returning 500s instead of 404s, and pager alerts would go off at 3 AM."
"Exactly. Checking an error's string value is incredibly brittle," Eleanor explained. "In Go, an error isn't just a string for a human to read. It is a piece of state for your program to inspect."
The Sentinel Error
Eleanor opened the database package file. "Instead of relying on random text, a package should declare its specific failure states as exported variables. We call these Sentinel Errors—they stand guard, representing a specific, known failure."
She typed at the top of the file:
import "errors"
// ErrNotFound is a Sentinel Error.
// It is exported (capitalized) so other packages can check against it.
var ErrNotFound = errors.New("record not found")
"Now," she said, switching back to the HTTP handler, "we check against the Sentinel using errors.Is."
user, err := db.GetUser(id)
if err != nil {
// We ask Go: "Is this specific error inside the chain?"
if errors.Is(err, database.ErrNotFound) {
return respondWithError(w, 404, "User not found")
}
return respondWithError(w, 500, "Internal server error")
}
Ethan nodded. "That's much safer. If they change the text inside ErrNotFound later, my if statement still works because it's checking the memory address of the variable, not the string."
The Wrapping Problem (%v vs %w)
Ethan frowned at the screen. "But wait. If the database layer just returns ErrNotFound, the HTTP handler won't know which user ID failed. I need to add context to the error before I return it up the stack."
He quickly typed out a solution:
func GetUser(id int) (User, error) {
// ... db lookup fails ...
// Add context to the Sentinel error using fmt.Errorf and %v (value)
return User{}, fmt.Errorf("failed to fetch user %d: %v", id, ErrNotFound)
}
"Don't do that!" Eleanor warned, catching his hand before he hit save.
"If you use %v (value) or %s (string), fmt.Errorf creates a brand new, flattened string. It destroys the original Sentinel Error. When your HTTP handler calls errors.Is(err, ErrNotFound), it will return false. The Sentinel is gone."
"So how do I add context without destroying the Sentinel?"
"You wrap it," Eleanor said, smiling. "Go 1.13 gave us a superpower. We use the %w verb."
She changed one character in his code:
func GetUser(id int) (User, error) {
// ... db lookup fails ...
// Use %w to WRAP the error
return User{}, fmt.Errorf("failed to fetch user %d: %w", id, ErrNotFound)
}
"Think of %w like a Matryoshka doll," Eleanor explained. "It creates a new error with your helpful context ('failed to fetch user 42'), but it hides the original ErrNotFound securely inside it. It creates a linked list of errors."
"And errors.Is knows how to open the dolls?" Ethan asked.
"Exactly," Eleanor said. "When you call errors.Is(err, ErrNotFound), Go automatically unwraps the dolls, layer by layer, checking each one to see if it matches the Sentinel you are looking for."
Ethan looked at his refactored code. It was clean, type-safe, and completely immune to string-formatting bugs.
"I was treating errors like console logs," Ethan realized. "I was just formatting text."
"It's the most common mistake in Go," Eleanor agreed. "An error is an API. You are designing a programmable interface of failure states. Build it so the machine can read it, and the humans will be fine."
Key Concepts
The String Trap
- Never use
strings.Contains(err.Error(), "...")to determine your application's control flow. Error strings are meant for humans and logging; they are subject to change and will break your logic silently.
Sentinel Errors
- A package-level, exported error variable (e.g.,
var ErrNotFound = errors.New("...")) used to indicate a specific, expected failure state. - Callers can use these variables to make decisions without relying on strings.
Error Wrapping (%w)
- When returning an error up the stack, you often want to add context (e.g., "failed to open config file").
- Using
fmt.Errorf("... %v", err)destroys the original error type. - Using
fmt.Errorf("... %w", err)wraps the error, creating a chain. The context is added, but the original error is preserved inside.
errors.Is
- Replaces the old
if err == ErrNotFoundpattern. errors.Isautomatically unwraps the error chain (the Russian nesting dolls) and checks if the target Sentinel error exists anywhere in the chain.
Aaron Rose is a software engineer and technology writer at tech-reader.blog. For explainer videos and podcasts, check out Tech-Reader YouTube channel.
.jpeg)

Comments
Post a Comment