A lightweight Go error handling package that adds stack traces to errors while maintaining compatibility with Go 1.13+ error wrapping.
errstk is designed with the following principles:
- Minimal API: Focus solely on adding stack traces, nothing more
- Standard library first: Encourage use of
errors.New,fmt.Errorfanderrors.Join - Compatibility: Full support for Go 1.13+ error handling features
- Defer-friendly: Accurate line number capture with defer usage
- Enforced correctness: Includes a linter to ensure proper stack trace capture
go get github.com/tomoemon/go-errstkpackage main
import (
"errors"
"fmt"
"github.com/tomoemon/go-errstk"
)
func main() {
err := doSomething()
if err != nil {
// Print error with stack trace
fmt.Printf("%+v\n", err)
}
}
func doSomething() error {
err := processFile()
if err != nil {
return errstk.With(err)
}
return nil
}
func processFile() error {
return errors.New("file not found")
}func GetUser(id string) (user *User, err error) {
defer errstk.Wrap(&err)
user, err = db.Query(id)
if err != nil {
return nil, err
}
err = user.Validate()
if err != nil {
return nil, err
}
return user, nil
}The defer errstk.Wrap(&err) pattern captures the exact line where the error is returned, making debugging much easier.
func With(err error) errorAnnotates an error with a stack trace at the point With was called.
- Returns
nilif the input error isnil - Avoids double-wrapping if the error already has a stack trace
- Preserves the error chain for
errors.Isanderrors.As
Example:
err := errors.New("database error")
wrappedErr := errstk.With(err)
fmt.Printf("%+v", wrappedErr)
// Output:
// database error
// main.myFunction()
// /path/to/file.go:42 +0x1234567
// main.main()
// /path/to/file.go:30 +0x7654321func Wrap(errp *error)Wraps the error pointed to by errp with a stack trace. Designed for use with defer and named return values.
- Does nothing if
*errpisnil - Captures the stack trace at the return point when used with
defer - Avoids double-wrapping
Example:
func processData() (err error) {
defer errstk.Wrap(&err)
// Your code here
if someCondition {
return errors.New("validation failed") // Stack trace points here
}
return nil
}func ErrorStack(err error) stringReturns a string containing both the error message and callstack. Searches through the error chain to find all stack traces and combines them, working even when the error has been wrapped multiple times with fmt.Errorf or errors.Join.
- Preserves complete error context from wrapped errors
- Returns empty string if no stack trace is found
- Automatically handles message duplication
Example:
// Layer 1: Original error with stack trace
err1 := errstk.With(errors.New("database connection failed"))
// Layer 2: Wrap with fmt.Errorf
err2 := fmt.Errorf("failed to initialize: %w", err1)
// Layer 3: Wrap again
err3 := fmt.Errorf("service startup failed: %w", err2)
// Get complete stack trace with full context
stackTrace := errstk.ErrorStack(err3)
fmt.Println(stackTrace)
// Output:
// service startup failed: failed to initialize: database connection failed
//
// database connection failed
// main.layer1()
// /path/to/main.go:10 +0x1234567
// ...func WalkStack(err error, f func(error, []StackFrame))Walks through the error chain and calls the callback function for each error that has a stack trace. Supports both single error chains (via errors.Unwrap) and multiple error chains (via errors.Join).
This is useful when you need custom formatting or processing of error stack traces. For standard formatted output, use ErrorStack() instead.
Example - Custom formatting:
errstk.WalkStack(err, func(err error, frames []StackFrame) {
fmt.Printf("Error: %s\n", err.Error())
for _, frame := range frames {
fmt.Printf(" at %s:%d in %s\n", frame.File, frame.LineNumber, frame.Name)
}
})Example - JSON serialization:
type ErrorTrace struct {
Message string `json:"message"`
Stack []StackFrame `json:"stack"`
}
var traces []ErrorTrace
errstk.WalkStack(err, func(err error, frames []StackFrame) {
traces = append(traces, ErrorTrace{
Message: err.Error(),
Stack: frames,
})
})
data, _ := json.Marshal(traces)Example - Collecting only first N frames:
var topFrames []StackFrame
errstk.WalkStack(err, func(_ error, frames []StackFrame) {
if len(topFrames) == 0 && len(frames) > 5 {
topFrames = frames[:5] // Get first 5 frames
}
})errstk supports standard Go format verbs:
%s,%v: Error message only%q: Quoted error message%+v: Error message with full stack trace
Example:
err := errstk.With(errors.New("something went wrong"))
fmt.Printf("%s\n", err) // something went wrong
fmt.Printf("%v\n", err) // something went wrong
fmt.Printf("%q\n", err) // "something went wrong"
fmt.Printf("%+v\n", err) // *errors.errorString something went wrong
// main.myFunction()
// /path/to/file.go:42 +0x1234567
// ...errstk preserves Go 1.13+ error chains, allowing you to use errors.Is and errors.As:
var ErrNotFound = errors.New("not found")
func findUser(id string) error {
err := db.Find(id)
if err != nil {
return errstk.With(ErrNotFound)
}
return nil
}
func main() {
err := findUser("123")
// errors.Is works correctly
if errors.Is(err, ErrNotFound) {
fmt.Println("User not found")
}
// Extract stack trace from wrapped errors
stackTrace := errstk.ErrorStack(err)
if stackTrace != "" {
fmt.Println("Stack trace:")
fmt.Println(stackTrace)
}
// Or use %+v format verb
fmt.Printf("%+v\n", err)
}ErrorStack can extract stack traces even when the error has been wrapped multiple times with fmt.Errorf:
func layer1() error {
// Original error with stack trace
return errstk.With(errors.New("database connection failed"))
}
func layer2() error {
err := layer1()
if err != nil {
// Wrap with additional context using fmt.Errorf
return fmt.Errorf("failed to initialize: %w", err)
}
return nil
}
func layer3() error {
err := layer2()
if err != nil {
// Wrap again with more context
return fmt.Errorf("service startup failed: %w", err)
}
return nil
}
func main() {
err := layer3()
// Error message includes all wrapping context
fmt.Printf("Error: %v\n", err)
// Output: service startup failed: failed to initialize: database connection failed
// ErrorStack can still find the stack trace deep in the error chain
stackTrace := errstk.ErrorStack(err)
if stackTrace != "" {
fmt.Println("\nStack trace from original error:")
fmt.Println(stackTrace)
// Output shows the stack trace from layer1() where errstk.With was called
}
// Note: Using %+v on the fmt.Errorf-wrapped error won't show the stack trace
// because fmt.Errorf creates a plain error type.
// Use ErrorStack() instead to extract the stack trace from the error chain.
}ErrorStack can extract and combine stack traces from multiple errors joined with errors.Join:
func processFile() error {
var errs []error
// Read operation fails with stack trace
if err := readFile(); err != nil {
errs = append(errs, errstk.With(fmt.Errorf("read failed: %w", err)))
}
// Write operation also fails with stack trace
if err := writeFile(); err != nil {
errs = append(errs, errstk.With(fmt.Errorf("write failed: %w", err)))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func main() {
err := processFile()
if err != nil {
// Error message includes all joined errors
fmt.Printf("Errors: %v\n", err)
// Output:
// read failed: file not found
// write failed: permission denied
// ErrorStack extracts stack traces from all errors
stackTrace := errstk.ErrorStack(err)
fmt.Println("\nComplete stack traces:")
fmt.Println(stackTrace)
// Output:
// read failed: file not found
// write failed: permission denied
//
// read failed: file not found
// processFile
// /path/to/file.go:10
// ...
//
// write failed: permission denied
// processFile
// /path/to/file.go:15
// ...
}
}Cleanup error pattern with errors.Join:
func processResource() (err error) {
var cleanupErr error
defer func() {
// Cleanup operation that may fail
if cerr := cleanup(); cerr != nil {
cleanupErr = errstk.With(fmt.Errorf("cleanup failed: %w", cerr))
err = errors.Join(err, cleanupErr)
}
}()
// Main operation with stack trace
if err := doWork(); err != nil {
return errstk.With(fmt.Errorf("work failed: %w", err))
}
return nil
}
func main() {
err := processResource()
if err != nil {
// ErrorStack will show stack traces from both the main error
// and the cleanup error
fmt.Println(errstk.ErrorStack(err))
// Output includes both stack traces with their contexts
}
}func complexOperation() (result *Result, err error) {
defer errstk.Wrap(&err)
if err := validateInput(); err != nil {
return nil, err // Stack trace captured here
}
if err := processData(); err != nil {
return nil, err // Stack trace captured here
}
return &Result{}, nil
}func simpleOperation() error {
err := doSomething()
if err != nil {
return errstk.With(err)
}
return nil
}This package intentionally does not provide a New function to encourage using the standard library:
// Good
return errstk.With(errors.New("invalid input"))
// Avoid - no such function
return errstk.New("invalid input")You can configure the maximum stack depth globally:
errstk.DefaultMaxStackDepth = 50 // Default is 32You can configure the number of stack frames to skip when capturing a stack trace. This is useful when you wrap With or Wrap in your own helper functions.
Example - Custom wrapper function:
package myapp
import (
"fmt"
"github.com/tomoemon/go-errstk"
)
func init() {
// Skip one additional frame for our custom wrapper
errstk.DefaultSkipFrames = 1
}
// WrapWithContext Custom wrapper that adds context
func WrapWithContext(err error, ctx string) error {
if err == nil {
return nil
}
return errstk.With(fmt.Errorf("%s: %w", ctx, err))
}You can customize how stack frames are formatted:
func init() {
errstk.DefaultStackFrameFormatter = func(frame *errstk.StackFrame) string {
return fmt.Sprintf("%s:%d", frame.File, frame.LineNumber)
}
}errstklint is a linter that ensures all functions returning errors include defer errstk.Wrap(&err) for proper stack trace capture.
go install github.com/tomoemon/go-errstk/cmd/errstklint@latest# Standalone
errstklint ./...
# Auto-fix: add named returns and defer errstk.Wrap(&err)
errstklint -fix ./...
# With exclusions for generated code
errstklint -exclude="generated/*.go,**/mock_*.go" ./...
# As golangci-lint plugin (requires golangci-lint v2.0.0+)
golangci-lint custom # See documentation for setup
./custom-gcl run
./custom-gcl run --fix # Auto-fix with golangci-lintThe -fix flag automatically applies the following fixes:
- Unnamed return values are converted to named returns (
errorbecomeserr error, others become_ type) defer errstk.Wrap(&err)is inserted at the beginning of the function bodyimport "github.com/tomoemon/go-errstk"is added if not already present
// Before
func GetUser(id string) (*User, error) {
// ...
}
// After fix
func GetUser(id string) (_ *User, err error) {
defer errstk.Wrap(&err)
// ...
}Note: Functions with multiple error return values are not auto-fixed and will show a warning.
Use nolint directives compatible with golangci-lint:
//nolint:errstklint
func HelperFunction() (err error) {
// This function is excluded from linting
return nil
}
// File-level exclusion
//nolint:errstklint
package testhelpersSee full documentation for more exclusion options including //lint:ignore and exclude-rules.
For detailed usage and golangci-lint integration:
Contributions are welcome! Please feel free to submit issues or pull requests.
Inspired by: