Skip to content

Stack Traces

A stack trace shows how your program reached a failure.

A stack trace shows how your program reached a failure.

When a program crashes, the most important question is not only “what failed?” It is also “how did the program get there?” A stack trace answers that second question.

A Simple Crash

Look at this program:

const std = @import("std");

fn third() void {
    unreachable;
}

fn second() void {
    third();
}

fn first() void {
    second();
}

pub fn main() void {
    first();
}

The call chain is:

main -> first -> second -> third

The failure happens in third, because unreachable was reached.

A stack trace helps you see that path.

What the Stack Is

When a function calls another function, Zig must remember where to return later.

For this code:

fn first() void {
    second();
}

the program enters first, then calls second.

While second runs, first is still waiting.

If second calls third, then both first and second are waiting.

This creates a stack of active function calls.

A stack trace is a printed view of that stack.

Reading a Stack Trace

A stack trace usually contains function names and source locations.

You might see information like:

third
second
first
main

Read it from the failure outward:

third failed.

second called third.

first called second.

main called first.

That tells you where to start looking.

Stack Traces and Tests

Stack traces are especially useful when a test fails deep inside helper code.

Example:

const std = @import("std");

fn requirePositive(n: i32) void {
    if (n <= 0) unreachable;
}

fn calculate(n: i32) i32 {
    requirePositive(n);
    return n * 2;
}

test "calculate doubles the input" {
    try std.testing.expectEqual(@as(i32, 10), calculate(0));
}

The test says it expects 10, but it calls calculate(0).

Inside calculate, the helper function rejects zero.

The useful question is:

Which test caused requirePositive to fail?

A stack trace points from the failure back to the test block.

Do Not Stop at the Top Frame

The first frame tells you where the crash happened.

It may not tell you where the bug is.

Example:

fn getAt(items: []const i32, index: usize) i32 {
    return items[index];
}

If this function crashes because index is out of bounds, getAt may appear at the top of the stack trace.

But the real bug may be in the caller that passed the wrong index.

So read several frames, not just the first one.

Ask:

Who called this function?

What arguments did it pass?

Which earlier decision made those arguments possible?

Stack Traces and Bounds Errors

A common beginner bug is an invalid index.

const std = @import("std");

fn last(items: []const i32) i32 {
    return items[items.len];
}

test "last returns the final item" {
    const values = [_]i32{ 10, 20, 30 };
    try std.testing.expectEqual(@as(i32, 30), last(values[0..]));
}

The valid indexes are 0, 1, and 2.

But items.len is 3.

So this line is wrong:

return items[items.len];

The last valid index is:

items.len - 1

The corrected function is:

fn last(items: []const i32) i32 {
    return items[items.len - 1];
}

A stack trace can show the failing line. Your job is to understand the logic around that line.

Stack Traces and Error Returns

Not every failure is a crash.

Sometimes a test fails because a function returned an error.

const std = @import("std");

fn parsePort(text: []const u8) !u16 {
    return try std.fmt.parseInt(u16, text, 10);
}

test "parsePort parses port numbers" {
    const port = try parsePort("abc");
    try std.testing.expectEqual(@as(u16, 8080), port);
}

Here, "abc" cannot be parsed as a number.

The test fails at:

const port = try parsePort("abc");

A stack trace or test failure output helps you locate the failing try.

Then you inspect the input.

The bug may be the function. It may also be the test. In this case, the test gives invalid input but expects success.

Add Context with Debug Prints

A stack trace gives call locations.

It does not always show the values that caused the failure.

Add temporary prints:

std.debug.print("index = {}, len = {}\n", .{ index, items.len });

Example:

const std = @import("std");

fn getAt(items: []const i32, index: usize) i32 {
    std.debug.print("index = {}, len = {}\n", .{ index, items.len });
    return items[index];
}

Now, when the program fails, you can see the bad values.

Remove temporary debug prints after fixing the bug.

Keep Functions Small

Stack traces are easier to read when functions are small.

If a stack trace points to a 200-line function, you still have a lot of work.

If it points to a 10-line function, the bug is easier to isolate.

This is a practical reason to keep code divided into small functions. Small functions make testing easier, but they also make debugging easier.

Stack Traces and unreachable

unreachable means “this code path should never happen.”

Example:

fn nameOfDigit(digit: u8) []const u8 {
    return switch (digit) {
        0 => "zero",
        1 => "one",
        2 => "two",
        else => unreachable,
    };
}

If you call:

_ = nameOfDigit(9);

then the program reaches unreachable.

A stack trace shows where the bad value came from.

But the deeper fix is often to remove the invalid assumption.

Maybe the function should return an error instead:

const DigitError = error{
    InvalidDigit,
};

fn nameOfDigit(digit: u8) ![]const u8 {
    return switch (digit) {
        0 => "zero",
        1 => "one",
        2 => "two",
        else => DigitError.InvalidDigit,
    };
}

Use unreachable only when the case truly cannot happen.

Stack Traces and Optimization

Debug builds usually give the clearest stack traces.

Optimized release builds can inline functions, remove information, or reorder code. That can make stack traces harder to read.

When debugging, start with:

zig test main.zig

or:

zig build-exe main.zig

Do not start with:

zig build-exe main.zig -O ReleaseFast

First make the bug reproducible in debug mode. Then fix it.

A Good Debugging Process

When you see a stack trace, use this process:

Read the top frame.

Find the source line.

Read the next few frames.

Identify the caller.

Inspect the arguments.

Add debug prints if needed.

Write a small test that reproduces the bug.

Fix the code.

Keep the test.

The final step matters. Once you find a bug, turn it into a test so it does not return later.

Complete Example

Save this as main.zig:

const std = @import("std");

fn getLast(items: []const i32) i32 {
    return items[items.len];
}

fn summarize(items: []const i32) i32 {
    return getLast(items);
}

test "summarize returns the last item" {
    const values = [_]i32{ 10, 20, 30 };
    try std.testing.expectEqual(@as(i32, 30), summarize(values[0..]));
}

Run:

zig test main.zig

The test fails because getLast uses an invalid index.

Fix it:

fn getLast(items: []const i32) i32 {
    return items[items.len - 1];
}

Then run the test again.

The main lesson is simple: a stack trace is not noise. It is a map. It shows the route your program took before it failed. Read it from the failure back through the callers, then use that path to find the wrong assumption.