Go's error handling gets a lot of criticism from developers coming from exception-based languages. But the real problem isn't if err != nil — it's the patterns that get built on top of it. Most tutorials show you bare error returns and call it a day. In a production codebase, you end up with errors that tell you something failed but not where, why, or what caller context led there.
The Problem with Bare Error Returns
Here's the code you write when you're moving fast:
func getUser(id string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
This compiles fine. It even works. But when the error reaches your HTTP handler and you log it, you get:
sql: no rows in result set
That's all. No package name, no function name, no request ID, no context about what was being fetched. Multiply this by 50 functions across 15 packages and you have a log that's useless for debugging.
Wrapping Errors with fmt.Errorf and %w
Go 1.13 introduced the %w verb for fmt.Errorf, which wraps an error while preserving the original for later inspection:
func getUser(id string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("getUser(id=%s): %w", id, err)
}
return &u, nil
}
Now the error travels up the stack with context attached at each layer:
handler: processRequest: getUser(id=abc123): sql: no rows in result set
The rule I follow: always wrap at the point of origin, never in the middle without adding information. Wrapping fmt.Errorf("getUser: %w", err) inside a loop that calls getUser ten times just adds noise.
One subtle gotcha: %w creates a chain. fmt.Errorf("context: %w", fmt.Errorf("inner: %w", originalErr)) gives you three levels. That's fine — errors.Unwrap navigates the chain, and errors.Is/errors.As traverse it automatically.
Sentinel Errors: When to Use Them
A sentinel error is a package-level variable that callers can compare against:
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
func getUser(id string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("getUser(id=%s): %w", id, ErrNotFound)
}
return nil, fmt.Errorf("getUser(id=%s): %w", id, err)
}
return &u, nil
}
The caller can now do:
user, err := getUser(id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
This works because errors.Is traverses the entire error chain — even though ErrNotFound is wrapped inside fmt.Errorf, errors.Is finds it.
When to use sentinels: when callers need to make branching decisions based on the error type. Not found, permission denied, rate limited — these are meaningful categories that affect control flow.
When not to: for every internal error. Package io has io.EOF because it's a legitimate signal, not just a failure. Your internal database timeout doesn't need to be a sentinel unless callers genuinely need to handle it differently.
Custom Error Types for Structured Data
Sometimes you need to carry structured data in an error — an HTTP status code, a field name that failed validation, a request ID for correlation. That's where custom error types come in:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{Field: "email", Message: "must contain @"}
}
return nil
}
The caller uses errors.As to extract the structured data:
err := validateEmail(input)
var ve *ValidationError
if errors.As(err, &ve) {
respondWithFieldError(w, ve.Field, ve.Message)
}
errors.As also traverses the chain, so if validateEmail's error gets wrapped by an outer function, errors.As still finds it.
The combination of custom types and wrapping lets you do something powerful: an HTTP handler can catch a *ValidationError and return 400, catch ErrNotFound and return 404, and return 500 for everything else — without knowing the internals of the packages it calls.
Unwrapping: errors.Is, errors.As, and the Chain
Here's the mental model that makes all of this click:
-
errors.Is(err, target)— walks the chain calling==at each step. Use for sentinel comparison. -
errors.As(err, &target)— walks the chain looking for a type match. Use for custom types. -
errors.Unwrap(err)— returns the next error in the chain. Rarely call this directly.
// Chain: wrapErr → ErrNotFound
wrapErr := fmt.Errorf("some context: %w", ErrNotFound)
fmt.Println(errors.Is(wrapErr, ErrNotFound)) // true
fmt.Println(wrapErr == ErrNotFound) // false — never use == on wrapped errors
This is the most common mistake I see in Go codebases: using == to compare errors after wrapping. It always returns false. Always use errors.Is.
One more thing: if you implement a custom error type that wraps another, implement Unwrap() error to make the chain traversable:
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
func (e *AppError) Unwrap() error {
return e.Err
}
Without Unwrap, errors.Is and errors.As stop at AppError and can't see what's wrapped inside it.
Sanitizing Errors at the API Boundary
For security-hardened applications, consistent error handling isn't just about debuggability — it's about not leaking internal details to API consumers while still giving operators enough context to diagnose issues.
A pattern I've used in production:
type internalError struct {
Op string
Err error
}
func (e *internalError) Error() string {
return fmt.Sprintf("op=%s: %v", e.Op, e.Err)
}
func (e *internalError) Unwrap() error { return e.Err }
// At the HTTP boundary, sanitize before responding
func sanitizeError(err error) string {
switch {
case errors.Is(err, ErrNotFound):
return "resource not found"
case errors.Is(err, ErrUnauthorized):
return "unauthorized"
default:
log.Printf("internal error: %v", err) // full chain logged internally
return "internal server error"
}
}
The internal error carries full context for your logs. The API caller gets a safe, generic message. The operator log has everything needed to trace the failure back to its source.
The Takeaway
Go error handling scales when you follow three rules consistently:
-
Wrap with context using
fmt.Errorf("op: %w", err)at every layer that adds meaningful information. - Use sentinels only for errors that callers genuinely need to branch on — not for every internal failure.
-
Use custom types when you need structured data alongside the error message, and always implement
Unwrap()when your type wraps another error.
The errors.Is and errors.As functions handle chain traversal automatically. Once you internalize that model, Go error handling stops being a boilerplate tax and starts being a precise contract between layers.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)