A feature-complete error handling library for Go. Fully compatible with errors.Is, errors.As, and errors.Unwrap. Optimised for high-throughput systems with object pooling, hybrid context storage, and inlining-immune stack capture.
go get github.com/olekukonko/errors@latestRequires Go 1.21 or later.
| Package | Purpose |
|---|---|
errors | Core error type, wrapping, context, stack traces, retry, chain, multi-error, channel utilities |
errmgr | Parameterised error templates, occurrence monitoring, threshold alerting |
// Fast — no stack trace, 0 allocations with poolingerr:=errors.New("connection failed")
// Formatted — full fmt verb support including %werr:=errors.Newf("user %s not found", "alice")
err:=errors.Errorf("query failed: %w", cause) // alias of Newf// With stack traceerr:=errors.Trace("critical issue")
err:=errors.Tracef("query %s failed: %w", query, cause)
// Named — useful for sentinel-style matchingerr:=errors.Named("AuthError")
// Standard library compatibleerr:=errors.Std("connection failed") // returns plain errorerr:=errors.Stdf("error %s", "detail") // formatted plain error// Capture at creationerr:=errors.Trace("critical issue")
// Add to an existing errorerr=err.WithStack()
// Read framesfor_, frame:=rangeerr.Stack() {
fmt.Println(frame) // "main.go:42 main.main"
}
// Lightweight version (file:line only, no function names)for_, frame:=rangeerr.FastStack() {
fmt.Println(frame)
}Stack capture is immune to compiler inlining — frames are collected from the physical call stack and trimmed by slice arithmetic, not by skip count.
err:=errors.New("processing failed").
With("user_id", "123").
With("attempt", 3).
With("retryable", true)
// Read backctx:=errors.Context(err) // map[user_id:123 attempt:3 retryable:true]// Check for a keyiferr.HasContextKey("user_id") { ... }
// Variadic bulk attacherr.With("k1", v1, "k2", v2)
// Semantic helperserr.WithCode(500)
err.WithCategory("network")
err.WithTimeout()
err.WithRetryable()The first four context items are stored in a fixed-size array (no allocation). Items beyond four spill to a map.
lowErr:=errors.New("connection timeout").With("server", "db01")
bizErr:=errors.New("failed to load user").Wrap(lowErr)
apiErr:=errors.Wrapf(bizErr, "request failed: %w", bizErr)
// Traversefori, e:=rangeerrors.UnwrapAll(apiErr) {
fmt.Printf("%d. %s\n", i+1, e)
}
// 1. request failed: ...// 2. failed to load user// 3. connection timeoutConst creates a stable, pointer-comparable sentinel safe for package-level variables.
var (
ErrNotFound=errors.Const("not_found", "resource not found")
ErrForbidden=errors.Const("forbidden", "access denied")
)
// Match anywhere in a chainiferrors.Is(err, ErrNotFound) { ... }
// Add call-site context without losing the sentinelerr:=ErrNotFound.With("user 42 not found")
errors.Is(err, ErrNotFound) // true — sentinel is the cause// JSON and slog work automaticallyb, _:=json.Marshal(ErrNotFound) // {"error":"resource not found","code":"not_found"}slog.Error("lookup failed", "err", ErrNotFound)
Constvserrmgr.Defineerrors.Const— static comparable value forerrors.Ismatching.errmgr.Define— parameterised factory that creates new*Errorinstances from a format template.
// Is — checks identity or name matcherr:=errors.Named("AuthError")
wrapped:=errors.Wrapf(err, "login failed")
errors.Is(wrapped, err) // true// As — extract the first matching *Error from the chainvartarget*errors.Erroriferrors.As(wrapped, &target) {
fmt.Println(target.Name()) // "AuthError"
}
// Generic helpers (Go 1.18+)ife, ok:= errors.AsType[*MyError](err); ok { ... }
if errors.IsType[*MyError](err) { ... }
found, ok:=errors.FindType(err, func(e*MyError) bool {
returne.Code() ==404
})
codes:=errors.Map(err, func(e*MyError) int { returne.Code() })
errors.Filter[*MyError](err) // [] *MyError from chain
errors.FirstOfType[*MyError](err) // first *MyError
Is()string-equality note —(*Error).Isfalls back to string comparison as a convenience for matching stdlib errors by message. For strict identity matching useConst().
// Basicm:=errors.NewMultiError()
m.Add(errors.New("name required"))
m.Add(errors.New("email invalid"))
fmt.Println(m.Count()) // 2// With limits and samplingm:=errors.NewMultiError(
errors.WithLimit(100),
errors.WithSampling(10), // 10% sample rate
)
// Custom formatterm:=errors.NewMultiError(
errors.WithFormatter(func(errs []error) string {
returnfmt.Sprintf("%d errors", len(errs))
}),
)
// Inspectm.First() // first errorm.Last() // last errorm.Errors() // []error snapshotm.Has() // boolm.Single() // nil | first error | *MultiError// FilternetworkErrs:=m.Filter(func(eerror) bool {
returnstrings.Contains(e.Error(), "network")
})
// Merge two MultiErrorsm.Merge(other)
// Join is a convenience that collapses errors to *MultiError or nilerr:=errors.Join(err1, err2, err3)retry:=errors.NewRetry(
errors.WithMaxAttempts(5),
errors.WithDelay(200*time.Millisecond),
errors.WithMaxDelay(2*time.Second),
errors.WithJitter(true),
errors.WithBackoff(errors.ExponentialBackoff{}),
errors.WithRetryIf(errors.IsRetryable),
errors.WithOnRetry(func(attemptint, errerror) {
log.Printf("attempt %d: %v", attempt, err)
}),
)
err:=retry.Execute(func() error {
returncallExternalService()
})
// Generic version — preserves return valueresult, err:=errors.ExecuteReply[string](retry, func() (string, error) {
returnfetchData()
})
// Context-awarectx, cancel:=context.WithTimeout(context.Background(), 5*time.Second)
defercancel()
retry2:=retry.Transform(errors.WithContext(ctx))
err=retry2.Execute(fn)
// Backoff strategies
errors.ConstantBackoff{}
errors.LinearBackoff{}
errors.ExponentialBackoff{}Sequential steps with per-step retry, timeout, tagging, and optional steps.
chain:=errors.NewChain(
errors.ChainWithTimeout(10*time.Second),
errors.ChainWithLogHandler(slog.Default().Handler()),
).
Step(validateInput).Tag("validation").
Step(verifyKYC).Tag("kyc").
Step(processPayment).Tag("billing").Code(402).
Retry(3, 100*time.Millisecond, errors.WithRetryIf(errors.IsRetryable)).
Step(sendNotification).Tag("notification").Optional()
iferr:=chain.Run(); err!=nil {
errors.Inspect(err, os.Stderr)
}
// Run all steps, collect every erroriferr:=chain.RunAll(); err!=nil {
errors.Inspect(err, os.Stderr)
}StepCtx passes the chain-level context (with its deadline) to the step, so
blocking calls like HTTP or database queries respect the chain timeout:
chain.StepCtx(func(ctx context.Context) error {
req, _:=http.NewRequestWithContext(ctx, "GET", url, nil)
_, err:=http.DefaultClient.Do(req)
returnerr
})These compose with the standard Go (chan T, chan error) idiom rather than replacing it.
// Drain — block until channel closes, collect into *MultiErrorerr:=errors.Drain(errs)
// First — return first non-nil error; ctx for deadline only, caller owns cancelerr:=errors.First(ctx, errs)
iferr!=nil {
cancel() // caller decides to stop siblings
}
// Collect — bounded sample; wraps ErrLimitReached when n is hiterr:=errors.Collect(ctx, errs, 10)
iferrors.Is(err, errors.ErrLimitReached) {
log.Warn("more than 10 errors — some dropped")
}
// Fan — merge multiple error channels; caller must drain or cancel to avoid leakmerged:=errors.Fan(ctx, validateErrs, enrichErrs)
forerr:=rangemerged {
log.Println(err)
}// Process items concurrently, collect all errorss:=errors.NewStream(ctx, urls, func(urlstring) error {
returnfetch(url)
}, 8) // 8 workers; omit for len(items) workers// Option A — block until doneiferr:=s.Wait(); err!=nil {
errors.Inspect(err, os.Stderr)
}
// Option B — process errors as they arrives.Each(func(errerror) {
log.Println(err)
})
// Stop early (drains channel to avoid goroutine leak)s.Stop()Wait and Each are mutually exclusive. Calling either a second time panics immediately.
// Resolve HTTP status from an *Error's codestatus:=errors.HTTPStatusCode(err, http.StatusInternalServerError)
// Write HTTP error responseerrors.HTTPError(w, err) // plain text, status from err.Code()// With optionserrors.HTTPError(w, err,
errors.WithFallbackCode(http.StatusBadGateway),
errors.WithBody(false), // header onlyerrors.WithBodyFunc(func(eerror) string {
returnfmt.Sprintf(`{"error":%q}`, e.Error())
}),
)Group collects all errors from concurrent goroutines — unlike errgroup which stops at the first.
g:=errors.NewGroup()
g.Go(func() error { returnvalidateUser(id) })
g.Go(func() error { returnvalidatePerms(id) })
iferr:=g.Wait(); err!=nil {
// err is *MultiError containing every failureerrors.Inspect(err, os.Stderr)
}
// Context-awareg:=errors.NewGroup(
errors.GroupWithContext(ctx, true), // cancelOnFirst=trueerrors.GroupWithLimit(50),
)
g.GoCtx(func(ctx context.Context) error {
returnlongRunningCheck(ctx)
})
_=g.Wait()// Default — writes to os.Stderrerrors.Inspect(err)
// Targeted outputvarbuf bytes.Buffererrors.Inspect(err, &buf)
// Multiple destinationserrors.Inspect(err, os.Stderr, logFile)
// Optionserrors.Inspect(err, os.Stderr,
errors.WithStackFrames(5),
errors.WithMaxDepth(20),
)
// *Error-specific convenienceerrors.InspectError(err, os.Stderr)Inspect handles *Error, *MultiError, and any stdlib error. It writes
to the supplied io.Writer values (merged via io.MultiWriter) and never
touches stdout.
Both *Error and *Sentinel implement slog.LogValuer:
slog.Error("request failed", "err", err)
// produces structured group: err.message, err.name, err.code, err.category, err.context, err.causeslog.Error("lookup failed", "err", errors.ErrNotFound)
// produces: err.error="resource not found", err.code="not_found"// Pre-warm (called automatically at init with 100 instances)errors.WarmPool(1000)
errors.WarmStackPool(500)
// Tune global configerrors.Configure(errors.Config{
StackDepth: 32,
ContextSize: 4,
DisablePooling: false,
FilterInternal: true,
AutoFree: false, // opt-in GC-based pool return
})
// Explicit pool return (preferred)err:=errors.New("temp")
defererr.Free()
// Copy without affecting originalcopied:=err.Copy().With("extra", "data")
// Transform (non-destructive)enriched:=errors.Transform(err, func(e*errors.Error) {
e.WithCode(500).With("env", "prod").WithStack()
})// Define a reusable templatevarErrDBQuery=errmgr.Define("DBQuery", "database query failed: %s")
// Instantiate with argumentserr:=ErrDBQuery("SELECT timed out")
fmt.Println(err) // "database query failed: SELECT timed out"fmt.Println(err.Category()) // "database"err:=errmgr.ErrNotFoundfmt.Println(err.Code()) // 404err:=errmgr.ErrDBQuery("SELECT failed")netErr:=errmgr.Define("NetError", "network issue: %s")
monitor:=errmgr.NewMonitor("NetError")
errmgr.SetThreshold("NetError", 3)
defermonitor.Close()
gofunc() {
foralert:=rangemonitor.Alerts() {
fmt.Printf("alert: %s (count: %d)\n", alert, alert.Count())
}
}()
err:=netErr("timeout")
err.Free()Key design decisions:
- Pool —
NewandWrapreuse*Errorinstances fromsync.Pool(12 ns/op, 0 allocs). - Hybrid context — up to 4 key-value pairs in a fixed array; overflow to map. Avoids heap allocation for the common case.
- Stack capture —
captureStackis inlining-immune: it always starts fromruntime.Callersframe 1 and trims by array slicing, so the compiler's inlining decisions never corrupt the skip count. - Pool capacity preservation — the pool buffer is trimmed in-place (
copy(buf, buf[trimmed:n])), not re-allocated. Prevents progressive capacity shrinkage under repeatedFree()cycles. MarshalJSON— bytes are copied out of the pool buffer before returning it, eliminating the race between concurrent JSON serialisations.With()— the mutex is acquired once at entry, eliminating the TOCTOU race in the former optimistic read-then-lock path.
// Beforeerr:=fmt.Errorf("user %s not found: %w", username, cause)
// After — same output, plus context, code, and chain traversalerr:=errors.Newf("user %s not found: %w", username, cause).
With("username", username).
WithCode(404)// Beforeerr:=pkgerrors.Wrap(cause, "operation failed")
// Aftererr:=errors.New("operation failed").Wrap(cause).WithStack()// Fully compatible — no changes needediferrors.Is(err, io.EOF) { ... }
vartarget*errors.Erroriferrors.As(err, &target) {
fmt.Println(target.Name())
}When should I use Const vs Named?Const — package-level sentinel for errors.Is matching. Returns the same pointer every call, so pointer equality works. Named — creates a new *Error instance each call; useful for structured errors with context but not for == comparison.
When should I use Const vs errmgr.Define?errors.Const("not_found", "resource not found") creates a static sentinel. errmgr.Define("DBQuery", "query failed: %s") creates a parameterised factory — you call it with arguments to produce a new *Error each time.
When should I call Free()?
In hot paths where the error is short-lived and you want to return it to the pool immediately. For most application code, letting the GC handle it is fine. If AutoFree is enabled in Config, the GC returns the error automatically — but defer err.Free() is more predictable.
Why does First not cancel the context?context.Context is immutable — only context.WithCancel produces a cancellable context. First accepts ctx for deadline support only. The pattern is: call First, then call cancel() yourself if you want to stop siblings.
Why do Each and Wait on Stream panic on second call?
Consuming the same channel twice silently splits errors between two callers. The panic surfaces the bug immediately rather than letting it produce subtly wrong results in production.
How do I debug a deep error chain?
errors.Inspect(err, os.Stderr, errors.WithMaxDepth(30), errors.WithStackFrames(10))How do I write to both stderr and a log file?
errors.Inspect(err, os.Stderr, logFile) // io.MultiWriter internallyFork → branch → commit → PR. Please include tests for new behaviour and run go test -count=10 -race ./... before opening a PR.
MIT — see LICENSE.