Skip to content

Debug Builds

A debug build is a build made for finding mistakes.

A debug build is a build made for finding mistakes.

When you are learning Zig, most of your work should happen in debug mode. It gives you stronger safety checks, better error messages, and more useful stack traces. It is slower than an optimized release build, but that is the point. While developing, correctness matters more than speed.

Build Modes

Zig programs can be built in different optimization modes.

The main modes are:

ModeMain PurposeSafety ChecksSpeed
Debugdevelopment and debuggingmany checks enabledslowest
ReleaseSafeoptimized but still safety-orientedmany checks enabledfaster
ReleaseFastmaximum speedmany checks disabledfastest
ReleaseSmallsmall binary sizemany checks disabledoptimized for size

For beginners, use Debug unless you have a specific reason not to.

When you run:

zig test main.zig

or:

zig build-exe main.zig

Zig uses debug-style behavior by default unless you ask for an optimized mode.

Why Debug Builds Matter

Debug builds help catch mistakes early.

For example, this program reads past the end of an array:

const std = @import("std");

pub fn main() void {
    const numbers = [_]u8{ 10, 20, 30 };

    const x = numbers[5];

    std.debug.print("{}\n", .{x});
}

The array has only three items. Valid indexes are 0, 1, and 2.

In a debug build, Zig can catch this kind of mistake and stop the program instead of silently producing bad behavior.

That is exactly what you want while developing.

Debug Builds Check Integer Overflow

Integer overflow happens when a number becomes too large for its type.

Example:

const std = @import("std");

pub fn main() void {
    var x: u8 = 255;
    x += 1;

    std.debug.print("{}\n", .{x});
}

A u8 can store values from 0 to 255.

Adding 1 to 255 overflows.

In a debug build, Zig treats this as a safety problem. The program traps instead of quietly wrapping around.

That helps you find arithmetic bugs.

If you intentionally want wrapping arithmetic, Zig makes that explicit:

x +%= 1;

The +%= operator means wrapping addition assignment.

Zig’s design pushes you to write down your intent.

Debug Builds Check Invalid States

Zig has a special value called unreachable.

It means: “execution should never get here.”

Example:

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

This function only handles 0, 1, and 2.

If someone calls:

_ = digitName(9);

then the program reaches unreachable.

In a debug build, this is caught. Zig reports that the program reached code marked as impossible.

This is useful, but use unreachable carefully. It is a promise to the compiler. Only write it when the code really should be impossible.

Debug Builds Give Better Stack Traces

A stack trace shows the path of function calls that led to a crash or failure.

Example:

const std = @import("std");

fn third() void {
    unreachable;
}

fn second() void {
    third();
}

fn first() void {
    second();
}

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

The call path is:

main -> first -> second -> third

When the program fails, a debug build can show this path.

That tells you where the failure happened and how the program got there.

For beginners, stack traces are one of the most useful debugging tools. They turn “the program crashed” into “the program crashed here, through this sequence of calls.”

Debug Builds Are Slower

Debug builds do extra work.

They may check bounds, overflow, invalid enum values, invalid error states, and other safety conditions.

That extra work costs time.

So this is normal:

Debug build: easier to debug, slower to run
Release build: harder to debug, faster to run

Do not judge final performance from a debug build.

Use debug builds while writing and testing code. Use release builds when measuring performance.

Building in Debug Mode

For a single file:

zig build-exe main.zig

This is enough for normal debugging.

You can run the result:

./main

On Windows:

main.exe

For tests:

zig test main.zig

This runs tests in a debug-friendly mode by default.

Building in Release Modes

To build an optimized executable, use -O.

Example:

zig build-exe main.zig -O ReleaseFast

Other examples:

zig build-exe main.zig -O ReleaseSafe
zig build-exe main.zig -O ReleaseSmall

For tests:

zig test main.zig -O ReleaseSafe

This can be useful when you want to test behavior under an optimized but safety-checking build.

Debugging Tests

When a test fails, start with the failure message.

Example:

const std = @import("std");

fn add(a: i32, b: i32) i32 {
    return a - b;
}

test "add returns the sum" {
    try std.testing.expectEqual(@as(i32, 7), add(3, 4));
}

The function is wrong. It subtracts instead of adding.

Run:

zig test main.zig

The test failure points you to the failing expectation.

Then inspect:

try std.testing.expectEqual(@as(i32, 7), add(3, 4));

The expected result is 7. The actual result is -1.

From there, the bug is easy to find.

Add Temporary Debug Prints

You can use std.debug.print while debugging:

std.debug.print("value = {}\n", .{value});

Example:

const std = @import("std");

fn factorial(n: u32) u32 {
    var result: u32 = 1;
    var i: u32 = 1;

    while (i <= n) : (i += 1) {
        std.debug.print("i = {}, result = {}\n", .{ i, result });
        result *= i;
    }

    return result;
}

This prints the state of the loop.

Debug prints are simple and effective. Remove them when they are no longer needed.

Prefer Small Reproducible Examples

When something fails, reduce the code.

Instead of debugging a large program all at once, create a smaller example that still fails.

Large bug:

my whole parser crashes on one file

Smaller bug:

this function crashes on this 8-byte input

Small examples are easier to inspect, test, and fix.

This is especially important in Zig because many bugs involve precise values: a length, an index, an allocator, a pointer, or a boundary.

Debug Builds and Undefined Values

Zig lets you write:

var x: i32 = undefined;

This means the variable has no meaningful value yet.

You must assign to it before reading it.

Bad:

const std = @import("std");

pub fn main() void {
    var x: i32 = undefined;
    std.debug.print("{}\n", .{x});
}

Reading x before assigning a real value is a bug.

Debug builds can make such mistakes easier to notice, but the deeper rule is simpler:

Only use undefined when you are about to fully initialize the value before reading it.

For beginners, avoid undefined unless you have a clear reason.

Debug Builds and Allocators

Debugging memory bugs is easier when you use the right allocator.

In tests, use:

const allocator = std.testing.allocator;

Example:

const std = @import("std");

test "allocate and free a buffer" {
    const allocator = std.testing.allocator;

    const buffer = try allocator.alloc(u8, 100);
    defer allocator.free(buffer);

    try std.testing.expectEqual(@as(usize, 100), buffer.len);
}

The testing allocator is designed to help tests catch allocation mistakes.

The important habit is this:

Every allocation needs a matching cleanup, unless ownership is clearly transferred somewhere else.

Debug First, Optimize Later

A common beginner mistake is to optimize too early.

Do not start with:

zig build-exe main.zig -O ReleaseFast

Start with:

zig test main.zig

and:

zig build-exe main.zig

Make the program correct first.

Then measure performance.

Then optimize the specific slow part.

This order matters. Fast wrong code is still wrong code.

Practical Workflow

A good beginner workflow is:

Write a small function.

Write a unit test.

Run zig test.

Fix compile errors.

Fix test failures.

Run the program in debug mode.

Add debug prints if needed.

Only later, try release builds.

For example:

zig test main.zig
zig build-exe main.zig
./main
zig build-exe main.zig -O ReleaseSafe
zig build-exe main.zig -O ReleaseFast

Use the safe modes to find bugs. Use the fast modes to measure final performance.

The Main Idea

Debug builds are not a beginner-only feature. Experienced Zig programmers rely on them too.

They make invalid behavior visible.

They catch many mistakes near the place where the mistake happens.

They give better information when something fails.

Use debug builds as your default working mode. Treat release builds as a separate step for shipping, benchmarking, and final testing.