Stack Traces for Errors in Go and Whether You Need Them
Go errors carry no stack trace by default. Build one with runtime.Callers, measure what it costs, and decide whether wrapping, panics or a library fit better.

If you come to Go from Java, Python or JavaScript, one of the first things you miss is the stack trace. An exception in those languages arrives with the chain of calls that led to it, for free, and a failure in production can be located by reading the top frame. A Go error, printed with fmt.Println(err), is a string. It says what went wrong and nothing about where. The omission is deliberate and it has real costs, and there are several ways to recover what you lost. This article compares them with numbers.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It belongs to the error handling guide and takes up a question that adding context to errors raises: when words are not enough. Everything below ran on Go 1.25.6 on an Apple M-series laptop, and the benchmark numbers are from that machine.
Why Go errors have no trace
Capturing a stack costs time and memory, and the benchmark below puts numbers on it. Walking the call stack and storing the program counters takes real work, and keeping them alive extends the life of data. Go's authors chose that errors would be ordinary values that cost no more than a pointer and a string to create, because errors are returned from hot paths constantly. io.EOF ends every file read, and a parser may produce thousands of failures a second as part of normal input handling. A stack trace on each would make the common case slower to serve the rare one.
The language took the view that a program's errors should describe what happened in terms the program understands, and that wrapping with context (wrapping with %w) gives you a path through the logic that is more useful than a path through the functions. handle checkout: charge card: gateway timeout explains the failure. A trace of checkout.go:88, billing.go:41 and http.go:120 only says where the code is.
That position is a trade-off and not a fact of nature. A path of intentions needs someone to write the words at each layer, and a program where people did not leaves you with EOF and no clue.
Building a stack trace into an error
It is not hard to attach one. runtime.Callers fills a slice with return addresses, and runtime.CallersFrames turns them into function names and lines:
package main
import (
"errors"
"fmt"
"path/filepath"
"runtime"
"strings"
)
type stackError struct {
err error
pcs [16]uintptr
n int
}
func WithStack(err error) error {
if err == nil {
return nil
}
e := &stackError{err: err}
e.n = runtime.Callers(2, e.pcs[:])
return e
}
func (e *stackError) Error() string { return e.err.Error() }
func (e *stackError) Unwrap() error { return e.err }
func (e *stackError) Stack() string {
var b strings.Builder
frames := runtime.CallersFrames(e.pcs[:e.n])
for {
f, more := frames.Next()
fmt.Fprintf(&b, "%s (%s:%d)\n", f.Function, filepath.Base(f.File), f.Line)
if !more {
break
}
}
return b.String()
}
var ErrDisk = errors.New("disk full")
func write() error { return WithStack(ErrDisk) }
func save() error { return write() }
func handle() error {
return save()
}
func main() {
err := handle()
fmt.Println(err, errors.Is(err, ErrDisk))
var se *stackError
if errors.As(err, &se) {
fmt.Print(se.Stack())
}
}disk full true
main.write (main.go:44)
main.save (main.go:45)
main.handle (main.go:47)
main.main (main.go:51)
runtime.main (proc.go:285)
runtime.goexit (asm_arm64.s:1268)WithStack records up to sixteen frames at the point where it is called, and the error still works with errors.Is, because it implements Unwrap. The Callers(2, ...) argument skips the Callers and WithStack frames themselves so that the first line is the function that created the error. The printed trace shows write, save, handle, main and the runtime's own start-up frames, with the line numbers. This is about thirty lines and it gives you what Java gives for free. The question is what it costs.
What it costs
A benchmark of three ways of creating an error answers it:
package main
import (
"errors"
"fmt"
"runtime"
"testing"
)
var errBase = errors.New("base")
var sink error
type stackErr struct {
err error
pcs [16]uintptr
n int
}
func (e *stackErr) Error() string { return e.err.Error() }
func (e *stackErr) Unwrap() error { return e.err }
func withStack(err error) error {
e := &stackErr{err: err}
e.n = runtime.Callers(2, e.pcs[:])
return e
}
func BenchmarkSentinelReturn(b *testing.B) {
for b.Loop() {
sink = errBase
}
}
func BenchmarkWrapW(b *testing.B) {
for b.Loop() {
sink = fmt.Errorf("load: %w", errBase)
}
}
func BenchmarkWithStack(b *testing.B) {
for b.Loop() {
sink = withStack(errBase)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkSentinelReturn-10 532849032 2.341 ns/op 0 B/op 0 allocs/op
BenchmarkSentinelReturn-10 556959663 2.181 ns/op 0 B/op 0 allocs/op
BenchmarkSentinelReturn-10 549781891 2.181 ns/op 0 B/op 0 allocs/op
BenchmarkWrapW-10 13323327 90.76 ns/op 48 B/op 2 allocs/op
BenchmarkWrapW-10 13618489 88.46 ns/op 48 B/op 2 allocs/op
BenchmarkWrapW-10 13571436 88.30 ns/op 48 B/op 2 allocs/op
BenchmarkWithStack-10 5066806 233.2 ns/op 160 B/op 1 allocs/op
BenchmarkWithStack-10 5332710 221.6 ns/op 160 B/op 1 allocs/op
BenchmarkWithStack-10 5508398 215.1 ns/op 160 B/op 1 allocs/op
PASS
ok example.test/lab 11.058sOn an Apple M1 Max, with the stack five frames deep, the medians of three runs were these:
| Operation | Time per error | Memory | Allocations |
|---|---|---|---|
| Return an existing sentinel | 2.2 ns | 0 B | 0 |
fmt.Errorf("load: %w", err) | 89 ns | 48 B | 2 |
| Wrap and capture a 16-frame buffer | 220 ns | 160 B | 1 |
Returning a sentinel is free, wrapping costs about forty times that, and capturing a stack costs roughly two and a half times a wrap. The stack figure grows with depth, because runtime.Callers unwinds every frame it records, so a handler nested thirty calls deep pays more than this benchmark does, and the 160 bytes are the struct that holds the program counters. None of these costs matter for an error that happens a few times a second. For an error that is part of normal control flow, such as a parser reporting "not a number" on each of a million inputs, 220 ns each adds up to a fifth of a second of pure overhead.
That points at a guideline. Capture a stack for errors that signal something unexpected, where a human will read the trace. Don't capture one for sentinels and expected conditions, which is where the volume is.
Traces that already exist
Before adding a mechanism, notice the places where Go gives you a stack without being asked.
Panics. An unrecovered panic prints the goroutine's stack and exits, and the trace is the main tool for debugging them (panic trace reading). The runtime panics, nil dereferences and index errors, come with locations for free.
runtime/debug.Stack(). It returns the current goroutine's stack as bytes, from anywhere. The natural use is in a recover boundary, to log where a panic came from:
package main
import (
"fmt"
"runtime/debug"
"strings"
)
func risky() {
var m map[string]int
m["x"] = 1
}
func safely(f func()) (err error) {
defer func() {
if r := recover(); r != nil {
stack := string(debug.Stack())
first := strings.Split(stack, "\n")[0]
err = fmt.Errorf("panic: %v (%s, %d lines of stack)", r, first, strings.Count(stack, "\n"))
}
}()
f()
return nil
}
func main() {
fmt.Println(safely(risky) != nil)
}trueThe deferred function catches the panic and takes the stack at that moment, while the frames of the failing call are still on it. The code prints only a summary here; in a service you would pass the whole thing to the logger. This is the right place for stack capture in most programs: panics are the rare, unexpected failures where a location is worth its price.
log/slog source. Setting AddSource: true in the handler options adds the file and line of the logging call to each record. That identifies where the error was logged, not where it was created, which is useful and different.
Libraries that attach traces
Several packages offer errors with stacks. The one that started the trend, github.com/pkg/errors, added Wrap and WithStack and a %+v format that prints the trace; its README now says the package is moving into maintenance mode and not accepting new functionality, since the standard library took over wrapping. A currently active alternative is github.com/cockroachdb/errors, which attaches stacks, works with errors.Is and errors.As, and can carry errors across a network boundary. go-errors/errors is a smaller one built just around stack capture. Error handling libraries compares them and asks whether you need any.
A library buys you the formatting and a convention, so that the whole codebase shares one way of attaching traces. The price is a dependency that every package touching errors would use, and a type that leaks into signatures if you are not careful.
What to do
Practical advice, in order:
- Start with wrapping. A chain such as
handle checkout: charge card: gateway timeoutlocates most failures in seconds, and it costs nothing at runtime beyond the wrap. - Log at the boundary, once. With structured fields for the request (log errors once), you rarely need a stack to find the code.
- Capture a stack at panic boundaries.
recoverplusdebug.Stack()handles the unexpected. - Add stack capture selectively. If you have an error class where "where did this come from" is a recurring question, such as a database layer that returns errors from many call sites, attach a stack to that class only.
- Reach for a library if your team wants one convention and accepts the dependency.
The Go team has discussed adding frames to the standard errors more than once, and each time the cost and the compatibility questions outweighed the convenience. Until that changes, the choice sits with you. For background, the runtime package documentation explains Callers and its frame skipping, and runtime/debug covers Stack.


