errutil

errutil annotates errors with caller location, joins cleanup failures onto an existing error, and enumerates the errors inside an errors.Join value.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/errutil"

Three functions, each answering a question the standard errors package leaves open: where did this error come from, how do I report a cleanup failure without losing the real one, and what is actually inside this joined error.

Trace, and what caller capture costs

Trace wraps an error with the file, line and function of its call site, using %w so errors.Is and errors.As still see through it.

if err := db.Query(ctx, q); err != nil {
    return errutil.Trace(err)
}

It does that with runtime.Caller and runtime.FuncForPC, and those are not free. Against plain fmt.Errorf wrapping:

Callns/opB/opallocs/op
errutil.Trace(err)552.94246
fmt.Errorf("file: %s, line: %d, function: %s, error: %w", ...) with the values already known163.01122
fmt.Errorf("doing thing: %w", err)85.7562

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/errutil/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

The middle row is the interesting one, because it formats exactly the same string. The gap between it and the first row, roughly 390 ns and four allocations, is the price of asking the runtime where you are rather than being told.

That number sets the usage rule. Trace belongs at layer boundaries, where an error crosses from storage into service or service into transport, and the annotation buys you something. It does not belong inside a loop over ten thousand rows. An error path that runs once per request will not notice 550 ns; an error path that runs per item will.

One deployment note from the doc comment: the file path is whatever the compiler recorded, which is an absolute build-host path unless you build with -trimpath. Release builds should use -trimpath so your build directory does not end up in production logs.

JoinFnError: the deferred-close problem

JoinFnError is independent of Trace and solves a different problem.

The pattern it replaces appears in every Go codebase that opens something:

defer f.Close()   // error discarded

Discarding it is wrong for anything buffered, where Close is when the write actually fails. But the obvious fix loses the primary error instead:

defer func() { err = f.Close() }()   // overwrites the real failure

JoinFnError takes the address of a named return and joins rather than assigns:

func process() (err error) {
    defer errutil.JoinFnError(&err, closer)

    return errors.New("primary failure")
}
// yields both, in order: "primary failure" then "close failed"

At 33 ns and two allocations it is cheap enough for any cleanup path. If the supplied function is nil it joins ErrNilErrorFunc rather than panicking, so a programming mistake surfaces in the error rather than in a stack trace.

The cost is the named return value, which some house styles ban outright. There is no way around that: the deferred call needs somewhere to write.

Errors

Errors flattens an errors.Join value into a slice. A plain error gives a one-element slice, nil gives nil. It exists so that reporting code can iterate without type-asserting for the unexported interface{ Unwrap() []error } each time.

Lineage

The gosrvlib generation of this library called it errtrace. The rename to errutil came with the 2025 merge back into the Tecnick.com line, along with JoinFnError, which the older package did not have.

Example

err := errors.New("example error")
testErr := errutil.Trace(err)

fmt.Println(testErr)

Full source is in example_errutil_test.go. More runnable examples are on pkg.go.dev.

Dependencies

This package reaches no external module: it uses only the Go standard library.