The way error propagation is commonly done in Go can be a bit noisy and often doesn’t fully meet the needs of most developers.
In traditional Go error handling, we often use fmt.Errorf
with %w
to wrap errors. Over time, this can create very long and unreadable error messages. For example:
err := fmt.Errorf("failed to load config: %w",
fmt.Errorf("could not read file: %w",
fmt.Errorf("permission denied")))
// Error: failed to load config: could not read file: permission denied
This flattens into a long, nested message that’s difficult to parse — especially once you add multiple layers deep into a production system.
In contrast, a structured error approach could separate concerns neatly:
err := errors.New("ConfigError", "failed to load config")
err = errors.Annotate(err, "filename", "/etc/config.yaml")
Here, you get:
failed to load config
)ConfigError
)filename: /etc/config.yaml
)Instead of digging through wrapped strings, you can query structured fields or use them directly in logs, UIs, or debugging tools. Combined with a stacktrace this would contain all the information required to debug issues effectively.
Recently, I came across an article by Michael Olofinjana: Golang Error Handling: A Practical and Robust Solution.
There were some very good ideas in it, especially the “Good error reporting” checklist:
A good error report should inform me of:
While I agree with the checklist, I wasn’t convinced by the proposed implementation.
For example, the use of timestamps felt unnecessary — you can simply use runtime.Callers
to generate a stack trace automatically, which is much more helpful and requires less manual work.
Also, for point 4 (attaching useful data), I expected something like an error.Annotate(name string, data any)
functionality — but that was missing entirely.
In my view, a clean solution would involve creating a small library that wraps Go’s standard errors
package and adds a few essential features.
At its core, we would need a struct that stores:
Here’s a rough sketch of what that could look like:
type internalError struct {
Kind string `json:"kind"`
Message string `json:"message"`
Annotations map[string]any `json:"annotations,omitempty"`
Stacktrace string `json:"stacktrace,omitempty"`
Parent error `json:"parent,omitempty"`
}
To create a new error, we need a simple New
function that automatically generates a stack trace:
func New(kind string, msg string) error {
return &internalError{
Kind: kind,
Message: msg,
Stacktrace: stackFromCallers(),
}
}
The stack trace can be generated using the runtime
package:
func stackFromCallers() string {
const depth = 32
var pcs [depth]uintptr
n := runtime.Callers(4, pcs[:])
frames := runtime.CallersFrames(pcs[0:n])
var stacktrace string
for {
frame, more := frames.Next()
if !more {
break
}
stacktrace += fmt.Sprintf("%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)
}
return stacktrace
}
Additionally, we can add an Annotate
function to enrich errors with extra context:
func Annotate(err error, key string, value any) error {
if err == nil {
return nil
}
internalErr, ok := err.(*internalError)
if !ok {
internalErr = New("", err.Error()).(*internalError)
internalErr.Parent = err
}
if internalErr.Annotations == nil {
internalErr.Annotations = make(map[string]any)
}
internalErr.Annotations[key] = value
return internalErr
}
Finally, we’d just need to implement the necessary methods to satisfy Go’s error
interface and ensure compatibility with fmt
, slog
, and other packages.
With only about 150 lines of code, we’d have a much more powerful and structured error handling library — without introducing too much complexity.
If you’re interested in a complete implementation with tests and documentation, check out the finished version here: github.com/Neokil/errors.
The repository includes everything you need to get started — feel free to explore, use it in your own projects, or contribute!