Overview
Finishing our style tune-up made it clear that invariants are worthless unless they fail loudly (36). This chapter explains how Zig formalizes those failures as illegal behavior and how the toolchain catches most of them before they corrupt state. #illegal behavior
Next we will dive into command-line tooling, so we want our runtime guardrails in place before scripting toggles optimization modes on our behalf. 38
Learning Goals
- Distinguish between safety-checked and unchecked categories of illegal behavior.
- Inspect the active optimization mode and reason about which runtime checks Zig will emit.
- Build contracts around
@setRuntimeSafety,unreachable, andstd.debug.assertto keep invariants provable in every build.
Refs: 4
Illegal Behavior in Zig
Illegal behavior is Zig’s umbrella term for operations the language refuses to define, ranging from integer overflow to dereferencing an invalid pointer. We have already relied on bounds checks for slices and optionals; this section consolidates those rules so the upcoming CLI work inherits a predictable failure story. 3
Safety-Checked vs Unchecked Paths
Safety-checked illegal behavior covers the cases the compiler can instrument at runtime (overflow, sentinel mismatches, wrong-union-field access), while unchecked cases remain invisible to safety instrumentation (aliasing through the wrong pointer type, layout violations from foreign code).
Debug and ReleaseSafe keep the guards on by default. ReleaseFast and ReleaseSmall assume you traded those traps for performance, so anything that slips past your invariants becomes undefined in practice.
Example: Guarding Unchecked Arithmetic
The following helper proves an addition safe with @addWithOverflow, then disables runtime safety for the final + to avoid redundant checks while saturating pathological inputs to the type’s maximum. #setruntimesafety
const std = @import("std");
/// Performs addition with overflow detection and saturation.
/// If overflow occurs, returns the maximum u8 value instead of wrapping.
/// Uses @setRuntimeSafety(false) in the non-overflow path for performance.
fn guardedUncheckedAdd(a: u8, b: u8) u8 {
// Check if addition would overflow using builtin overflow detection
const sum = @addWithOverflow(a, b);
const overflow = sum[1] == 1;
// Saturate to max value on overflow
if (overflow) return std.math.maxInt(u8);
// Safe path: disable runtime safety checks for this addition
// since we've already verified no overflow will occur
return blk: {
@setRuntimeSafety(false);
break :blk a + b;
};
}
/// Performs addition without runtime safety checks.
/// This allows the operation to wrap on overflow (undefined behavior in safe mode).
/// Demonstrates completely disabling safety for a function scope.
fn wrappingAddUnsafe(a: u8, b: u8) u8 {
// Disable all runtime safety checks for this entire function
@setRuntimeSafety(false);
return a + b;
}
// Verifies that guardedUncheckedAdd correctly handles both normal addition
// and overflow saturation scenarios.
test "guarded unchecked addition saturates on overflow" {
// Normal case: 120 + 80 = 200 (no overflow)
try std.testing.expectEqual(@as(u8, 200), guardedUncheckedAdd(120, 80));
// Overflow case: 240 + 30 = 270 > 255, should saturate to 255
try std.testing.expectEqual(std.math.maxInt(u8), guardedUncheckedAdd(240, 30));
}
// Demonstrates that wrappingAddUnsafe produces the same wrapped result
// as @addWithOverflow when overflow occurs.
test "wrapping addition mirrors overflow tuple" {
// @addWithOverflow returns [wrapped_result, overflow_bit]
const sum = @addWithOverflow(@as(u8, 250), @as(u8, 10));
// Verify overflow occurred (250 + 10 = 260 > 255)
try std.testing.expect(sum[1] == 1);
// Verify wrapped result matches unchecked addition (260 % 256 = 4)
try std.testing.expectEqual(sum[0], wrappingAddUnsafe(250, 10));
}
$ zig test 01_guarded_runtime_safety.zigAll 2 tests passed.Running the same test with -OReleaseFast verifies that the guard continues to saturate rather than panic even when global runtime safety is absent.
Safety Defaults by Optimization Mode
The current optimization mode is exposed as @import("builtin").mode, making it easy to surface which runtime checks will exist in a given artifact without consulting build scripts. #compile variables The table below summarizes the default contract each mode offers before you start opting in or out of checks manually.
| Mode | Runtime safety | Typical intent |
|---|---|---|
| Debug | Enabled | Development builds with maximum diagnostics and stack traces. |
| ReleaseSafe | Enabled | Production builds that still prefer predictable traps over silent corruption. |
| ReleaseFast | Disabled | High-performance binaries that assume invariants are already proven elsewhere. |
| ReleaseSmall | Disabled | Size-constrained deliverables where every injected trap would be a liability. |
Instrumenting Safety at Runtime
This probe prints the active mode and the implied safety default, then compares a checked addition with an unchecked one so you can see what survives when checks vanish.
const std = @import("std");
const builtin = @import("builtin");
// Extract the compile-time type of the optimization mode enum
const ModeType = @TypeOf(builtin.mode);
/// Captures both the active optimization mode and its default safety behavior
const ModeInfo = struct {
mode: ModeType,
safety_default: bool,
};
/// Determines whether runtime safety checks are enabled by default for a given mode.
/// Debug and ReleaseSafe modes retain safety checks; ReleaseFast and ReleaseSmall disable them.
fn defaultSafety(mode: ModeType) bool {
return switch (mode) {
// These modes prioritize correctness with runtime checks
.Debug, .ReleaseSafe => true,
// These modes prioritize performance/size by removing checks
.ReleaseFast, .ReleaseSmall => false,
};
}
/// Performs checked addition that detects overflow without panicking.
/// Returns both the wrapped result and an overflow flag.
fn sampleAdd(a: u8, b: u8) struct { result: u8, overflowed: bool } {
// @addWithOverflow returns a tuple: [wrapped_result, overflow_bit]
const pair = @addWithOverflow(a, b);
return .{ .result = pair[0], .overflowed = pair[1] == 1 };
}
/// Performs unchecked addition by explicitly disabling runtime safety.
/// In Debug/ReleaseSafe, this avoids the panic on overflow.
/// In ReleaseFast/ReleaseSmall, the safety was already off, so this is redundant but harmless.
fn uncheckedAddStable(a: u8, b: u8) u8 {
return blk: {
// Temporarily disable runtime safety for this block only
@setRuntimeSafety(false);
// Raw addition without overflow checks; wraps silently on overflow
break :blk a + b;
};
}
pub fn main() void {
// Capture the current build mode and its implied safety setting
const info = ModeInfo{
.mode = builtin.mode,
.safety_default = defaultSafety(builtin.mode),
};
// Report which optimization mode the binary was compiled with
std.debug.print("optimize-mode: {s}\n", .{@tagName(info.mode)});
// Show whether runtime safety is on by default in this mode
std.debug.print("runtime-safety-default: {}\n", .{info.safety_default});
// Demonstrate checked addition that reports overflow without crashing
const checked = sampleAdd(200, 80);
std.debug.print("checked-add result={d} overflowed={}\n", .{ checked.result, checked.overflowed });
// Demonstrate unchecked addition that wraps silently (24 = (200+80) % 256)
const unchecked = uncheckedAddStable(200, 80);
std.debug.print("unchecked-add result={d}\n", .{unchecked});
}
$ zig run 02_mode_probe.zigoptimize-mode: Debug
runtime-safety-default: true
checked-add result=24 overflowed=true
unchecked-add result=24Re-run the probe with -OReleaseFast to watch the default safety flag flip to false while the checked path still reports the overflow, helping you document feature flags or telemetry you might need in release builds.
Contracts, Panics, and Recovery
Stack traces are calmly terrifying when you trigger unreachable in a safety-enabled build. Treat them as the last line of defense after assertions and error unions have exhausted graceful exits. #reaching unreachable code
Pairing that discipline with the error-handling techniques from earlier chapters keeps failure modes debuggable without sacrificing determinism. 4
Example: Asserting Digit Conversion
Here we document an ASCII digit contract twice: once with an assertion that unlocks unchecked math and once with an error union for caller-friendly validation. debug.zig
// This file demonstrates different safety modes in Zig and how to handle
// conversions with varying levels of runtime checking.
const std = @import("std");
/// Converts an ASCII digit character to its numeric value without runtime safety checks.
/// This function uses an assert to document the precondition that the input must be
/// a valid ASCII digit ('0'-'9'). The @setRuntimeSafety(false) directive disables
/// runtime integer overflow checks for the subtraction and cast operations.
///
/// Precondition: byte must be in the range ['0', '9']
/// Returns: The numeric value (0-9) as a u4
pub fn asciiDigitToValueUnchecked(byte: u8) u4 {
// Assert documents the contract: caller must provide a valid ASCII digit
std.debug.assert(byte >= '0' and byte <= '9');
// Block with runtime safety disabled for performance-critical paths
return blk: {
// Disable runtime overflow/underflow checks for this conversion
@setRuntimeSafety(false);
// Safe cast because precondition guarantees result fits in u4 (0-9)
break :blk @intCast(byte - '0');
};
}
/// Converts an ASCII digit character to its numeric value with error handling.
/// This function validates the input at runtime and returns an error if the
/// byte is not a valid ASCII digit, making it safe to use with untrusted input.
///
/// Returns: The numeric value (0-9) as a u4, or error.InvalidDigit if invalid
pub fn asciiDigitToValue(byte: u8) !u4 {
// Validate input is within valid ASCII digit range
if (byte < '0' or byte > '9') return error.InvalidDigit;
// Safe cast: validation ensures result is in range 0-9
return @intCast(byte - '0');
}
// Verifies that the unchecked conversion produces correct results for all valid inputs.
// Tests all ASCII digits to ensure the assert-backed function maintains correctness
// even when runtime safety is disabled internally.
test "assert-backed conversion stays safe across modes" {
// Iterate over all valid ASCII digit characters at compile time
inline for ("0123456789") |ch| {
// Verify unchecked function produces same result as direct conversion
try std.testing.expectEqual(@as(u4, @intCast(ch - '0')), asciiDigitToValueUnchecked(ch));
}
}
// Verifies that the error-returning conversion properly rejects invalid input.
// Ensures that error handling path works correctly and provides meaningful diagnostics.
test "error path preserves diagnosability" {
// Verify that non-digit characters return the expected error
try std.testing.expectError(error.InvalidDigit, asciiDigitToValue('z'));
}
$ zig test 03_unreachable_contract.zigAll 2 tests passed.The assertion-backed path compiles to a single subtraction in ReleaseFast, but it still panics in Debug if you pass a non-digit. Keep a defensive error-returning variant around for untrusted data.
Notes & Caveats
- Even in Debug mode, some pointer-based mistakes stay unchecked. Prefer slice-based APIs when you need bounds enforcement.
- Narrow the scope of
@setRuntimeSafety(false)to the smallest possible block and prove the preconditions before toggling it. - Capture panic stack traces in development and ship symbol files if you expect to triage ReleaseSafe crashes later.
Exercises
- Extend
guardedUncheckedAddto emit diagnostics when a sentinel-terminated slice would overflow the destination buffer, then measure the difference between safety-on and safety-off builds. #sentinel terminated arrays - Write a benchmarking harness that loops through millions of safe additions, toggling
@setRuntimeSafetyper iteration to confirm the cost of the guard in each mode. - Enhance the mode probe to log build metadata in your upcoming CLI project so scripts can warn when ReleaseFast binaries omit traps. 38
Alternatives & Edge Cases
- Failing to switch from
+to@addWithOverflowin ReleaseFast risks silent wraparound that only manifests under rare load patterns. - Runtime safety does not defend against concurrent data races. Pair these tools with the synchronization primitives introduced later in the book.
- When calling into C code, remember that Zig’s checks stop at the FFI boundary. Validate foreign inputs before trusting invariants. 33