Add Context to Errors in Go Without Stack Trace Noise
Add useful context to Go errors: what to say at each layer, what the standard library already says, how to avoid stutter, and when to use fields.

A bare error says what went wrong in the one place it happened. It does not say what the program was trying to achieve, for whom or with which input. connection refused could be the database, the cache or a webhook, and a log line that says only that sends the reader on a search through the code. Adding context solves this, but the cheap way to add it, wrapping at every level with a long message, creates its own problem: logs full of repeated words that bury the one fact that matters.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article is about what to put in the wrap and what to leave out. It follows wrapping with %w, which covers the mechanics, and is part of the error handling guide. Everything ran on Go 1.25.6.
What context is worth adding
Ask what the reader of the final message needs and cannot get from the error itself. Usually that is two things.
The operation. A short phrase naming what this layer was doing: load config, charge card, render invoice. It tells the reader which of the hundred things the program does failed.
The identity of this call. The identifier that distinguishes it from all the other calls: the user ID, the order number, the table name. Without it you know that some order failed to save. With it you know which one.
Everything else is optional. Compare two messages for the same failure:
package main
import (
"errors"
"fmt"
"os"
)
func readSettings(path string) error {
_, err := os.ReadFile(path)
return err
}
func noisy(path string) error {
if err := readSettings(path); err != nil {
return fmt.Errorf("failed to read settings file %s: error: %w", path, err)
}
return nil
}
func clean(path string, userID int) error {
if err := readSettings(path); err != nil {
return fmt.Errorf("load settings for user %d: %w", userID, err)
}
return nil
}
func main() {
p := "/srv/app/settings.json"
fmt.Println(noisy(p))
fmt.Println(clean(p, 42))
fmt.Println(errors.Is(clean(p, 42), os.ErrNotExist))
}failed to read settings file /srv/app/settings.json: error: open /srv/app/settings.json: no such file or directory
load settings for user 42: open /srv/app/settings.json: no such file or directory
trueThe noisy version says the path twice, since *fs.PathError already includes it (open /srv/app/settings.json: no such file or directory), and spends words on "failed to" and "error", which the reader knows. The clean version adds the user, which the error could not know, and drops the path. Both keep the chain, so errors.Is works on either.
Check what the callee already says
The skill is knowing which errors are already rich. A short list from the standard library:
| Source | What the message already contains |
|---|---|
os.Open, os.ReadFile, os.Stat | The operation and the full path |
strconv.Atoi | The function name and the offending input |
json.Unmarshal into a struct | The field and the Go type, for type mismatches |
net.Dial | The network, the address and the cause |
sql.ErrNoRows | Nothing; it is just "no rows in result set" |
io.EOF | Nothing |
context.DeadlineExceeded | Nothing but the fact of the deadline |
The last three are the ones to wrap generously, because they carry no location at all. fmt.Errorf("find order %d: %w", id, sql.ErrNoRows) is a useful message, and sql.ErrNoRows alone is a mystery. The first four are already informative, and an extra layer that restates their content is noise. Run the call that fails once and read the message before deciding what to add.
Stutter and how to spot it
A stutter is the same word repeated down the chain. It usually comes from wrapping with the name of the function you are inside:
package main
import (
"errors"
"fmt"
)
var errConn = errors.New("connection refused")
func dial() error { return errConn }
func fetchUser() error {
if err := dial(); err != nil {
return fmt.Errorf("fetchUser: %w", err)
}
return nil
}
func getProfile() error {
if err := fetchUser(); err != nil {
return fmt.Errorf("getProfile: %w", err)
}
return nil
}
func handle() error {
if err := getProfile(); err != nil {
return fmt.Errorf("handle: %w", err)
}
return nil
}
func main() {
fmt.Println(handle())
}handle: getProfile: fetchUser: connection refusedYou get handle: getProfile: fetchUser: connection refused. The function names are a poor man's stack trace, and they are the thing the code reader already knows. They tell a user nothing, and they make the message break when someone renames a function. The fix is to wrap with what the function was doing for its caller, not what it is called, and to skip the wrap altogether at layers that add nothing:
handle profile request for user 42: load profile: connection refusedCompare how much more that tells you. And note that the middle layers either said something real (load profile) or returned the error unchanged.
Where to wrap and where not to
A useful rule is to wrap where the caller cannot see what you did. A call across a package boundary, into a goroutine or over the network crosses such a line. Between two helper functions in one file, the caller knows what it just called, and a wrap adds only the callee's name. The practical default:
- Public functions and methods: wrap, with a phrase about the operation.
- Unexported helpers: return the error as is, unless the helper has information the caller lacks.
- A function with several calls that can fail the same way: wrap each with a short distinguishing phrase (
read header,read body), otherwise two failures produce the same message.
That last case is the most common one where wrapping earns its place. A function that calls io.ReadFull three times will return unexpected EOF from any of them, and the phrase says which.
Context that is data, not text
Sometimes the context is more than a phrase. A request ID, a tenant, a retry count, the SQL text. Formatting these into the message with %d and %s works for people and fails for programs: you cannot filter logs on a number that is inside a sentence. Two better places exist.
Log attributes at the point of handling. The code that finally logs the error knows the request and can attach the values as fields:
package main
import (
"errors"
"fmt"
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
err := fmt.Errorf("charge card: %w", errors.New("insufficient funds"))
log.Error("checkout failed", "err", err, "order_id", 8812, "tenant", "acme")
}level=ERROR msg="checkout failed" err="charge card: insufficient funds" order_id=8812 tenant=acmeThe message stays short, and the order ID and the tenant are fields that a log system can index. This is usually the best place for request-scoped data, since the handler has it and the deep function does not need to know it exists (log errors once).
A custom error type when a caller, not just a logger, needs the value. A *ValidationError with a Field is context as data in the type system (custom error types). Use it when code branches on the value, not when a human reads it.
Stack traces are a different tool
Context phrases are what the program was doing. A stack trace is where it was. They answer different questions, and Go's errors carry only the first. If you find yourself writing long chains to reconstruct the location, that is a sign you want a trace in the log at the point of handling, not more words in the wraps, which the article on stack traces for errors weighs. For the language's own guidance on messages, Effective Go and the Go code review comments on error strings set the lower-case, no-punctuation convention that this article follows.


