Zig Debugging
Debugging is the process of finding and fixing problems in your program. Zig gives you multiple layers of debugging support: runtime safety checks, stack traces on panics, compile-time assertions, integration with GDB and LLDB, and the ability to add structured logging. This topic covers the practical tools and techniques you use every day.
Debug vs Release Builds
Debug build (default: zig build or zig run): ✓ Bounds checking on array/slice access ✓ Integer overflow detection ✓ Null pointer dereference detection ✓ Unreachable code detection ✓ Stack traces on panic ✓ Memory fill with 0xAA (catches use-of-uninitialized) ✗ Slower execution Release builds (zig build -Doptimize=ReleaseSafe): ✓ Optimized (fast) ✓ Safety checks still on (ReleaseSafe only) ✗ No debug symbols by default
Runtime Panics and Stack Traces
When a safety violation occurs in a debug build, Zig panics and prints a stack trace:
// This code panics in debug mode:
var arr = [_]u32{ 1, 2, 3 };
_ = arr[10]; // index 10 out of bounds for array of size 3
Output: thread 1 panic: index out of bounds: index 10, len 3 [path]/src/main.zig:5:12: 0x... in main [path]/lib/std/start.zig:...: 0x... in start
The trace shows the file, line number, and column where the panic happened. In a project, run zig build without optimization flags to keep this information.
std.debug.print — Printf-Style Debugging
const std = @import("std");
pub fn main() void {
var x: i32 = 0;
while (x < 5) : (x += 1) {
std.debug.print("x={d}\n", .{x}); // always goes to stderr
}
}
std.debug.print always writes to stderr regardless of whether your program's stdout is redirected. This prevents debug output from corrupting program output when you pipe your program's output to another command.
Compile-Time Assertions
// Crash at compile time with a clear message if condition is false
const BUFFER_SIZE = 512;
comptime {
if (BUFFER_SIZE < 64) @compileError("BUFFER_SIZE must be at least 64");
if (BUFFER_SIZE % 8 != 0) @compileError("BUFFER_SIZE must be a multiple of 8");
}
// Assert a type property at compile time
fn mustBePowerOfTwo(comptime n: usize) void {
comptime {
if (n == 0 or (n & (n - 1)) != 0) {
@compileError("argument must be a power of two");
}
}
}
mustBePowerOfTwo(16); // OK
mustBePowerOfTwo(10); // COMPILE ERROR: argument must be a power of two
std.debug.assert — Runtime Assertions
const std = @import("std");
fn divide(a: i32, b: i32) i32 {
std.debug.assert(b != 0); // panics in debug if false, removed in release
return @divTrunc(a, b);
}
pub fn main() void {
std.debug.print("{d}\n", .{divide(10, 2)}); // 5
std.debug.print("{d}\n", .{divide(10, 0)}); // PANIC: assertion failed
}
assert(condition): Debug mode: condition=false → panic with stack trace Release mode: condition removed entirely (zero cost)
Using GDB with Zig
1. Build with debug info (default):
zig build
2. Launch GDB:
gdb ./zig-out/bin/myapp
3. Common GDB commands:
run ← start the program
break main ← set breakpoint at main
break src/main.zig:15 ← breakpoint at line 15
next ← step over (one line)
step ← step into (enter function)
print x ← print variable x
info locals ← show all local variables
backtrace ← show call stack
continue ← resume execution
quit ← exit GDB
Using LLDB with Zig
lldb ./zig-out/bin/myapp Common LLDB commands: process launch ← start the program b main ← breakpoint at main n ← next line s ← step into p x ← print variable x frame variable ← show all locals thread backtrace ← show call stack c ← continue q ← quit
Valgrind — Memory Error Detection
Build first: zig build Run with Valgrind: valgrind --leak-check=full ./zig-out/bin/myapp Valgrind detects: ✓ Use-after-free ✓ Memory leaks ✓ Invalid reads/writes ✓ Use of uninitialized values
Valgrind is available on Linux. On macOS, use Xcode Instruments. On Windows, use the Visual Studio memory profiler or Dr. Memory.
Structured Logging Pattern
const std = @import("std");
const LogLevel = enum { debug, info, warn, err };
const LEVEL = LogLevel.info; // change to .debug for verbose output
fn log(level: LogLevel, comptime fmt: []const u8, args: anytype) void {
if (@intFromEnum(level) < @intFromEnum(LEVEL)) return;
const prefix = switch (level) {
.debug => "[DEBUG]",
.info => "[INFO] ",
.warn => "[WARN] ",
.err => "[ERROR]",
};
std.debug.print("{s} " ++ fmt ++ "\n", .{prefix} ++ args);
}
pub fn main() void {
log(.debug, "Starting up (x={d})", .{42}); // hidden at info level
log(.info, "Server started on port {d}", .{8080});
log(.warn, "Config file missing, using defaults", .{});
log(.err, "Database connection failed: {s}", .{"timeout"});
}
Output:
[INFO] Server started on port 8080 [WARN] Config file missing, using defaults [ERROR] Database connection failed: timeout
Finding the Source of a Bug — Methodology
Step 1: Read the panic message and stack trace
→ identifies file + line + type of violation
Step 2: Add std.debug.print statements around the panic
→ narrow down which input caused it
Step 3: Simplify the failing case
→ can you reproduce it in a small test?
Step 4: Write a test that captures the bug
→ test "bug reproduction" { ... }
Step 5: Fix the code until the test passes
Step 6: Keep the test to prevent regression
The built-in safety checks — bounds checking, overflow detection, null dereference detection — do most of the work of finding bugs in debug builds. Enable them by using debug mode during development and only switching to release builds for performance measurement or deployment.
