An assertion is a check that must be true while the program runs.
In Zig, assertions are commonly written with std.debug.assert:
const std = @import("std");
pub fn main() void {
const x = 10;
std.debug.assert(x > 0);
}This says:
x must be greater than zeroIf the condition is true, the program continues.
If the condition is false, the program stops.
Why Assertions Exist
Assertions help you catch wrong assumptions.
Suppose you write a function that expects a non-empty slice:
fn first(items: []const i32) i32 {
return items[0];
}This function crashes if items is empty.
You can make the assumption explicit:
const std = @import("std");
fn first(items: []const i32) i32 {
std.debug.assert(items.len > 0);
return items[0];
}Now the function clearly says:
this function expects at least one itemThat is better than hiding the assumption inside items[0].
Assertions Are for Programmer Mistakes
Use assertions for bugs in your own code.
Do not use assertions for normal user input.
Bad:
fn parseAge(text: []const u8) u8 {
std.debug.assert(text.len > 0);
// parse user input
}User input can be wrong. That is normal.
Use an error for that:
const ParseError = error{
EmptyInput,
};
fn parseAge(text: []const u8) !u8 {
if (text.len == 0) return ParseError.EmptyInput;
return try std.fmt.parseInt(u8, text, 10);
}Rule:
Use errors for expected failure.
Use assertions for impossible or invalid internal states.
Assertions Document Preconditions
A precondition is something that must be true before a function is called.
Example:
const std = @import("std");
fn divide(a: i32, b: i32) i32 {
std.debug.assert(b != 0);
return @divTrunc(a, b);
}The precondition is:
b must not be zeroThe assertion makes that rule visible.
But for public APIs, consider returning an error instead:
const MathError = error{
DivideByZero,
};
fn divide(a: i32, b: i32) !i32 {
if (b == 0) return MathError.DivideByZero;
return @divTrunc(a, b);
}Assertions are best when the caller is your own code and violating the rule means a programming bug.
Assertions Document Invariants
An invariant is something that should remain true while a value is valid.
Example:
const std = @import("std");
const Range = struct {
start: i32,
end: i32,
fn len(self: Range) i32 {
std.debug.assert(self.end >= self.start);
return self.end - self.start;
}
};The invariant is:
end must be greater than or equal to startIf Range ever has end < start, the program has created an invalid Range.
The assertion catches that mistake near the place where the invalid state is used.
Assertions Help During Refactoring
Refactoring means changing the structure of code without changing its behavior.
Assertions are useful during refactoring because they protect assumptions.
Example:
const std = @import("std");
fn indexOfNeedle(haystack: []const u8, needle: u8) ?usize {
for (haystack, 0..) |ch, i| {
if (ch == needle) return i;
}
return null;
}
fn byteAfterNeedle(haystack: []const u8, needle: u8) ?u8 {
const index = indexOfNeedle(haystack, needle) orelse return null;
std.debug.assert(index < haystack.len);
if (index + 1 >= haystack.len) return null;
return haystack[index + 1];
}The assertion says that indexOfNeedle must only return a valid index.
If a future change breaks indexOfNeedle, the assertion can catch it.
Assertion Failure Means Fix the Code
When an assertion fails, do not handle it like normal failure.
An assertion failure means your program reached a state that the programmer said should never happen.
The right response is usually:
find the wrong assumption
fix the code
add a test
For example:
fn first(items: []const i32) i32 {
std.debug.assert(items.len > 0);
return items[0];
}If this assertion fails, ask:
Why did the caller pass an empty slice?
Should empty slices be allowed?
Should the function return an error or optional instead?
Maybe the better API is:
fn first(items: []const i32) ?i32 {
if (items.len == 0) return null;
return items[0];
}The assertion helped reveal an API design problem.
Assertions and Tests
Assertions work well with tests.
Example:
const std = @import("std");
fn halfEven(n: i32) i32 {
std.debug.assert(n % 2 == 0);
return @divTrunc(n, 2);
}
test "halfEven divides even numbers by two" {
try std.testing.expectEqual(@as(i32, 5), halfEven(10));
}Do not write a normal success test with invalid input:
test "halfEven handles odd numbers" {
try std.testing.expectEqual(@as(i32, 2), halfEven(5));
}That test contradicts the function contract.
Instead, decide whether odd input should be impossible or supported.
If it should be supported, change the function:
const MathError = error{
NotEven,
};
fn halfEven(n: i32) !i32 {
if (n % 2 != 0) return MathError.NotEven;
return @divTrunc(n, 2);
}Then test the error:
test "halfEven rejects odd numbers" {
try std.testing.expectError(MathError.NotEven, halfEven(5));
}Assertions Are Not Input Validation
This distinction is important.
Input validation handles data from outside the program:
command-line arguments
files
network requests
user text
environment variables
database rows
For those, use errors, optionals, or explicit validation.
Assertions handle programmer assumptions inside the program.
Example:
fn setCapacity(capacity: usize) void {
std.debug.assert(capacity > 0);
}This may be fine if capacity is calculated by trusted internal code.
But if capacity comes from a config file, do this instead:
const ConfigError = error{
InvalidCapacity,
};
fn setCapacity(capacity: usize) !void {
if (capacity == 0) return ConfigError.InvalidCapacity;
}External data can be invalid. That is not a programmer bug.
Assertions and Optimization
Assertions are mainly a debugging tool.
In optimized release builds, safety and debug behavior may differ depending on the build mode and compiler settings. Do not rely on assertions for security, validation, or required runtime behavior.
This is why the rule matters:
If the program must handle the case correctly in production, use real control flow.
Use this for required behavior:
if (items.len == 0) return null;Use this for internal assumptions:
std.debug.assert(items.len > 0);Good Assertions Are Specific
Weak:
std.debug.assert(ok);Better:
std.debug.assert(index < items.len);The second assertion tells the reader exactly what must be true.
Good assertions usually mention concrete variables and boundaries:
std.debug.assert(count <= capacity);
std.debug.assert(index < items.len);
std.debug.assert(self.end >= self.start);
std.debug.assert(ptr != null);Specific assertions make debugging easier.
Do Not Overuse Assertions
Assertions should clarify important assumptions.
Too many assertions can make code noisy.
For example, this is excessive:
std.debug.assert(x == x);
std.debug.assert(items.len >= 0);items.len is a usize, so it cannot be negative.
That assertion adds no value.
Use assertions where a bug is plausible and the rule matters.
A Complete Example
Here is a small stack type:
const std = @import("std");
const Stack = struct {
data: []i32,
len: usize,
fn init(buffer: []i32) Stack {
return .{
.data = buffer,
.len = 0,
};
}
fn push(self: *Stack, value: i32) void {
std.debug.assert(self.len < self.data.len);
self.data[self.len] = value;
self.len += 1;
}
fn pop(self: *Stack) i32 {
std.debug.assert(self.len > 0);
self.len -= 1;
return self.data[self.len];
}
};
test "Stack pushes and pops values" {
var buffer: [4]i32 = undefined;
var stack = Stack.init(buffer[0..]);
stack.push(10);
stack.push(20);
try std.testing.expectEqual(@as(i32, 20), stack.pop());
try std.testing.expectEqual(@as(i32, 10), stack.pop());
}The assertions say:
push requires free capacity
pop requires at least one item
For a small internal data structure, that can be reasonable.
For a public API, you might return errors instead:
const StackError = error{
Full,
Empty,
};Then push could return StackError.Full, and pop could return StackError.Empty.
The Main Idea
Assertions are executable assumptions.
They make hidden rules visible.
Use them when a condition should always be true if the program is correct. Use errors, optionals, or validation when failure is part of normal program behavior.