Confession: for about a year, every wrapped error in my Go services used %v instead of %w, and I found out when an errors.Is check for sql.ErrNoRows quietly returned false in production. The handler sent a 500 where it should have sent a 404. For a year. Nobody caught it because the endpoint was internal, which is somehow worse than someone catching it.
So this is the golang error handling post I needed before I shipped that bug. Not the polished tour version. The version with the mistakes left in. Short version for the impatient: use %w when callers might inspect the error, errors.Is for sentinel values, errors.As for types, errors.Join for cleanup paths, and errgroup for goroutines. If you want to know why each of those rules cost me something, read on.
%v and %w are one character apart and nothing alike
Here’s the exact pattern I was writing, copied from muscle memory that predated Go 1.13:
// Before: what I did for a year
func getUser(id int) (*User, error) {
u, err := db.FetchUser(id)
if err != nil {
return nil, fmt.Errorf("fetching user %d: %v", id, err)
}
return u, nil
}
That %v formats the error into a plain string. The message looks identical in your logs, which is exactly why the bug survives review. But the original error value is gone. When a caller upstream does this:
if errors.Is(err, sql.ErrNoRows) {
return http.StatusNotFound
}
it never matches, because the chain was flattened into text three layers down. The fix is one character:
// After: the chain survives
if err != nil {
return nil, fmt.Errorf("fetching user %d: %w", id, err)
}
%w wraps the error instead of printing it, so errors.Is and errors.As can walk the chain. The Go team explained the design when it landed in the Go 1.13 errors post, and I’d apparently skimmed it without absorbing anything. My rule now: %w by default, and %v only when I deliberately want to hide an implementation detail from callers. That case is rarer than I assumed.
errors.Is and errors.As do different jobs
I used to treat these as synonyms with different spellings. They’re not. errors.Is compares against a sentinel, a specific exported value like sql.ErrNoRows or io.EOF:
var ErrQuotaExceeded = errors.New("quota exceeded")
if errors.Is(err, ErrQuotaExceeded) {
// caller can react to this exact condition
}
errors.As extracts a concrete type from the chain so you can read fields off it:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Printf("op=%s path=%s", pathErr.Op, pathErr.Path)
}
The decision between them is really a decision about your API. A sentinel says “this condition happened.” A custom error type says “this condition happened and here’s structured data about it.” I default to sentinels because they’re smaller commitments. Once you export an error type, callers depend on its fields and you’re stuck maintaining them. I’ve regretted exporting types. I’ve never regretted exporting a sentinel.
The full mechanics are in the errors package docs, which are shorter than this post and worth ten minutes.
The wording conventions that make wrapped errors readable
One thing nobody warned me about: wrapped errors concatenate, so your message style compounds. I once traced a log line that read “Failed to sync account: Failed to fetch profile: Failed to query users: connection refused”. Three layers, each one starting with “Failed to”, each one capitalized like a sentence. The Go convention exists precisely because of this. Error strings should be lowercase, carry no trailing punctuation, and describe the operation, not repeat the word “error” or “failed”.
// Before: reads terribly once wrapped
return fmt.Errorf("Failed to fetch profile: %w", err)
// After: composes into one readable line
return fmt.Errorf("fetch profile for account %s: %w", accountID, err)
The second version chains into “sync account: fetch profile for account ab12: query users: connection refused”. Same information, half the length, and the IDs are in there. My other habit: include the identifier of the thing you were operating on at the layer that knows it. The database layer knows the query, the service layer knows the account, the handler knows the request. Each wrap adds the one fact only it can add. When I review PRs now, a wrap that adds no new information is the first thing I flag.
errors.Join fixed my defer cleanup
Before Go 1.20 I had a bad habit in cleanup paths. See if this looks familiar:
// Before: the Close error vanishes
func writeReport(path string, data []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close() // error silently dropped
_, err = f.Write(data)
return err
}
That deferred Close can fail, and on some filesystems the write error only surfaces at close time. Mine was NFS in a client’s staging environment, and the report file was empty while my function returned nil. Since Go 1.20, errors.Join gives you a clean way to keep both errors:
// After: both errors survive
func writeReport(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, f.Close())
}()
_, err = f.Write(data)
return err
}
errors.Join discards nils, so the happy path still returns nil. And a joined error still works with errors.Is, which checks every branch. The named return value trick reads a little odd the first time, but it’s the standard pattern now and I’ve stopped apologizing for it in code review.
Goroutines swallow errors unless you plumb them out
An error returned inside a goroutine goes nowhere. There’s no caller to receive it. I learned this properly while debugging the leak I wrote about in my goroutine leak post, and the error-handling half of that lesson belongs here.
The hand-rolled version uses channels and gets verbose fast. The version I actually write uses errgroup:
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
// first non-nil error from any goroutine
return fmt.Errorf("fetching feeds: %w", err)
}
Two things I got wrong at first. One: g.Wait returns only the first error, so if you need all of them, collect into a slice under a mutex and errors.Join at the end. Two: the derived context cancels when any goroutine fails, and your workers have to actually check it or the cancellation does nothing. That second one is a whole story on its own.
The syntax debate is over, and I’m relieved
For years, every Go survey listed error handling verbosity as the top complaint, and the proposals kept coming: try, check/handle, the ? operator. The try proposal collected thousands of reactions before being declined. Then in 2025 the Go team published their decision to stop pursuing syntax changes entirely.
Unpopular opinion, maybe: they were right. I’ve worked in codebases where errors travel invisibly and in Go where they’re loud, and I’ll take loud. The if err != nil block is where wrapping happens, where context gets added, where you decide whether this error is worth structured fields. Compress the syntax and most people would compress the thinking too. The verbosity was never really the problem. Bad wrapping was, at least in my code.
The rules I keep now
After all the self-inflicted bugs above, my working rules fit on an index card. Wrap with %w and add context that says what you were doing, not what failed two layers down. Handle an error once, meaning log it or return it, never both, because double-logging turned my incident timelines into noise. Prefer sentinels over exported error types until callers demonstrably need fields. Use errors.Join in cleanup paths and anywhere you’d otherwise drop a second error. And in goroutines, use errgroup so errors have a way home. This is the boring backbone of the backend work I do for client projects, and boring is the point.
Here’s the thing you can do this week: grep your codebase for : %v", err and look at each hit. Ask whether any caller might ever want errors.Is or errors.As to work through that boundary. Mine had 41 hits and 38 of them should have been %w. It took an afternoon, and one of the three I left alone was hiding a bug I’d been blaming on a vendor SDK. Yours will probably have one too.