Stack Traces & Debugging — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "What is a stack trace?" and "How do I read one?"
When a Go program crashes — or when you ask it nicely — it can print a list of function calls that were active at that moment, with file names and line numbers. That list is called a stack trace (or "stack dump", or "traceback"). It is the single most important diagnostic tool in any programmer's box, because it answers the question that always comes up first when something goes wrong: where did this happen?
In other languages a stack trace usually arrives attached to an exception. In Go, errors do not carry stack traces by default. You either: - Get a stack trace because the program panicked and the runtime printed one for you, or - Capture one explicitly using the runtime and runtime/debug packages.
That is a big philosophical difference compared to Java or Python. It is also one of the first things every Go developer needs to understand.
Run this and you will see the function names, file paths, and line numbers leading from main to debug.PrintStack. That is your first stack trace, captured deliberately.
After reading this file you will: - Be able to read a panic stack trace from top to bottom - Know how to capture a stack trace programmatically - Understand the difference between capturing a trace and displaying one - Know about runtime.Caller, runtime.Stack, runtime/debug.Stack, and debug.PrintStack - Understand GOTRACEBACK and what each setting does - Know when to look at a stack trace and when to reach for a debugger or pprof
Prerequisites¶
- Required:
panicandrecover(covered in 5.7) — the most common way to see a stack trace is via a panic. - Required: Goroutines (basic understanding) — Go traces are per-goroutine.
- Required: Functions and call stacks — knowing what "the function called me" means.
- Helpful but not required: Comfort with the standard library's
os,fmt, andruntimepackages. - Helpful but not required: Familiarity with reading file paths and line numbers in compiler errors.
Glossary¶
| Term | Definition |
|---|---|
| stack trace | A snapshot of the active function calls in a goroutine, top-most caller first or last (Go puts the failing function first). |
| frame | One entry in the trace: a function plus the line it was at when the snapshot happened. |
| PC | Program counter — the instruction address inside a function. runtime.Caller returns one. |
| panic | A runtime mechanism for unrecoverable errors. By default panics print a stack trace before the program exits. |
| goroutine dump | A trace of every goroutine, not just the current one. Triggered by runtime.Stack(buf, true) or by SIGQUIT. |
| GOTRACEBACK | Environment variable controlling how much the runtime prints on panic: none, single, all, system, crash. |
| inline | The compiler may merge a small function into its caller; inlined functions can be missing or unusual in traces. |
| delve / dlv | The standard interactive debugger for Go programs. |
| pprof | A profiling tool built into the runtime, useful for CPU, memory, and goroutine analysis. |
Core Concepts¶
Concept 1: A panic prints a stack trace¶
The simplest way to see a stack trace is to crash on purpose:
Run it. The output looks like:
panic: boom
goroutine 1 [running]:
main.c(...)
/tmp/main.go:3
main.b(...)
/tmp/main.go:4
main.a(...)
/tmp/main.go:5
main.main()
/tmp/main.go:7 +0x...
exit status 2
The order is deepest first: the function that panicked is on top, and main is at the bottom. Read it top-to-bottom and you walk backward through the call chain.
Concept 2: Stack traces are per-goroutine¶
Go programs almost always have many goroutines running. A panic in one goroutine prints that goroutine's trace, plus — depending on the panic — sometimes a "goroutine N [...]:" header. Other goroutines keep going (or the runtime kills them after the panic, depending on the setting).
If you want to dump every goroutine, use runtime.Stack(buf, true) or send the process SIGQUIT (Ctrl-\ on Unix terminals).
Concept 3: Capturing without panicking¶
You do not need to crash to see a trace. The standard library gives you four building blocks:
runtime.Caller(skip int) (pc uintptr, file string, line int, ok bool)
runtime.Callers(skip int, pc []uintptr) int
runtime.Stack(buf []byte, all bool) int
runtime/debug.Stack() []byte
runtime/debug.PrintStack()
The first two give you raw program counters; the second two give you human-readable text.
Concept 4: The PC → frame pipeline¶
Stack traces work in two stages: 1. Capture PCs — collect the program counters of the active call frames. 2. Resolve symbols — turn each PC into "function, file, line".
runtime.Callers is the cheap step: it just copies a few uintptrs into a slice. runtime.CallersFrames is the slower step: it looks up each PC in the binary's symbol table.
You can capture PCs cheaply at the time of an error and resolve them later when (or if) the error is actually printed.
Concept 5: GOTRACEBACK controls how much you see¶
When the program panics, the runtime asks GOTRACEBACK how verbose to be:
| Value | Meaning |
|---|---|
none | No traceback (rare, used for stripped builds). |
single | Only the panicking goroutine (the default). |
all | All goroutines. |
system | All goroutines including runtime-internal ones. |
crash | Like system, then dump core. |
Default is single. Setting GOTRACEBACK=all is the first thing to try when a panic confuses you.
Real-World Analogies¶
| Concept | Analogy |
|---|---|
| Stack trace | The "you are here" map at a department store, showing every floor you took to get to the third-floor coffee shop. |
| Panic + trace | A black-box recorder on an airplane: it stops, prints, and the program lands hard. |
runtime.Caller | Looking up "who called me" in your phone's recent-calls list — one entry at a time. |
| Goroutine dump | A roll-call of every employee in the building when the fire alarm rings. |
GOTRACEBACK=all | Turning on every camera in the store after a shoplifting incident, not just the one above the register. |
| Inlining | A meeting note that says "merged with previous minute" — the original speaker is hidden inside someone else's row. |
Mental Models¶
The film-strip model. A stack trace is one frame of a film. The film is your program running; the frame freezes the position of every actor (function call) at one instant. You cannot see what they did — you can see where they were standing.
The receipt model. Each function call is an item on a long receipt. When something goes wrong at the bottom of the receipt, you scan up to figure out which line started this purchase. The stack trace is that scan.
The two-stage capture model. Think of runtime.Callers as taking a polaroid photo of bare addresses (cheap), and CallersFrames as paying a translator to label everyone in the photo (expensive). Most production systems take the cheap photo at the error site and pay the translator later — only if the error is actually displayed.
Pros & Cons¶
| Pros | Cons |
|---|---|
| Tells you exactly where execution was at the captured moment. | Capturing the trace is not free — it costs microseconds and allocates. |
| Built into the runtime, no third-party library required. | Default Go errors do not carry stack traces. |
| Works the same way in dev, staging, and prod. | Inlined and tail-called functions can be missing or merged in the output. |
| Goroutine dumps are uniquely powerful for diagnosing concurrency bugs. | Stack traces can leak internal file paths, function names, and structure to whoever reads them. |
GOTRACEBACK lets you tune verbosity at runtime. | Without context (request ID, user ID), a trace alone is rarely enough. |
When to use:¶
- Diagnosing a panic in development.
- Building a custom error type that needs to know its origin.
- Investigating a goroutine leak with a goroutine dump.
When NOT to use:¶
- Per-error decoration in a hot loop. (See
optimize.md.) - As a replacement for structured logging or distributed tracing.
Use Cases¶
- Crash diagnosis — read the panic trace to find the line that failed.
- Custom error types — attach a captured stack to an error so the consumer can print "where".
- Test failures —
t.Fatalf/t.Errorfprint the test trace;runtime/debug.Stackadds your own. - Goroutine leak investigation — dump all goroutines and look for ones blocked in unexpected places.
- Production debugging —
pprofendpoints expose live goroutine stacks without crashing.
Code Examples¶
Example 1: Read a panic trace¶
package main
func bottom() { panic("oops") }
func middle() { bottom() }
func top() { middle() }
func main() { top() }
What it does: Triggers a panic; the runtime prints a trace from bottom upward to main. How to run: go run main.go and read the output.
Example 2: Print a stack without panicking¶
package main
import (
"fmt"
"runtime/debug"
)
func reportHere() {
fmt.Println("--- stack ---")
fmt.Println(string(debug.Stack()))
}
func main() {
reportHere()
fmt.Println("still alive")
}
What it does: debug.Stack() returns a []byte with the current goroutine's trace. The program continues normally.
Example 3: Find your caller with runtime.Caller¶
package main
import (
"fmt"
"runtime"
)
func whoCalledMe() {
pc, file, line, ok := runtime.Caller(1) // skip = 1 means "my caller"
if !ok {
fmt.Println("could not get caller")
return
}
fn := runtime.FuncForPC(pc)
fmt.Printf("called from %s at %s:%d\n", fn.Name(), file, line)
}
func realCaller() {
whoCalledMe()
}
func main() {
realCaller()
}
What it does: Looks one frame up the stack and prints the caller's function, file, and line.
Example 4: Capture PCs with runtime.Callers¶
package main
import (
"fmt"
"runtime"
)
func capture() {
pcs := make([]uintptr, 10)
n := runtime.Callers(2, pcs) // skip Callers itself and capture
pcs = pcs[:n]
frames := runtime.CallersFrames(pcs)
for {
f, more := frames.Next()
fmt.Printf("%s\n %s:%d\n", f.Function, f.File, f.Line)
if !more {
break
}
}
}
func main() {
capture()
}
What it does: Captures the live PC slice, then resolves each to a (function, file, line) using CallersFrames. The standard idiom.
Example 5: Dump every goroutine¶
package main
import (
"fmt"
"runtime"
"time"
)
func sleeper(name string) {
time.Sleep(time.Hour) // parked here so it shows up in the dump
_ = name
}
func main() {
go sleeper("a")
go sleeper("b")
time.Sleep(50 * time.Millisecond) // let the goroutines start
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, true) // true = all goroutines
fmt.Println(string(buf[:n]))
}
What it does: Starts two goroutines that sleep, then prints the trace of every goroutine. Notice "all=true" — the magic switch.
Every example must be runnable. Include
package mainandfunc main().
Coding Patterns¶
Pattern 1: Recover-and-log with stack¶
The classic top-of-handler pattern: catch the panic, log the cause and trace, do not crash the whole server.
Pattern 2: Caller-aware logger¶
func logAt(skip int, msg string) {
_, file, line, _ := runtime.Caller(skip + 1)
log.Printf("%s:%d %s", file, line, msg)
}
A log helper that prints the caller's file and line, not its own. Many third-party loggers do this.
Pattern 3: Capture PCs at error origin¶
type withStack struct {
err error
pcs [32]uintptr
n int
}
func wrap(err error) error {
var ws withStack
ws.err = err
ws.n = runtime.Callers(2, ws.pcs[:])
return &ws
}
We capture the cheap part (PCs) at the point of failure. Resolution to file/line is deferred until someone actually prints the error.
Pattern 4: Use debug.SetTraceback programmatically¶
If you want a single panic site to print all goroutines (regardless of GOTRACEBACK), set it just before panicking.
Pattern 5: Send SIGQUIT to inspect a hung process¶
In the terminal where your Go program is running, press *Ctrl-* (on Unix). The runtime prints a goroutine dump and exits. No code changes required.
Clean Code¶
- Capture the stack at the origin of the failure, not at every wrap. A 5-deep wrap chain with 5 captured stacks is wasteful and confusing.
- Print stacks at the boundary (top-level handler, request middleware), not deep in libraries.
- Do not capture stacks in tight loops. They allocate.
- Keep the message that goes with a stack short — the stack itself is the long part.
- When you need a quick diagnostic in development,
fmt.Println(string(debug.Stack()))is a perfectly fine "print where I am".
Product Use / Feature¶
In a real Go service the most common stack-trace usages are:
// Top-level recovery middleware
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %v\n%s", rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Diagnostic endpoint
func goroutinesHandler(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
w.Write(buf[:n])
}
// Or use the built-in net/http/pprof endpoints
import _ "net/http/pprof"
// /debug/pprof/goroutine?debug=2 dumps all goroutines
Error Handling¶
- A captured stack is for humans, not for
errors.Is/errors.As. Do not branch on stacks. - When you build a custom error with a stack, still implement
Unwrap()so the rest of the error machinery keeps working. - When you log "panic + stack", do it once, at the top boundary. Logging it again upstream will double the log volume.
Security Considerations¶
- Stack traces leak code structure. They reveal package names, file paths, function names. Never send a raw stack trace in an HTTP response to an untrusted client.
- Internal paths like
/Users/alice/src/...show up in builds; consider stripping them with-trimpath. - Error messages in stacks can leak data — if you panicked with
panic(fmt.Sprintf("user %s password %s", u, p)), the panic value is in the trace. - Goroutine dumps include parameters in some forms. Treat them as sensitive.
Performance Tips¶
runtime.Callerswith a small slice is cheap (~hundreds of ns);CallersFramesis more expensive because it does symbolization.runtime/debug.Stack()does both — capture and symbolize and format. Easily microseconds. Do not call it in hot paths.- A panic-and-recover round trip with stack formatting can be 10-100 µs. Fine for a top-level safety net, terrible inside a tight loop.
- See
optimize.mdfor benchmarks and concrete numbers.
Best Practices¶
- Use the runtime's tracing first. Read the panic.
GOTRACEBACK=all. Only then reach for tooling. - Capture stacks once, at the source. Add them to the error as it is created, not as it is wrapped.
- Format stacks lazily. Capture cheap PCs, format only when a human will see them.
- Use structured logs. Pair a stack with a request ID so you can correlate.
- Have a goroutine-dump endpoint.
pprof.Lookup("goroutine").WriteTo(w, 2)saves you in production. - Trim binaries with
-trimpathfor distribution.
Edge Cases & Pitfalls¶
- Inlined functions disappear. A small function inlined into its caller may not show as a separate frame, or may show with
[inlined]markers. Build with-gcflags='-l'to disable inlining when debugging. - Tail calls are not always visible. Go does not do classical tail-call optimization, but small wrappers can be optimized away.
runtime.Caller(0)returns the call site ofCalleritself, which is rarely what you want. You usually wantCaller(1).runtime.Stacktruncates to the buffer size. If your buffer is too small, you get a partial trace. Start with 64 KB; enlarge if needed.- Panic before main starts (e.g., during
init) prints a trace, butrecovercannot catch it.
Common Mistakes¶
- Calling
runtime.Caller(0)when you wantedCaller(1)— printing your own helper as the caller. - Using
runtime/debug.Stackin a tight loop and complaining about CPU. - Logging the stack but not the panic value. You see the where but not the what.
- Recovering and dropping the stack on the floor. A bare
recover()withoutdebug.Stackmakes panics invisible. - Printing a raw stack to the user in an HTTP response — leaks structure.
- Forgetting
all=trueinruntime.Stackwhen investigating goroutine leaks. - Buffer too small for
runtime.Stack— silent truncation. - Using
runtime.Callersskip values that include the wrong number of frames — off-by-one is the usual mistake.
Common Misconceptions¶
- "Errors carry stack traces." They do not — not by default. Wrapping with
%wcarries no location info. - "Panic is the only way to see a stack." No —
debug.Stackworks without panicking. - "Stacks are free." They allocate and walk the runtime; not free.
- "
Callerreturns the line that called my function." It returns whichever frame you ask for viaskip. - "Inlining always preserves the call name in the trace." Not always — sometimes you only see the outer function.
Tricky Points¶
- Skip parameter —
Caller(skip)andCallers(skip, ...)both have askipcount, but the meaning is slightly different:Caller'sskip=0is the caller ofCaller, butCallers' first slot can beruntime.Callersitself depending on version. Read the doc for your Go version. - Goroutine ID is not in the public API. You can grep it from
runtime.Stackoutput, but Go intentionally does not expose it for normal use — it discourages thread-local state. debug.PrintStackwrites toos.Stderr. If your stderr is redirected, you may miss the output.
Test¶
package debugutil
import (
"runtime/debug"
"strings"
"testing"
)
func TestStackContainsCallerName(t *testing.T) {
s := string(debug.Stack())
if !strings.Contains(s, "TestStackContainsCallerName") {
t.Fatalf("stack should mention the test function:\n%s", s)
}
}
Run with: go test ./...
Tricky Questions¶
-
Do Go errors carry stack traces by default? No. Wrapping with
%wpropagates messages, not location. Stack traces must be captured explicitly. -
What is the difference between
runtime.Callerandruntime.Callers?Callerreturns one frame;Callersfills a slice with many PCs. -
Why use
runtime.CallersFramesinstead of looking up each PC manually? It correctly handles inlined functions — it can return multiple "virtual" frames per real PC. -
What does
GOTRACEBACK=alldo? On panic, prints a trace for every goroutine in the program, not only the panicking one. -
How do you trigger a goroutine dump without modifying the code? Send
SIGQUIT(Ctrl-\ on Unix) to the running process. -
Why does my stack trace show
?for some lines? The function was inlined, the symbol was stripped, or the frame is from the runtime itself.
Cheat Sheet¶
// Capture PCs (cheap)
pcs := make([]uintptr, 32)
n := runtime.Callers(2, pcs)
pcs = pcs[:n]
// Resolve to (func, file, line)
frames := runtime.CallersFrames(pcs)
for {
f, more := frames.Next()
fmt.Printf("%s %s:%d\n", f.Function, f.File, f.Line)
if !more { break }
}
// One-shot human-readable text
b := debug.Stack()
fmt.Println(string(b))
// Print to stderr
debug.PrintStack()
// Find caller info
pc, file, line, ok := runtime.Caller(1)
// Dump every goroutine
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, true)
// Set traceback verbosity
debug.SetTraceback("all")
Self-Assessment Checklist¶
- I can read a panic stack trace and find the failing line.
- I can capture a stack without panicking using
debug.Stack. - I know what
skipmeans inruntime.Callerandruntime.Callers. - I know how to dump every goroutine with
runtime.Stack(buf, true). - I know what
GOTRACEBACKdoes and the valuesnone,single,all,system,crash. - I know that errors do not carry stack traces by default.
- I do not call
runtime/debug.Stackin hot paths. - I avoid sending raw stacks to users.
Summary¶
Stack traces are how Go tells you where code was running when something interesting happened — a panic, a captured snapshot, or a goroutine dump. The runtime prints them automatically on panic and exposes a small API (runtime.Caller, runtime.Callers, runtime/debug.Stack) to capture them on demand. They are not part of the error value by default; you opt in. Read traces top-to-bottom, lean on GOTRACEBACK=all and SIGQUIT for free debugging, and keep stacks out of hot paths and untrusted output.
What You Can Build¶
- A small recovery middleware for an HTTP server that logs panic + stack and returns 500.
- A custom logger that prefixes every log line with the caller's file:line.
- A diagnostic CLI flag (
--dump-goroutines) that callsruntime.Stack(buf, true)and prints to stderr. - A "where am I" decorator function used in unit tests to mark setup steps with their location.
Further Reading¶
- Package runtime/debug
- Package runtime — Stack, Caller, Callers, FuncForPC, CallersFrames
- The Go Blog: Stack traces and the runtime (search "traceback" or "panic")
- Diagnostics — official guide to debugging Go programs
- Delve debugger
Related Topics¶
- 05-wrapping-unwrapping-errors —
%wdoes not capture a stack - 07-panic-and-recover — panic is the most common source of a trace
- 04-custom-error-types — adding a stack field to an error
- 03-error-vs-panic — when to escalate to panic so a trace gets printed
Diagrams & Visual Aids¶
panic
|
v
+----------+
| bottom() | <-- top of trace
+----------+
| middle() |
+----------+
| top() |
+----------+
| main() | <-- bottom of trace
+----------+