{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/to-wrap-or-not-to-wrap/",
"description": "Exploring the tradeoffs between wrapping errors at every return site versus wrapping only at boundaries, with no definitive answer - just honest tradeoffs for the kind of software I write.",
"path": "/go/to-wrap-or-not-to-wrap/",
"publishedAt": "2026-03-07T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Error Handling"
],
"textContent": "A lot of the time, the software I write boils down to three phases: parse some input, run it\nthrough a state machine, and persist the result. In this kind of code, you spend a lot of\ntime knitting your error path, hoping that it'd be easier to find the root cause during an\nincident. This raises the following questions:\n\n- When to fmt.Errorf(\"doing X: %w\", err)\n- When to use %v instead of %w\n- When to just return err\n\nThere's no consensus, and the answer changes depending on the kind of application you're\nwriting. The [Go 1.13 blog] already covers the mechanics and offers some guidance, but I\nwanted to collect more evidence of what people are actually doing in the open and share\nwhat's worked for me.\n\nThe problem with bare errors\n\nHere's a function that places an order by calling into a few different packages:\n\nAll four calls can fail with connection refused. When one of them does, your log says:\n\nWhich call? No idea. You grep the codebase, add temporary logging, narrow it down. In a\nservice with dozens of dependencies, debugging this trail of errors can turn into a huge\ntime sink.\n\nOne obvious fix is to wrap the error at every return site:\n\nNow the log says:\n\nThat tells you exactly which call failed and which item it was for.\n\nThe case for wrapping at every return site\n\nDave Cheney advocated for this in his 2016 talk [Don't just check errors]. His pkg/errors\nlibrary introduced errors.Wrap, which adds a message and a stack trace at the point where\nthe error occurs. The idea is that each function knows what operation it was attempting, and\nthat context is lost if you don't capture it immediately.\n\nCockroachDB takes this further. They use [cockroachdb/errors], a drop-in replacement for the\nstdlib errors package that captures a stack trace at every wrap site:\n\nThe Terraform AWS provider does the same thing with fmt.Errorf(\"...: %w\", err) at every\nlayer. Their [contributor guidelines] mandate a consistent format for all resource\noperations:\n\nThe [wrapcheck] linter codifies this as a rule. It doesn't flag every bare return err,\nonly errors that originated from a different package:\n\nThe reasoning is that when an error crosses a package boundary, the receiving code is the\nlast place that knows what it was trying to do. Within a package, the caller already has\nthat context.\n\nFor many cases, wrapping everything is the right default:\n\n> The risk of overwrapping, especially in my private code, is much lower than the risk of\n> underwrapping when the service crashes and you get io.EOF.\n>\n> -- [Peter Bourgon on Go Time #91]\n\nBut wrapping has costs that only show up as the codebase grows.\n\nThe cost of overwrapping\n\nMessages pile up\n\nWhen every layer wraps, your error messages become nested chains:\n\nFour layers of context for one connection refused. The middle layers (checking warehouse\nand querying database) don't add a warehouse ID or a query. They just restate the call\nchain.\n\nIt also makes the error string fragile. It changes whenever someone renames an intermediate\nfunction or refactors the call chain. If you had an alert matching on\nchecking warehouse: querying database: connection refused, it breaks the moment someone\nrenames checkWarehouse to checkStock. The same root cause (connection refused) wrapped\nthrough different code paths produces different error strings, making it hard to aggregate\nthem in your logging dashboard.\n\n[Jay Conrod]'s error handling guidelines address this:\n\n> Each function is responsible for including its own values in the error message, except for\n> arguments passed to the function that returned the wrapped error.\n\nIn other words, if os.Open already puts the file path in its error, your wrapper shouldn't\nadd the path again:\n\nThe [Google Go Style Guide] says the same:\n\n> When adding information to errors, avoid redundant information that the underlying error\n> already provides.\n\nYou should still wrap, but only when you're adding information - a user ID, an item ID, the\nname of the external service you were calling.\n\n> [!IMPORTANT]\n>\n> If a function is just passing through a call to another function within the same package,\n> the wrapper is noise.\n\n%w creates contracts you didn't mean to\n\n%w in fmt.Errorf creates an error chain that callers can traverse with errors.Is and\nerrors.As. That means the wrapped error becomes part of your function's API surface.\n\nThe [Go 1.13 blog] uses sql.ErrNoRows to illustrate this. Say your LookupUser function\ncalls database/sql internally:\n\nBecause of %w, callers can now do errors.Is(err, sql.ErrNoRows) to check whether the\nuser wasn't found. That works until you switch from database/sql to an ORM, or put a cache\nin front of the query. The callers matching on sql.ErrNoRows silently break.\n\nThe [Go 1.13 blog] is explicit about this:\n\n> Wrapping an error makes that error part of your API. If you don't want to commit to\n> supporting that error as part of your API in the future, you shouldn't wrap the error.\n\nThe [Error Values FAQ] makes the same point:\n\n> Callers can depend on the type and value of the error you're wrapping, so changing that\n> error can now break them. [...] At that point, you must always return sql.ErrTxDone if\n> you don't want to break your clients, even if you switch to a different database package.\n\nSame thing with typed errors. If your repository wraps a pgconn.PgError with %w, callers\ncan unwrap through to the Postgres error code:\n\nWhen you migrate to MySQL or put a cache in front of the database, those callers silently\nbreak.\n\nThe [Google Go Style Guide] notes that %w is appropriate when your package's API\nguarantees that certain underlying errors can be unwrapped and checked by callers. If you\ndon't want to make that guarantee, use %v.\n\n> [!IMPORTANT]\n>\n> %w makes the wrapped error part of your function's API. Callers can errors.Is and\n> errors.As through it, which means they can start depending on the inner error type. If\n> you later change that inner error (swap databases, add a cache layer), those callers\n> break. Use %w only when you intend to expose the inner error.\n\n%v as the conservative default\n\n%v adds the same context text (the human reading the log sees the identical message) but\nsevers the error chain. No caller can errors.Is or errors.As through it:\n\nBoth produce the same log output. But with %v, you're free to swap the database later\nwithout breaking callers who were depending on the inner error type.\n\nAt system boundaries, the [Google Go Style Guide] recommends translating rather than\nwrapping:\n\n> At points where your system interacts with external systems like RPC, IPC, or storage,\n> it's often better to translate domain-specific errors into a standardized error space\n> (e.g., gRPC status codes) rather than simply wrapping the raw underlying error with %w.\n\nSay your repository layer talks to Postgres via pgx. Wrapping with %w exposes pgx\nerrors to callers:\n\nNow any caller can errors.Is(err, pgx.ErrNoRows), tying them to your database driver.\nTranslating means mapping the storage error into your own domain before it crosses the\nboundary:\n\nCallers check errors.Is(err, ErrNotFound) - which is yours - instead of\nerrors.Is(err, pgx.ErrNoRows). When you swap from Postgres to MySQL, callers don't break.\nAnd at system boundaries, consider translating entirely instead of wrapping.\n\nHow the stdlib handles errors\n\nThe standard library also uses [sentinel errors] and [custom error types] alongside %w and\n%v.\n\nPackages like io define sentinel errors - package-level variables that callers check with\nerrors.Is. The io package defines EOF and returns it from Read when there's no more\ndata:\n\nA caller uses the sentinel to distinguish \"end of input\" from a real failure:\n\nSentinels work when the caller only needs to know _which_ failure occurred. When callers\nneed structured metadata - not just identity - the stdlib uses custom error types. os.Open\ndefines a fs.PathError struct and returns it with the operation name, file path, and\nunderlying syscall error as struct fields:\n\nBecause PathError implements Unwrap(), errors.Is(err, fs.ErrNotExist) works through\nthe chain. But unlike fmt.Errorf wrapping, the context is in typed struct fields. A caller\ncan extract those fields to decide what to do:\n\nnet.OpError follows the same pattern with Op, Net, Source, Addr, and Err fields. The\npackage controls exactly what's exposed via Unwrap(), and callers get structured metadata\nthey can act on programmatically.\n\nThe stdlib also uses fmt.Errorf with both %w and %v, and the database/sql package\nshows why the choice matters. Rows.Scan wraps scanner errors with %w:\n\nBefore Go 1.16, Rows.Scan used %v here, which severed the chain. Custom Scanner\nimplementations returning sentinel errors couldn't be inspected with errors.Is by callers.\n[Issue #38099] fixed this by switching to %w. But in the same package, internal type\nconversion errors use %v because the underlying strconv parse error is an implementation\ndetail callers don't need to inspect:\n\nThe database/sql migration from %v to %w was safe because it only exposed more to\ncallers. Going the other direction would break callers who started depending on errors.Is.\n\n> [!IMPORTANT]\n>\n> Going from %v to %w is a backwards-compatible change (it exposes more to callers).\n> Going from %w to %v is a breaking change (callers who relied on errors.Is or\n> errors.As through the chain will stop working). When in doubt, start with %v.\n\nKubernetes went through a similar migration. They historically used %v for most wrapping,\nwhich meant errors.As couldn't traverse the chain. [Issue #123234] tracked the codebase-\nwide migration from %v to %w, acknowledging that %v may still be preferred in some\nplaces \"to abstract the implementation details\" but that such cases should be rare.\n\nFor most application code, fmt.Errorf with %w or %v is enough. Custom error types like\nPathError make more sense in libraries and shared packages where callers need structured\nmetadata. But wrapping isn't the only way to attach context to an error.\n\nStructured logging as an alternative to wrapping\n\nDave Cheney is the person who created pkg/errors and popularized error wrapping in Go. He\neventually walked away from his own advice. In 2021, when looking for new maintainers for\npkg/errors, he wrote:\n\n> I no longer use this package, in fact I no longer wrap errors.\n>\n> -- [Dave Cheney on pkg/errors #245]\n\nHis reasoning was that structured logging can carry the debugging context that wrapping was\nmeant to provide. Compare the two approaches. With wrapping, you bake the context into the\nerror string:\n\nThe log line looks like:\n\nWith structured logging, you keep the error value clean and attach the context as separate\nkey-value fields:\n\nThe log line looks like:\n\nThe same information is there, but in structured fields that your logging dashboard can\nindex, filter, and aggregate on. The error value itself stays as connection refused\nwithout a chain of prefixes.\n\nThe tradeoff is that structured logging requires a logging pipeline that can query on\nfields. If all you have is grep on a log file, the wrapping version is easier to work\nwith.\n\n> [!NOTE]\n>\n> Structured logging and wrapping aren't mutually exclusive. You can wrap at package\n> boundaries for the error string and log with slog at the handler for request-scoped\n> context (user IDs, request IDs, trace IDs). The handler example in the Services section\n> below does both.\n\nHow wrapping changes by application type\n\nSo how do you actually decide? It depends on what you're building. Marcel van Lohuizen from\nthe Go team described his own approach:\n\n> I do and don't... If I wanna have context, I wrap it. If I create a new error, I wrap it.\n> But sometimes you're not really adding too much information, and then I don't. So it\n> depends on the situation.\n>\n> -- [Marcel van Lohuizen on Go Time #91]\n\nLibraries\n\nBe conservative. The Google style guide applies most directly here because you're shipping\nan API contract. Use %v by default so you don't accidentally expose implementation\ndetails. Use %w only when you intentionally want callers to inspect the inner error, and\ndocument that you're doing so.\n\nA library that wraps with %w ties its callers to its dependencies. If v2 switches from\npgx to database/sql, every caller doing errors.Is(err, pgconn.something) breaks. Use\n%v by default, and define your own sentinels when callers need to branch on the error:\n\nCallers check errors.Is(err, ErrNotFound) - which is yours - without being coupled to your\nHTTP client. Same pattern as the UserRepo translation example earlier.\n\nCLI tools\n\nWrap freely with %w. The call stack is shallow, the error message is the user-facing\noutput, and nobody is calling errors.Is on your CLI's errors. Maximum context helps the\nhuman reading the terminal:\n\nThe user sees:\n\nServices\n\nIn my experience, services are where it's the hardest to give a formulaic answer to this.\nYou have structured logging and distributed tracing, but you also have deep call stacks and\nmany dependencies.\n\nThe approach I've landed on: wrap at package boundaries with context about what you were\ntrying to do. Use %w within your own codebase where callers should be able to inspect the\ninner error. Use %v when the error crosses a system boundary (RPCs, database calls,\nthird-party APIs). Skip wrapping for same-package calls.\n\nHere's the placeOrder function from the beginning, rewritten:\n\n- (1) users.Get is in another package - wrap with the user ID\n- (2) inventory.Reserve is in another package - wrap with the item ID\n- (3) payments.Charge is in another package - wrap with the operation name\n- (4) internal helper in the same package - bare return is enough\n\nAt the handler, use %v to translate into the external domain without exposing internals:\n\nThe handler logs the full error with request context for debugging, then returns a gRPC\nstatus with %v so the caller gets a useful message without being able to errors.Is\nthrough to your database driver.\n\nWhere I've landed\n\nThere's no consensus on how much to wrap, and I don't think there needs to be. Here's what I\ndo:\n\n- Within a package, bare return err. The caller already has context.\n- At package boundaries, fmt.Errorf(\"doing X: %w\", err) with identifying info (user IDs,\n item IDs, file paths). The [wrapcheck] linter can enforce this automatically. Only wrap\n when you're adding information the inner error doesn't already carry.\n- At system boundaries (RPCs, database calls, third-party APIs), translate rather than wrap.\n Map implementation errors into your own [sentinel errors] or [custom error types] so\n callers depend on your package, not your dependencies. Use %v for the fallback path.\n- In libraries, %v by default. Own sentinels (ErrNotFound, ErrConflict) for cases\n callers need to inspect. %w only when you intentionally want callers to unwrap, and\n document that you're doing so.\n- In CLIs, %w everywhere. The error message is the user-facing output.\n- In services, all of the above plus slog at the handler level for request-scoped context,\n so the error value doesn't need to carry all of that.\n\n\n\n\n[Go 1.13 blog]:\n https://go.dev/blog/go1.13-errors#whether-to-wrap\n\n[Google Go Style Guide]:\n https://google.github.io/styleguide/go/best-practices.html#error-extra-info\n\n[Don't just check errors]:\n https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully#annotating-errors\n\n[sentinel errors]:\n https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully#::text=Sentinel%20errors\n\n[custom error types]:\n https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully#::text=Error%20types\n\n[Peter Bourgon on Go Time #91]:\n https://changelog.com/gotime/91#t=16:22\n\n[Jay Conrod]:\n https://jayconrod.com/posts/116/error-handling-guidelines-for-go#context-and-wrapping\n\n[cockroachdb/errors]:\n https://github.com/cockroachdb/errors\n\n[issue #123234]:\n https://github.com/kubernetes/kubernetes/issues/123234\n\n[wrapcheck]:\n https://github.com/tomarrell/wrapcheck\n\n[Dave Cheney on pkg/errors #245]:\n https://github.com/pkg/errors/issues/245#issue-988166855\n\n[Marcel van Lohuizen on Go Time #91]:\n https://changelog.com/gotime/91#t=12:03\n\n[Error Values FAQ]:\n https://go.dev/wiki/ErrorValueFAQ#i-am-already-using-fmterrorf-with-v-or-s-to-provide-context-for-an-error-when-should-i-switch-to-w\n\n[issue #38099]:\n https://github.com/golang/go/issues/38099\n\n[contributor guidelines]:\n https://hashicorp.github.io/terraform-provider-aws/error-handling/#wrap-errors",
"title": "Go errors: to wrap or not to wrap?"
}