Error handling in Go is explicit by design. Every function that can fail returns an error as its last value, and callers must deal with it. That explicitness is valuable — but as your codebase grows, return err stops being enough. You lose context, callers can't distinguish failure modes, and debugging production incidents becomes guesswork. This article covers three patterns that hold up at scale: error wrapping, sentinel errors, and custom error types.
Why return err Isn't Enough
Consider a function that reads a config file, parses it, then initializes a database connection. Each step can fail. If you bubble up raw errors untouched, the caller might see:
permission denied
Was that the file read? The DB connection? No idea. The fix is adding context at each layer, so by the time the error reaches the top, it reads like a breadcrumb trail.
Error Wrapping with fmt.Errorf and %w
Go 1.13 introduced the %w verb in fmt.Errorf. It wraps an error so the original remains inspectable via errors.Is and errors.As, while still prepending human-readable context.
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("readConfig %q: %w", path, err)
}
return data, nil
}
func loadApp(configPath string) error {
_, err := readConfig(configPath)
if err != nil {
return fmt.Errorf("loadApp: %w", err)
}
return nil
}
func main() {
err := loadApp("/etc/myapp/config.yaml")
if err != nil {
fmt.Println(err)
// loadApp: readConfig "/etc/myapp/config.yaml": open /etc/myapp/config.yaml: no such file or directory
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("path that failed:", pathErr.Path)
}
}
}
The error message reads like an English stack trace. Each layer adds its prefix, and the underlying *os.PathError remains accessible through the chain.
One practical rule: use %w only once per fmt.Errorf call. If you find yourself wanting to wrap two errors at once, the function is probably doing too much.
Sentinel Errors
Sentinel errors are package-level variables that callers compare against to branch on specific failure modes. They're the right tool when the caller needs to change behavior — returning a 404 instead of a 500, retrying instead of aborting.
package store
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record already exists")
ErrPermission = errors.New("permission denied")
)
type User struct {
ID int
Name string
}
var db = map[int]*User{1: {ID: 1, Name: "alice"}}
func GetUser(id int) (*User, error) {
user, ok := db[id]
if !ok {
return nil, fmt.Errorf("GetUser %d: %w", id, ErrNotFound)
}
return user, nil
}
The HTTP handler can now make clean decisions:
func handleGetUser(w http.ResponseWriter, r *http.Request) {
id := parseID(r)
user, err := store.GetUser(id)
if err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, store.ErrPermission):
http.Error(w, "forbidden", http.StatusForbidden)
default:
log.Printf("GetUser: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
json.NewEncoder(w).Encode(user)
}
errors.Is traverses the full wrapping chain, so even if GetUser wrapped ErrNotFound with added context, the check still resolves correctly. Never compare errors with == unless you want pointer equality — errors.Is is always the right call.
When not to create sentinels: if no caller branches on a particular error, a well-wrapped descriptive error is enough. Sentinel proliferation is as bad as the absence of them.
Custom Error Types
When you need structured data in an error — field names, HTTP status codes, retry-after durations — a custom type is cleaner than encoding that data into a string.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}
func ValidateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "must be non-negative"}
}
if age > 150 {
return &ValidationError{Field: "age", Message: "outside realistic range"}
}
return nil
}
Callers extract the structured data with errors.As:
if err := ValidateAge(-5); err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("field=%s message=%s\n", valErr.Field, valErr.Message)
}
}
This pattern works well for API validation pipelines that return field-level errors to clients. It also ties into broader security posture: typed errors prevent accidentally leaking internal error strings to end users — something worth checking in your security hardening checklists.
If your custom type wraps another error, implement Unwrap() so the chain stays navigable:
type AppError struct {
Op string
Err error
}
func (e *AppError) Error() string { return fmt.Sprintf("%s: %v", e.Op, e.Err) }
func (e *AppError) Unwrap() error { return e.Err }
Without Unwrap(), errors.Is and errors.As stop at your type and cannot inspect what's underneath.
errors.Is vs errors.As: Choosing the Right Tool
Both functions walk the unwrapping chain recursively. The difference is what they look for:
-
errors.Is(err, target)— checks whethertargetappears anywhere in the chain. Use with sentinel values. -
errors.As(err, &target)— checks whether any error in the chain can be assigned to the type oftarget. Use with custom error types.
Using errors.Is with a custom type pointer will always return false — it compares values, not types. Using errors.As with a sentinel makes no sense either. Pick the function that matches what you're inspecting.
Anti-Patterns to Avoid
Silent discards: _ = someFunc() is almost never right. If an error genuinely doesn't matter, add a comment explaining why.
String matching: strings.Contains(err.Error(), "not found") is fragile. Error messages change; sentinel comparisons don't.
Panic for runtime failures: panic belongs in startup code for invariant violations, not in request handlers. A panicking HTTP server is not a well-behaved HTTP server.
Wrapping with %v instead of %w: fmt.Errorf("ctx: %v", err) bakes the error into a string and breaks the unwrapping chain. Use %w unless you specifically want to hide the underlying type from callers.
The Takeaway
The three patterns compose naturally. Wrap at every layer to preserve context. Define sentinels only for errors that callers need to branch on. Use custom types when you need structured data. Implement Unwrap() whenever you embed an error in a struct.
The discipline pays off at 2am when a production alert fires and the error log tells you exactly which operation failed, on which input, in which layer — without requiring you to read the code.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)