Confession: for about a year, every time I opened a Go file at work I’d silently grumble about if err != nil. I came from PHP and TypeScript where exceptions felt fine, and Go’s explicit error returns looked like noise I had to step over to read the actual logic.
I was wrong. Not about the noise (there is some), but about it being pointless. The reason I hated it was that I was treating errors as boilerplate to copy-paste. Once I picked up a handful of patterns from the errors package, fmt.Errorf with %w, and errgroup, the noise turned into something useful: a clear trail of what went wrong and where, without needing to grep stack traces in a JSON blob at 2am.
This post is what I actually write at the keyboard in 2026, after a few years of shipping Go in production. Nothing fancy. The patterns are well known. The point is which ones I reach for first, which I dropped, and why.
The old way I was writing Go errors (and why it fell apart)
Early on, all my error handling looked like this:
func loadUser(id string) (*User, error) {
row, err := db.Query("SELECT ... WHERE id = ?", id)
if err != nil {
return nil, errors.New("could not load user")
}
// ...
}
That’s the trap. errors.New("could not load user") discards the original err. By the time the message bubbles up to my logger I’ve lost the actual cause. Was it a network blip? A bad SQL query? A typo in a column name? I’d reproduce the bug locally, see the real database error, and curse past me.
The fix is fmt.Errorf with the %w verb, which has been in the standard library since Go 1.13. It wraps the original error so I can still unwrap it later:
func loadUser(id string) (*User, error) {
row, err := db.Query("SELECT ... WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("loadUser %s: query: %w", id, err)
}
// ...
}
Two things changed. I keep the original error attached via %w. And I include the input (id) and the operation (query) in the message, so when I see loadUser 9f3a: query: connection refused in logs, I know exactly where to look. The Go team’s post on Go 1.13 errors walks through the design, and it’s worth reading once even if you’ve been writing Go for years.
Stop reaching for errors.New in chained code paths
Related habit I had to break: using errors.New everywhere. errors.New is fine for sentinel values (more on those in a second), but if you’re wrapping a caller’s mistake you want fmt.Errorf("...: %w", err) every time.
The mental model that finally clicked: errors.New is for inventing a brand new error. fmt.Errorf with %w is for handing off an existing error with more context bolted on. If I’m somewhere deep in a call stack and a lower function already failed, I never invent. I always wrap.
The bonus is that wrapped errors compose. By the time the error reaches my HTTP handler it reads like a small breadcrumb trail:
loadUser 9f3a: query: dial tcp 10.0.0.5:5432: i/o timeout
That single line tells me which function, which input, which step inside that function, and what the OS actually returned. No stack trace required.
Sentinel errors versus typed errors
This is the part where I confused myself for months. Go gives you two ways to express “this is a known kind of failure”.
Sentinel errors are package-level variables:
var ErrUserNotFound = errors.New("user not found")
func loadUser(id string) (*User, error) {
row, err := db.Query(...)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("loadUser %s: %w", id, ErrUserNotFound)
}
return nil, fmt.Errorf("loadUser %s: query: %w", id, err)
}
// ...
}
Typed errors are structs with extra fields:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
I default to sentinels when the caller only needs to know “yes or no, did this thing happen”. ErrUserNotFound is a perfect fit because the HTTP handler doesn’t care why the user wasn’t found, just that they weren’t. I reach for typed errors when the caller needs structured data, like a field name from a validation failure or a retryable flag from a transient backend.
One rule I stick to: if I add a new sentinel, it lives next to the function that returns it, not in a shared errors.go file at the root of the module. That convention keeps the surface area small.
errors.Is versus errors.As: the thing I got wrong for months
I confused these two for too long, so writing it down for past me.
Use errors.Is(err, target) when target is a sentinel value:
if errors.Is(err, ErrUserNotFound) {
return http.StatusNotFound
}
Use errors.As(err, &target) when target is a typed error and you need to read its fields:
var verr *ValidationError
if errors.As(err, &verr) {
return verr.Field, verr.Message
}
The trick that finally made it stick: Is answers a yes/no question. As extracts data. If I’m asking “is this the kind of error where I should retry?”, that’s Is. If I’m asking “what field failed validation?”, that’s As. The official errors package docs lay it out, but seeing it twice in the codebase you wrote yourself is what cements it.
errgroup for parallel work that can fail
sync.WaitGroup works fine for fire-and-forget goroutines, but the moment any of those goroutines can return an error you want to surface, you should be reaching for golang.org/x/sync/errgroup instead. I’ve watched too many teams write a custom errors []error slice with a mutex when this exists.
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, ids []string) ([]*User, error) {
g, ctx := errgroup.WithContext(ctx)
users := make([]*User, len(ids))
for i, id := range ids {
i, id := i, id // capture (pre-1.22 habit, still doesn't hurt)
g.Go(func() error {
u, err := loadUser(ctx, id)
if err != nil {
return fmt.Errorf("fetch %s: %w", id, err)
}
users[i] = u
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return users, nil
}
What I love: as soon as one goroutine returns an error, the context cancels, so all the other in-flight calls bail out instead of pointlessly finishing. That alone is worth the import. The errgroup package docs cover the rest, including SetLimit for bounding concurrency. If you want the wider concurrency picture, I went deeper in my piece on Go concurrency patterns I actually ship, which pairs nicely with this one.
Errors aren’t just for humans: hook them into your logs
Last habit that paid off. When my error messages started including the input and the operation (loadUser 9f3a: query: ...), my logs got easier to grep. But the real step up was teaching my logger to extract structured fields from those errors.
If you’re using log/slog (and you should be, it’s in the standard library now), you can pass the error directly and the logger handles the rest:
logger.Error("load failed",
slog.String("user_id", id),
slog.Any("error", err))
For typed errors, I write a LogValue() method so slog pulls out field, message, retryable automatically as JSON keys. The result is logs I can query by error type in Loki or Datadog without parsing strings, which is the kind of thing past me would have called overkill and present me considers obvious.
I cover the broader habit (treat errors as data the rest of your stack can use, not just strings to print) across other languages in my backend work.
The one-week action
If you’re rewriting Go errors this week, the change that pays back fastest: open the file you most recently touched, search for errors.New(, and check each one. If you’re inside a function that already received an err from somewhere else, change it to fmt.Errorf("context: %w", err). That’s it. One commit. You’ll get the next bug report and the message will be readable, and you’ll wonder why you didn’t do it sooner.
That’s roughly where I was a couple of years ago, grumbling at if err != nil and copy-pasting the same useless wrapping. The patterns above aren’t fancy. They’re just the small set I keep reaching for, and they make Go errors feel like a feature instead of a tax.