Confession: I spent two hours last Tuesday tracking down a bug that turned out to be a nil pointer three call sites down from where the error surfaced. My stack trace was fine. My error wrapping was garbage. The message printed sql: no rows in result set at the top of a handler and I couldn’t tell which of four queries fired it.
I’ve been shipping Go for years and my error handling still leaks. So over the past few months I’ve been tightening it. Cutting the if err != nil ceremony where it doesn’t earn its keep, wrapping consistently, and finally getting errors.Is and errors.As right in my head. This post is what I actually run in production in 2026, warts included.
Short version for the impatient: wrap every error at every boundary with %w, use typed errors for anything a caller might branch on, and stop logging errors before you return them. If you want the code, keep reading.
The if err != nil ceremony I stopped writing everywhere
The classic Go complaint is that half your code is if err != nil { return err }. I used to defend that. I don’t anymore, and it’s not because the pattern is wrong. It’s because a lot of those returns weren’t doing anything useful.
Here’s what most of my HTTP handlers looked like a year ago:
func getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
user, err := db.GetUser(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Println(err)
}
}
Every error handled the same generic way. No wrapping. No context. Just a raw message to the client and a naked log.Println if the encode fails.
Now I lean on a tiny helper per package that centralizes the pattern:
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
http.Error(w, httpErr.Message, httpErr.Status)
return
}
slog.ErrorContext(r.Context(), "handler error", "err", err)
http.Error(w, "internal error", 500)
}
The handlers get thinner and the error path always has structure. That’s what I care about. Not deleting if err != nil for the sake of it.
Wrapping with %w: what actually changed my debugging
fmt.Errorf("...: %w", err) sounds boring. It’s the single change that dropped my average time-to-root-cause the most in the last year.
The rule I follow: every function that returns an error from another function wraps it with %w and adds one sentence of context. Not two. Not zero. One.
func loadInvoice(ctx context.Context, id int64) (*Invoice, error) {
inv, err := db.getInvoice(ctx, id)
if err != nil {
return nil, fmt.Errorf("load invoice %d: %w", id, err)
}
items, err := db.getItems(ctx, id)
if err != nil {
return nil, fmt.Errorf("load items for invoice %d: %w", id, err)
}
inv.Items = items
return inv, nil
}
When something fails four layers deep, I now get a message like handle checkout POST: load invoice 4218: get items for invoice 4218: sql: no rows in result set. The chain reads top-down like a Russian doll and I know exactly which query blew up.
The Go blog’s working with errors in Go 1.13 post is still the canonical reference. If you’re reading it for the first time, read it twice. The %w verb changed the language more than most people realize.
errors.Is vs errors.As: the split I got wrong for a year
I mixed these up embarrassingly often. Here’s the version that finally stuck in my head:
errors.Is(err, target): is this error equal to (or wrapping) a specific sentinel value? Use it forsql.ErrNoRows,context.Canceled,io.EOF. It’s a value comparison.errors.As(err, &target): can this error be unwrapped into a specific type? Use it for custom struct errors where you need fields off the target. It’s a type assertion.
inv, err := loadInvoice(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return c.JSON(404, "not found")
}
var vErr *ValidationError
if errors.As(err, &vErr) {
return c.JSON(400, map[string]any{
"field": vErr.Field,
"reason": vErr.Reason,
})
}
return c.JSON(500, "internal error")
}
The mental model: Is for equality, As for extraction. If you find yourself doing err.Error() == "some string" anywhere, that’s the signal you needed one of these two. The errors package docs spell it out but the naming still trips me up occasionally.
Sentinel errors vs typed errors: when each earns its keep
Sentinel errors are package-level var values like io.EOF or sql.ErrNoRows. Typed errors are structs that implement error. Both work. They’re for different things.
Reach for a sentinel when there’s exactly one thing that can go wrong and no extra data to carry. ErrNotFound, ErrRateLimited, ErrShuttingDown. Callers check with errors.Is.
Reach for a typed error when there’s structured data the caller might want. A validation error that carries a field name. An HTTP error that carries a status code. A retryable error that carries a next-attempt delay.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Reason)
}
type RetryableError struct {
Err error
After time.Duration
}
func (e *RetryableError) Error() string { return e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err }
The Unwrap method is what makes errors.Is and errors.As walk through your custom types to the underlying cause. If you forget it, chains break silently and you’ll spend an afternoon wondering why your sentinel checks stopped matching.
One trap I fell into: don’t export sentinel errors from packages where callers won’t need to branch on them. Once they’re exported, they’re API forever. If nobody’s calling errors.Is on your ErrConnectionRefused, keep it internal and let it wrap up as a generic 500.
Structured errors + slog: the pattern I ship now
This one plugs into what I wrote about in my structured logging with slog post. The two patterns lock together and I’d struggle to give up either.
The setup: when a handler catches an error, log it once at the top with the whole chain and a request-scoped attributes bag. Everything below just returns and wraps. No log.Println sprinkled through business code.
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
logger := slog.With(
"method", r.Method,
"path", r.URL.Path,
"request_id", middleware.RequestID(r.Context()),
)
var httpErr *HTTPError
if errors.As(err, &httpErr) {
logger.WarnContext(r.Context(), "client error",
"status", httpErr.Status, "err", err)
http.Error(w, httpErr.Message, httpErr.Status)
return
}
logger.ErrorContext(r.Context(), "server error", "err", err)
http.Error(w, "internal error", 500)
}
When slog’s JSON handler flattens the wrapped err, the whole chain shows up in one field, filterable in Grafana or Datadog with no regex. That’s the win. One log line per failed request, with the full trail. My previous setup had errors scattered across five different log calls and I’d have to correlate them manually.
If you’re pairing this with concurrent code, I cover the errgroup side of the story in my Go concurrency patterns post. The same wrapping rules apply, just with an extra g.Wait() at the end.
The one anti-pattern I still see everywhere
Log-and-return. It looks harmless:
if err != nil {
log.Println("failed to load user:", err)
return err
}
Now every layer that touches this error logs it. Your logs fill with the same error six times, at six different stack frames, and the actual root cause is buried in duplicates. Debugging a production incident becomes a puzzle of deduping timestamps.
Pick one: log it or return it. Never both. My rule is that only the outermost layer that decides how to respond to the user (usually the HTTP handler or the CLI command) logs. Everything below wraps and returns silently. If you need extra context in the log, add it to the wrapped error, not to a parallel log line.
The same rule applies to fmt.Println for debugging that you swore you’d delete before shipping. You know the one. Delete it. Add a real slog line with structured attributes if you actually need the visibility.
What to do this week
Grep your repo for log.Println(err) and log.Printf(...err...). For each one, decide: is this the outermost layer? If yes, upgrade it to slog.ErrorContext with the request attributes. If no, delete the log and make sure the error is wrapped with %w and one sentence of context. That single pass usually catches 80% of the debugging pain in a mid-sized Go codebase.
If you want to see how I think about the broader engineering practice (architecture, observability, the boring bits that make services survive production), I keep a running set of case studies on my portfolio work page. Not a sales pitch, just what I’ve been shipping.