1. Stack trace as a hint, not “scary text”
When a program crashes, a beginner often wants to do two things: get mad at Go and start poking the code with a stick “until it works”. It’s a human impulse, but there’s a faster way: accept that Go has already written you a hint — just in a format that looks like a supermarket receipt.
A stack trace is a printout of the call stack at the moment of the crash. In simple terms, it’s a list of functions that were “right now” in progress when the disaster happened.
Go prints a stack trace automatically on panic, because panic stops the normal execution flow and starts “unwinding” calls backward, executing defer (if any) and finally terminating the program. This mechanism is described in the classic article about defer/panic/recover.
Terms: call stack, stack frame, stack trace, goroutine
When you read a stack trace, it helps to know a couple of terms so you don’t feel like an archaeologist who has been handed instructions in Martian.
Call stack (call stack) — the “stack” of active function calls. Imagine that main() called run(), run() called load(), load() called parse(), and then a panic happened in parse(). At that moment, the stack is the chain main → run → load → parse.
Stack frame — one “floor” in that list: a specific function and the location in the file where it is.
Stack trace — printing those stack frames to the console: function, file, and line.
Goroutine — a lightweight thread of execution in Go. Until we dive into concurrency, it’s enough to know one practical thing: in a crash message there will almost always be a line like goroutine 1 [running]. It simply means “which execution thread crashed”. In typical learning CLI programs, everything crashes in 1, and that’s normal.
2. How a stack trace is structured
Any Go stack trace is convenient to read as a document with three semantic parts. Here’s a short “decoder”, and after that you’ll start recognizing it in almost every crash.
The panic: ... message: what happened
After the panic: header you usually get the shortest description of the cause: index out of range, nil pointer dereference, divide by zero and so on. This line is useful to read, but it almost never replaces jumping to a specific line of code.
The goroutine N [state] line: where it happened
This is not “magic”, it’s context: which goroutine and in what state. For us, for now, it will almost always be goroutine 1 [running].
Stack frames: how we got here
Next comes the list of frames: function name and file:line coordinates. And here is the main practical lifehack: your goal is to find the first frame that belongs to your code, not to runtime/....
4. The main rule: first find the first line from your project
When you see a stack trace, you don’t need to read everything top to bottom like a novel. Act like an engineer: find the coordinate in your code.
A practical strategy is: look at the stack frames and search for lines that mention your file (for example, main.go, task.go, app.go) or your package. Everything like runtime/panic.go, runtime/slice.go, fmt/print.go, is system code: it’s almost never “at fault”; it’s just honestly recording where things broke in your program.
Often “your” frame will be the topmost one or one of the first. But sometimes the panic happens inside a function you called, and then your code will be on the line that calls that function.
One more important nuance: the line in the stack trace shows where the error manifested, but not always where you made the logical mistake. So after jumping to file:line, it’s useful to ask yourself: “what variable values came in here that made this possible?”.
5. Practice: the most common runtime panics
Next are typical crashes you’ll see most often. The goal here is one thing: learn to connect the panic wording and the file:line coordinates to a concrete fix in code.
index out of range: index out of bounds
Let’s start with the most popular crash.
package main
import "fmt"
func main() {
nums := []int{10, 20}
fmt.Println(nums[2]) // this will panic
}
When you run it, you’ll see something like this (the exact format may differ slightly depending on IDE/OS, but the meaning is the same):
panic: runtime error: index out of range [2] with length 2
goroutine 1 [running]:
main.main()
/path/to/project/main.go:8 +0x...
exit status 2
What matters here: the message already says index out of range, i.e., the index went past the boundaries. But the most useful part is the coordinates /path/to/project/main.go:8. That’s the “teleport” to the problem: open the file, go to line 8, and you’re at the right spot.
Remember simple math: if the length len(s) == 2, then valid indices are 0 and 1. Index 2 is already “outside, without a hat”.
How to read the call chain when there is more than one function
One file and one function is just a warm-up. In real code, a panic often happens inside a small function called by another one, which is called by a third. The stack trace then becomes truly useful: it shows the route.
Let’s make an example using a mini “training app”, say a hypothetical CLI task manager. The idea: we want to take a task by index, but we forgot to check the bounds.
package main
import "fmt"
func mustTask(tasks []string, i int) string {
return tasks[i] // this will panic if i is invalid
}
func main() {
tasks := []string{"read", "code"}
fmt.Println(mustTask(tasks, 5))
}
A typical stack trace will look like:
panic: runtime error: index out of range [5] with length 2
goroutine 1 [running]:
main.mustTask()
/path/to/project/main.go:6 +0x...
main.main()
/path/to/project/main.go:11 +0x...
How to read this: main() called mustTask(), and inside mustTask() the panic happened. So the top frame is mustTask, the bottom one is main. Now you don’t just know “it crashed”, you know “which function crashed and who called it”.
slice bounds out of range: incorrect slice bounds
Another variation is a panic when you do s[a:b] with incorrect bounds.
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
fmt.Println(s[0:5]) // panic: slice bounds out of range
}
In the stack trace you again look for the coordinates of your file and line, and almost always right on that line you can see that 5 is greater than len(s).
nil pointer dereference: dereferenced nil
The second classic is panic: runtime error: invalid memory address or nil pointer dereference. It sounds like an incantation, but it means a simple thing: you tried to access a field or method through a pointer that is nil.
package main
import "fmt"
type Config struct {
Limit int
}
func main() {
var cfg *Config
fmt.Println(cfg.Limit) // panic: cfg == nil
}
Here it’s important to connect it to what you already know: var cfg *Config does not create a Config object; it creates a pointer variable with value nil. If you wrote cfg := &Config{Limit: 10}, everything would be fine.
The mechanics of panic and the fact that deferred calls are executed while the stack unwinds are described in the article about defer/panic/recover.
integer divide by zero: division by zero
Division by zero in Go doesn’t “return an error”; it crashes with a panic (for integers). This often happens in calculations like “average”, “percentage”, “divide sum by count”.
package main
import "fmt"
func main() {
total := 10
count := 0
fmt.Println(total / count) // panic: integer divide by zero
}
In a real app, count often becomes zero due to empty input: an empty slice, an empty file, the user entered nothing, a filter filtered everything out. And here the stack trace helps not to “understand math”, but to find the specific line where you divide and ask: “what if count == 0?”.
assignment to entry in nil map: writing to a nil map
You already know maps (map), and there’s a subtle but very practical point: you can read from a nil map, but you can’t write to it. An attempt to write triggers the panic assignment to entry in nil map.
package main
import "fmt"
func main() {
var counts map[string]int
counts["go"]++ // panic: assignment to entry in nil map
fmt.Println(counts["go"])
}
Why this matters for reading stack traces: if you see this wording, the cause is almost always the same — you forgot to make(map[...]). And the coordinates in the stack trace will show where exactly you first tried to write to the map.
interface conversion: unsafe type assertion
This panic is more common when you start using any and interfaces. The point is: the expression v.(T) without checking ok can panic if the actual type is different.
package main
import "fmt"
func main() {
var v any = 10
s := v.(string) // panic: interface conversion: int is not string
fmt.Println(s)
}
In the stack trace you’ll see the line with v.(string) — and that will be an honest pointer: “here is where you were too confident about the type”. The correct style is to use the safe form s, ok := v.(string) and handle ok == false. The idea of type assertion has already appeared in the course, and later it will be covered in more depth.
6. Where a stack trace comes from: runtime panic vs panic(...) from you
It’s important to distinguish two sources of panics.
The first is a runtime panic: when Go itself crashes due to violating execution rules (out of bounds, dereferenced nil, divided by zero). The message usually contains the phrase runtime error:.
The second is your own panic("something went wrong"). It’s rarely useful (usually in “this is impossible by the program’s logic” situations), but you may see it in learning code or prototypes.
package main
func main() {
panic("config is missing") // intentional panic
}
In both cases, Go prints a stack trace, because the mechanics are the same — abnormal termination and stack unwinding.
7. What to do when you see a crash
Here is a short checklist that’s useful to keep in mind so debugging doesn’t turn into guessing.
flowchart TD
A["Program crashed: panic + stack trace"] --> B["Read the panic message (type of problem)"]
B --> C["Find the first frame from your code (your file:line)"]
C --> D["Open that line and form a hypothesis: what values got here?"]
D --> E["Check invariants: bounds, nil, zero, type"]
E --> F["Fix it: add a check + a normal behavior branch"]
This checklist is simple, but it disciplines you: you stop “fixing everything at once” and fix the specific place indicated by the coordinates.
8. defer during a panic: why it sometimes shows up in the output
defer runs when exiting a function — both on a normal return and on a panic.
During a panic, Go “unwinds” the stack: it exits the current function while executing its deferred calls, then exits the caller function executing its deferred calls, and so on until it reaches the top of the goroutine. This is exactly the mechanism that guarantees files get closed and resources get released.
Practically, that means: if you had defer fmt.Println("cleanup"), you may see these prints right before the program finally crashes. It’s not a “ghost”; it’s normal logic: a panic does not cancel defer.
9. Typical mistakes when reading a stack trace
Mistake #1: read only the panic: ... line and ignore file:line.
The panic message says “what happened”, but it doesn’t say “where exactly”. Your real anchor is the file and line coordinates. If you train yourself to jump straight to file:line, your diagnostic speed increases dramatically.
Mistake #2: drown in runtime/... and try to fix the standard library.
Almost any stack trace contains frames from runtime and sometimes fmt. That doesn’t mean Go “broke”. It means Go honestly showed where it noticed your bug. Find the first frame with your file — that’s almost always the entry point of the investigation.
Mistake #3: confuse “length” and “last index”.
len(s) is the number of elements, and the last index is len(s) - 1. The index out of range error very often happens because someone mentally treats the length “as the last index”. The stack trace will show the line, but the best fix is understanding boundaries.
Mistake #4: not seeing nil where an object “should” be.
nil pointer dereference almost always means that a pointer variable remained nil: it wasn’t initialized, or initialization happened in a branch that didn’t run. The stack trace helps you find the crash site, but then you need the habit of checking: “could it have been nil?”.
Mistake #5: treat any panic as a “normal error” and try to “silence” it.
Sometimes you just want to put recover somewhere deep and “let it not crash”. But panic is a signal that “you have a bug or an invariant was violated”, and hiding it just postpones the problem. In Go, recover is usually placed at the application boundary to turn a crash into a clean message and a log, but the bug itself still must be fixed. This idea is well reflected in discussions about handling panics at the application boundary.
GO TO FULL VERSION