
There is enough that has been said and written about why handling errors in Go is important, so I am not going to go into detail on that here. However, just like Redowan, I also think that simply saying “wrap and return every error” is not helping much. So, in the spirit of the Go Proverbs, I thought I’d simply compile a set of 10 Commandments (the biblical reference here is entirely illustrative) extracted from his and my posts on the topic.
1. Thou shalt not ignore errors. #
Go makes failure explicit for a reason. If a call can fail, treat that branch as part of the program, not as ceremony to be skipped with.
2. Thou shalt wrap at package boundaries. #
When an error comes from another package, add the context that only your caller has: IDs, paths, operation names, or the dependency being called. fmt.Errorf("getting user %s: %w", id, err) is useful because it tells the next reader which call failed.
Simply returning the err is robbing you of the opportunity to present the whole story to the reader (more on that later). However, you may want to return the err when … 👇
3. Thou shalt return errors bare inside one package. #
If the caller lives in the same package, it usually already knows the local context. A plain return err keeps the error chain from turning into a transcript of every helper function.
4. Thou shalt make errors tell a story through actions. #
Add context that says what the code was trying to do when it failed: placing order, reserving stock, connection refused, etc. That way, when unwrapping the error up the call chain, it will read like prose: placing order [ORDER_ID]: reserving stock [STOCK_ID]: connection refused. Prefer short action phrases like loading config or fetching order status, and skip noisy prefixes like failed to, cannot, or error while.
5. Thou shalt not repeat what the inner error already says. #
If os.Open already includes the file path, wrapping with opening /path/to/file puts the same detail in the message twice. Add the higher-level intent instead: reading config: %w.
6. Thou shalt not build contracts on error strings. #
Error messages are for humans. Do not make callers, alerts, or dashboards depend on exact wrapped text; refactors and call paths will change it. If code needs to branch, expose a sentinel checked with errors.Is, or a typed error checked with errors.As.
7. Thou shalt remember that %w is an API promise. #
Wrapping with %w lets callers inspect the inner error with errors.Is and errors.As. That is powerful, but it also exposes implementation details. Once callers depend on sql.ErrNoRows or a driver-specific type, changing storage later can become a breaking change.
%v produces the same human-readable text as %w, but it severs the unwrap chain. It is the conservative choice when the underlying error belongs to a database driver, RPC client, third-party API, or any dependency you do not want callers to depend on.
8. Thou shalt translate foreign errors into your own error vocabulary. #
At system boundaries, map implementation errors into sentinels or custom error types that your package owns: ErrNotFound, ErrConflict, ErrUnauthorized. Let callers branch on your domain, not on the accident of which storage or HTTP client you use today.
9. Thou shalt not log and return an error #
Either log the error or return it - never both. When you log and return, every caller above you will be tempted to do the same. By the time it reaches the top, one failure has spawned five or ten log lines.
The single layer that decides the error if an error is terminal, is the only layer that should log it.
10. Thou shalt not let errors escape goroutines unheard #
A goroutine cannot return an error to its spawner. If you launch one without a way to collect its result, any failure it encounters dies silently - no log, no crash, nothing.
Treat each goroutine as a separate service boundary. Any error that occurs within a goroutine should either be logged, escalated to a panic (extremely rarely), or signalled back to the goroutine’s creator. Use errgroup for concurrent work that can fail. Alternatively, a buffered channel is your best friend.
// The buffered channel (`cap 1`) is important!
// if the caller stops listening, the goroutine can still send
// and exit cleanly instead of leaking.
errc := make(chan error, 1)
go func() {
errc <- doWork()
}()
if err := <-errc; err != nil {
return fmt.Errorf("doing work: %w", err)
}
Bonus: context.Canceled is not your error #
When a client disconnects or a deadline fires, context.Canceled or context.DeadlineExceeded will flow up through every layer of your call stack like any other error. If you treat it as one, you get false-positive ERROR logs and 500 responses sent to a client that is already gone.
At your outermost handler, check for it before doing anything else:
if err != nil {
if errors.Is(err, context.Canceled) {
return statusClientClosedRequest
}
log.Error("request failed", "err", err)
return statusInternalServerError
}
References #

Go's Error Handling Is a Form of Storytelling
Good error messages add up and tell a story

Go errors: to wrap or not to wrap?
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.

Error Flows in Go
Change the narrative
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Introducing gomjml: MJML for Go Developers
gomjml is a native Go implementation of the MJML email framework, making responsive email design faster and easier for Go developers.
Why I Made Peace With Go’s Date Formatting
If we’re all going to google it anyway, we might as well google something that makes sense.
Preslav Rachev
