Learn Zig Series (#24) - Logging, Formatting, and Debug Output
Learn Zig Series (#24) - Logging, Formatting, and Debug Output

What will I learn
- You will learn
std.logfor structured logging with compile-time log levels; - You will learn custom log functions and scoped loggers;
- You will learn
std.fmtformat specifiers:{s},{d},{x},{any}and more; - You will learn writing custom format functions for your types with
pub fn format(); - You will learn
std.debug.printfor quick debug output; - You will learn compile-time format string validation;
- You will learn formatting numbers with padding, precision, alignment, hex, and binary output.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output (this post)
Learn Zig Series (#24) - Logging, Formatting, and Debug Output
Welcome back! In episode 23 we built iterators and lazy evaluation pipelines -- custom next() ?T methods, map/filter/take adapters, the whole works. I asked you to build a Countdown + Zip iterator, a chunk iterator, and a flatten iterator. Let's look at those solutions first, and then we're moving on to something you'll use in literally every Zig project you ever write: logging, formatting, and debug output.
Because here's the thing -- you can write the most elegant data structures and the cleverest algorithms, but if you can't inspect what's happening at runtime, you're flying blind. Every real program needs to print values, log events, and format output for both humans and machines. Zig's standard library gives you some seriously powerful tools for this, and a lot of it happens at compile time. Here we go!
Solutions to Episode 23 Exercises
Exercise 1 -- Countdown iterator + Zip adapter:
const std = @import("std");
const Countdown = struct {
current: i32,
fn init(start: i32) Countdown {
return .{ .current = start };
}
fn next(self: *Countdown) ?i32 {
if (self.current < 0) return null;
const val = self.current;
self.current -= 1;
return val;
}
};
const Range = struct {
current: i32,
end: i32,
fn init(start: i32, e: i32) Range {
return .{ .current = start, .end = e };
}
fn next(self: *Range) ?i32 {
if (self.current >= self.end) return null;
const val = self.current;
self.current += 1;
return val;
}
};
fn ZipIterator(comptime A: type, comptime B: type, comptime T: type, comptime U: type) type {
return struct {
const Self = @This();
const Pair = struct { first: T, second: U };
iter_a: A,
iter_b: B,
fn next(self: *Self) ?Pair {
const a = self.iter_a.next() orelse return null;
const b = self.iter_b.next() orelse return null;
return .{ .first = a, .second = b };
}
};
}
fn zip(comptime T: type, comptime U: type, a: anytype, b: anytype) ZipIterator(@TypeOf(a), @TypeOf(b), T, U) {
return .{ .iter_a = a, .iter_b = b };
}
pub fn main() !void {
var zipped = zip(i32, i32, Range.init(0, 5), Countdown.init(4));
while (zipped.next()) |pair| {
std.debug.print("({d}, {d}) ", .{ pair.first, pair.second });
}
std.debug.print("\n", .{});
// Output: (0, 4) (1, 3) (2, 2) (3, 1) (4, 0)
}
Countdown is just a Range in reverse with no step needed -- decrement by 1 until we go below 0. The Zip adapter holds two inner iterators and calls next() on both, stopping as soon as either one returns null. This is the shortest iterator in the set, but it's one of the most useful in practice.
Exercise 2 -- ChunkIterator over slices:
const std = @import("std");
fn ChunkIterator(comptime T: type) type {
return struct {
const Self = @This();
data: []const T,
chunk_size: usize,
pos: usize,
fn init(data: []const T, chunk_size: usize) Self {
return .{ .data = data, .chunk_size = chunk_size, .pos = 0 };
}
fn next(self: *Self) ?[]const T {
if (self.pos >= self.data.len) return null;
const end = @min(self.pos + self.chunk_size, self.data.len);
const chunk = self.data[self.pos..end];
self.pos = end;
return chunk;
}
};
}
pub fn main() !void {
const data = "abcdefgh";
var chunks = ChunkIterator(u8).init(data, 3);
while (chunks.next()) |chunk| {
std.debug.print("'{s}' (len={d})\n", .{ chunk, chunk.len });
}
// Output: 'abc' (len=3), 'def' (len=3), 'gh' (len=2)
}
The key insight is using @min to handle the last chunk -- when there aren't enough elements left to fill a full chunk, we just return whatever remains. No allocations, no copies -- each chunk is a slice into the original data.
Exercise 3 -- FlattenIterator:
const std = @import("std");
fn FlattenIterator(comptime Inner: type) type {
return struct {
const Self = @This();
inner: Inner,
current_slice: ?[]const u8,
pos: usize,
fn init(inner: Inner) Self {
return .{ .inner = inner, .current_slice = null, .pos = 0 };
}
fn next(self: *Self) ?u8 {
while (true) {
if (self.current_slice) |slice| {
if (self.pos < slice.len) {
const val = slice[self.pos];
self.pos += 1;
return val;
}
// Current slice exhausted, get next one
self.current_slice = null;
}
// Get next slice from inner iterator
self.current_slice = self.inner.next() orelse return null;
self.pos = 0;
}
}
};
}
const SliceIterator = struct {
slices: []const []const u8,
index: usize,
fn init(slices: []const []const u8) SliceIterator {
return .{ .slices = slices, .index = 0 };
}
fn next(self: *SliceIterator) ?[]const u8 {
if (self.index >= self.slices.len) return null;
const slice = self.slices[self.index];
self.index += 1;
return slice;
}
};
pub fn main() !void {
const strings = [_][]const u8{ "hello", "world", "zig" };
var flat = FlattenIterator(SliceIterator).init(SliceIterator.init(&strings));
while (flat.next()) |byte| {
std.debug.print("{c}", .{byte});
}
std.debug.print("\n", .{});
// Output: helloworldzig
}
Flatten is the trickiest of the three. It maintains two levels of state: which slice we're currently iterating through, and our position within that slice. When the current slice runs out, we grab the next one from the inner iterator. When the inner iterator is exhausted, we're done. This is exactly how itertools.chain.from_iterable works in Python, except we built it from scratch in about 30 lines.
Alright, on to today's topic ;-)
std.debug.print -- your first debugging tool
Let's start with the function you've been using since episode 2 without thinking too much about it: std.debug.print. This function writes directly to stderr and is meant purely for debugging. It's the Zig equivalent of Python's print() or C's printf(), except it goes to stderr (not stdout) and it gets stripped out of ReleaseFast and ReleaseSmall builds entirely.
const std = @import("std");
pub fn main() !void {
const name = "Zig";
const version: u32 = 14;
const pi: f64 = 3.14159;
// Basic debug printing
std.debug.print("Hello {s}!\n", .{name});
std.debug.print("Version: {d}\n", .{version});
std.debug.print("Pi is approximately {d:.4}\n", .{pi});
// Multiple arguments
std.debug.print("{s} version {d} -- pi = {d:.2}\n", .{ name, version, pi });
// Print without arguments (note the empty tuple)
std.debug.print("Just a plain message\n", .{});
}
A few things to note. First, std.debug.print takes a comptime-known format string and a tuple of arguments. The format string is validated at compile time -- if you write {d} but pass a string, you get a compile error, not a runtime crash. Second, the .{} syntax is an anonymous struct literal (a tuple, effectively). Even when you have zero arguments, you still pass the empty tuple .{}. Forgetting that is a classic beginner mistake.
Third -- and this is important -- std.debug.print writes to stderr, not stdout. This means if you pipe your program's output somewhere, debug prints won't contaminate it. It also means std.debug.print is NOT for user-facing output. For that, you'd use std.io.getStdOut().writer() to write to stdout explicitly. We'll see that distinction matter more when we get to the logging section.
Format specifiers: {s}, {d}, {x}, {any} and the rest
Zig's std.fmt module handles all formatting, and it supports a rich set of format specifiers. If you've used Python's str.format() or Rust's format! macro, you'll recognise the general pattern -- curly braces with an optional specifier inside:
const std = @import("std");
pub fn main() !void {
const num: i32 = 255;
const float_val: f64 = 3.14159265;
const text = "hello";
const flag = true;
const ptr: *const i32 = #
// {d} -- decimal integer
std.debug.print("Decimal: {d}\n", .{num});
// {x} -- lowercase hex
std.debug.print("Hex: {x}\n", .{num});
// {X} -- uppercase hex
std.debug.print("Hex upper: {X}\n", .{num});
// {o} -- octal
std.debug.print("Octal: {o}\n", .{num});
// {b} -- binary
std.debug.print("Binary: {b}\n", .{num});
// {d:.N} -- float with N decimal places
std.debug.print("Float: {d:.4}\n", .{float_val});
std.debug.print("Float (2): {d:.2}\n", .{float_val});
// {e} -- scientific notation
std.debug.print("Scientific: {e}\n", .{float_val});
// {s} -- string (slices)
std.debug.print("String: {s}\n", .{text});
// {any} -- debug format for ANY type
std.debug.print("Bool (any): {any}\n", .{flag});
std.debug.print("Ptr (any): {any}\n", .{ptr});
// {c} -- single character (u8)
std.debug.print("Char: {c}\n", .{@as(u8, 'Z')});
}
The {any} specifier is your Swiss Army knife. It works for every type and produces a reasonable debug representation. For structs, it prints all fields. For slices, it shows the elements. For enums, it shows the tag name. It's not pretty and it's not configurable, but when you just need to see what a value looks like, {any} is your friend.
One specifier you'll use constantly: {s} for byte slices and string literals. In Zig, strings are []const u8, and {s} is the way to print them as text rather than as a sequence of numeric byte values (which is what {any} would give you). Using {d} on a string slice gives a compile error -- the format system knows a []const u8 is not an integer.
Padding, alignment, and fill characters
Here's where formatting gets interesting. You can control the width, alignment, and fill character of any formatted value. This is incredibly useful for printing tables, aligned output, and padded numbers:
const std = @import("std");
pub fn main() !void {
// Width: minimum number of characters
std.debug.print("[{d:>10}]\n", .{@as(i32, 42)}); // right-aligned
std.debug.print("[{d:<10}]\n", .{@as(i32, 42)}); // left-aligned
std.debug.print("[{d:^10}]\n", .{@as(i32, 42)}); // center-aligned
// Fill character (before the alignment specifier)
std.debug.print("[{d:0>8}]\n", .{@as(i32, 42)}); // zero-padded
std.debug.print("[{d:.>8}]\n", .{@as(i32, 42)}); // dot-padded
std.debug.print("[{d:-<8}]\n", .{@as(i32, 42)}); // dash-padded right
// Strings with padding
std.debug.print("[{s:.<20}]\n", .{"hello"}); // dot-fill right
std.debug.print("[{s:.>20}]\n", .{"hello"}); // dot-fill left
// Hex with zero padding (common for addresses)
std.debug.print("0x{x:0>8}\n", .{@as(u32, 0xDEAD)});
// Practical: aligned table
std.debug.print("\n", .{});
const items = [_]struct { name: []const u8, price: u32 }{
.{ .name = "Widget", .price = 1299 },
.{ .name = "Gadget", .price = 459 },
.{ .name = "Thingamajig", .price = 24999 },
};
std.debug.print("{s:<15} {s:>10}\n", .{ "Item", "Price" });
std.debug.print("{s:-<15} {s:->10}\n", .{ "", "" });
for (items) |item| {
std.debug.print("{s:<15} {d:>10}\n", .{ item.name, item.price });
}
}
The format spec syntax is: {[specifier]:[fill][alignment][width][.precision]}. Fill character goes first, then alignment (< left, > right, ^ center), then width (minimum characters), then precision (for floats). This is modeled after Python's format mini-language, so if you know Python (and after our Learn Python Series, you should ;-) ) the syntax will feel fammiliar.
Zero-padded hex is probably the most common practical use case. When you're debugging memory addresses, register values, or protocol bytes, 0x{x:0>8} gives you the consistent 0x0000DEAD format that's easy to scan visually.
Writing to stdout with std.io.getStdOut()
std.debug.print is for debugging. For actual program output -- things the user is supposed to see -- you write to stdout explicitly:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// write() for raw bytes
try stdout.writeAll("Hello from stdout!\n");
// print() for formatted output (same format syntax as debug.print)
try stdout.print("The answer is {d}\n", .{42});
try stdout.print("{s}: {d:.3} seconds\n", .{ "Elapsed", 1.337 });
// Buffered writing for performance
var bw = std.io.bufferedWriter(stdout);
const writer = bw.writer();
for (0..100) |i| {
try writer.print("Line {d:0>3}: some data here\n", .{i});
}
try bw.flush(); // don't forget to flush!
}
The writer() interface returns a Writer struct that has print, writeAll, writeByte, and other methods. All of them can fail (disk full, pipe broken, etc.) so they return errors that you need to handle with try. This is different from std.debug.print which is void -- debug output is fire-and-forget.
For programs that produce a lot of output, wrapping the writer in std.io.bufferedWriter reduces system calls dramatically. In stead of one write() syscall per line, the buffered writer accumulates data in an internal buffer and flushes it in larger chunks. Just remember to call flush() at the end (or the last chunk of data might stay in the buffer and never reach stdout).
std.log -- structured logging
Now we get to the real logging system. std.debug.print is fine for printf-debugging, but for production software you want structured logging with severity levels that can be filtered at compile time. Zig's std.log gives you exactly that:
const std = @import("std");
// The default log function uses std.log.default_log
const log = std.log;
pub fn main() !void {
log.info("Application started", .{});
log.debug("Loading configuration from {s}", .{"config.json"});
log.warn("Configuration file not found, using defaults", .{});
log.err("Failed to connect to database: {s}", .{"connection refused"});
}
There are four log levels, from least to most severe: debug, info, warn, err. By default, debug messages are suppressed in non-debug builds. The log output includes the scope (the module that produced the message) and the level, which makes it easy to grep through logs.
The really powerful thing is that log levels are resolved at compile time. A log.debug() call in a release build doesn't just skip printing -- the entire call is removed from the binary. Zero runtime cost. No "is this level enabled?" check at runtime. The compiler sees that the level is below the threshold and eliminates the code entirely. This is fundamentally different from how logging works in Python (logging.debug() still evaluates its arguments at runtime even if the level is disabled) or Java (where you guard with if (logger.isDebugEnabled())).
Custom log scope
You can override the default log scope to identify where messages come from. This is invaluable in larger programs with multiple modules:
const std = @import("std");
// Create a scoped logger for this module
const log = std.log.scoped(.database);
pub fn connect(host: []const u8, port: u16) !void {
log.info("Connecting to {s}:{d}", .{ host, port });
// ... connection logic ...
log.debug("TCP handshake complete", .{});
}
pub fn main() !void {
log.info("Database module starting", .{});
try connect("localhost", 5432);
}
The .database is a comptime enum literal that tags every log message from this module. In the output you'll see something like info(database): Connecting to localhost:5432. When you have 20 modules all logging simultaneuosly, those scope tags save your sanity.
Overriding the log function
The default log function writes to stderr in a specific format. You can override it completely by defining a pub const std_options in your root source file:
const std = @import("std");
const log = std.log.scoped(.app);
pub const std_options: std.Options = .{
.log_level = .debug, // show ALL levels (default filters out debug)
.logFn = customLog,
};
fn customLog(
comptime level: std.log.Level,
comptime scope: @TypeOf(.enum_literal),
comptime format: []const u8,
args: anytype,
) void {
const level_txt = comptime switch (level) {
.err => "ERROR",
.warn => "WARN ",
.info => "INFO ",
.debug => "DEBUG",
};
const scope_txt = if (@tagName(scope).len > 0)
"[" ++ @tagName(scope) ++ "] "
else
"";
const stderr = std.io.getStdErr().writer();
nosuspend stderr.print("{s} {s}{s}\n", .{
level_txt,
scope_txt,
std.fmt.comptimePrint(format, args),
}) catch {};
}
pub fn main() !void {
log.info("Custom logger active", .{});
log.debug("This debug message is now visible", .{});
log.warn("Something looks off", .{});
log.err("Critical failure", .{});
}
By overriding logFn, you can add timestamps, write to files, send logs over the network, format as JSON -- whatever your application needs. The signature is fixed (four parameters: level, scope, format, args), and the function must be fn not *const fn -- it's resolved at comptime, not a function pointer.
Having said that, for most programs the default logger is perfectly fine. Override it when you have specific output requirements (JSON structured logs for a container environment, for example), not just because you can.
Writing custom format functions for your types
Here's one of the most useful features of std.fmt: you can make any struct formattable by implementing a pub fn format() method. When you pass your struct to a format string with {any} or {}, Zig calls your custom format function:
const std = @import("std");
const Color = struct {
r: u8,
g: u8,
b: u8,
pub fn format(
self: Color,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = options;
if (fmt.len == 0 or comptime std.mem.eql(u8, fmt, "any")) {
// Default: rgb(R, G, B)
try writer.print("rgb({d}, {d}, {d})", .{ self.r, self.g, self.b });
} else if (comptime std.mem.eql(u8, fmt, "x")) {
// Hex: #RRGGBB
try writer.print("#{x:0>2}{x:0>2}{x:0>2}", .{ self.r, self.g, self.b });
} else {
@compileError("Unknown format specifier for Color: " ++ fmt);
}
}
};
pub fn main() !void {
const red = Color{ .r = 255, .g = 0, .b = 0 };
const teal = Color{ .r = 0, .g = 128, .b = 128 };
// Default format
std.debug.print("Red: {any}\n", .{red});
std.debug.print("Teal: {any}\n", .{teal});
// Hex format
std.debug.print("Red: {x}\n", .{red});
std.debug.print("Teal: {x}\n", .{teal});
}
The format function receives: self (the value to format), fmt (the format specifier between {}), options (padding/alignment/fill), and writer (where to write the output). The writer parameter uses anytype so it works with any writer implementation -- stdout, stderr, a buffer, a network socket, anything.
The beauty of this system is that custom format functions compose with all the standard formatting features. You can write {x:>20} and your Color's hex representation will be right-aligned in a 20-character field, with the padding handled automatically by the format system. You don't need to implement padding yourself.
Here's a more practical example -- a struct that represents a duration:
const std = @import("std");
const Duration = struct {
total_ms: u64,
fn fromSeconds(s: u64) Duration {
return .{ .total_ms = s * 1000 };
}
fn fromMs(ms: u64) Duration {
return .{ .total_ms = ms };
}
pub fn format(
self: Duration,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
const hours = self.total_ms / 3_600_000;
const mins = (self.total_ms % 3_600_000) / 60_000;
const secs = (self.total_ms % 60_000) / 1_000;
const ms = self.total_ms % 1_000;
if (hours > 0) {
try writer.print("{d}h{d:0>2}m{d:0>2}.{d:0>3}s", .{ hours, mins, secs, ms });
} else if (mins > 0) {
try writer.print("{d}m{d:0>2}.{d:0>3}s", .{ mins, secs, ms });
} else {
try writer.print("{d}.{d:0>3}s", .{ secs, ms });
}
}
};
pub fn main() !void {
const d1 = Duration.fromMs(3_723_456); // 1h2m3.456s
const d2 = Duration.fromMs(125_789); // 2m5.789s
const d3 = Duration.fromMs(4_321); // 4.321s
std.debug.print("d1 = {any}\n", .{d1});
std.debug.print("d2 = {any}\n", .{d2});
std.debug.print("d3 = {any}\n", .{d3});
}
This is the same principle you use in Python with __str__ and __repr__, or in Rust with impl Display. The difference in Zig is that the format function is compile-time dispatched -- no vtable lookup, no dynamic method resolution. The compiler knows at build time which format function to call for each argument.
Compile-time format string validation
This is one of those Zig features that feels like magic until you understand how it works. Every format string is validated at compile time. If you pass the wrong type, misspell a specifier, or have a mismatch between your format string and your arguments, you get a compile error -- not a runtime crash, not a garbled output, a compile error:
const std = @import("std");
pub fn main() !void {
const x: i32 = 42;
const name = "test";
// These all compile fine:
std.debug.print("{d}\n", .{x});
std.debug.print("{s}\n", .{name});
std.debug.print("{d} {s}\n", .{ x, name });
// These would be compile errors (uncomment to see):
// std.debug.print("{s}\n", .{x}); // i32 is not a string
// std.debug.print("{d}\n", .{ x, name }); // too many arguments
// std.debug.print("{d} {d}\n", .{x}); // too few arguments
// std.debug.print("{z}\n", .{x}); // 'z' is not a valid specifier
}
How does this work? The format string parameter is marked comptime, which means the compiler knows its contents at compile time. The std.fmt module then parses the format string as a comptime operation, counts the placeholders, checks each specifier against the corresponding argument type, and emits a compile error if anything doesn't match.
This is the kind of safety you don't get in C (printf("%s", 42) compiles fine and segfaults at runtime), and that you only partially get in Python (.format() checks at runtime, crashing in production instead of at build time). Zig catches the error before your code ever runs. For my money, this is one of the most underappreciated features in the language.
Formatting into buffers with std.fmt.bufPrint
Sometimes you need to build a formatted string into a buffer rather than printing it directly. std.fmt.bufPrint does exactly this -- same format syntax, but writes into a stack buffer in stead of stdout/stderr:
const std = @import("std");
pub fn main() !void {
var buf: [256]u8 = undefined;
// Format into a stack buffer
const result = try std.fmt.bufPrint(&buf, "User {s} scored {d} points", .{ "Alice", 1337 });
std.debug.print("Formatted: '{s}' (len={d})\n", .{ result, result.len });
// Building a hex dump
const data = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE };
var hex_buf: [128]u8 = undefined;
var pos: usize = 0;
for (data, 0..) |byte, i| {
const written = try std.fmt.bufPrint(hex_buf[pos..], "{x:0>2}", .{byte});
pos += written.len;
if (i < data.len - 1) {
const sep = try std.fmt.bufPrint(hex_buf[pos..], " ", .{});
pos += sep.len;
}
}
std.debug.print("Hex dump: {s}\n", .{hex_buf[0..pos]});
// allocPrint for dynamic allocation (when buffer size is unknown)
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const dynamic = try std.fmt.allocPrint(allocator, "Dynamic string with {d} items", .{42});
defer allocator.free(dynamic);
std.debug.print("Dynamic: '{s}'\n", .{dynamic});
}
bufPrint returns a slice into your buffer containing just the formatted bytes (not the whole buffer). If the buffer is too small, it returns error.NoSpaceLeft. For situations where you genuinely don't know how big the output will be, std.fmt.allocPrint allocates exactly the right amount of memory using an allocator -- just remember to free it when you're done.
This stack-buffer pattern is something you'll use constantly. Building file paths, constructing HTTP headers, formatting log lines, assembling error messages -- any time you need a formatted string as an intermediate value rather than printing it directly.
Practical example: structured request logging
Let's put everything together in a practical example. Imagine you're building a web server (we touched on TCP sockets back in episode 21) and you want structured logging for incoming requests:
const std = @import("std");
const log = std.log.scoped(.server);
const Request = struct {
method: []const u8,
path: []const u8,
status: u16,
duration_ms: u64,
bytes_sent: usize,
pub fn format(
self: Request,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
try writer.print("{s} {s} -> {d} ({d}ms, {d} bytes)", .{
self.method,
self.path,
self.status,
self.duration_ms,
self.bytes_sent,
});
}
};
fn handleRequest(method: []const u8, path: []const u8) Request {
// Simulate some work
const status: u16 = if (std.mem.eql(u8, path, "/health")) 200 else if (std.mem.eql(u8, path, "/missing")) 404 else 200;
const bytes: usize = if (status == 200) 1024 else 0;
return .{
.method = method,
.path = path,
.status = status,
.duration_ms = 23,
.bytes_sent = bytes,
};
}
pub fn main() !void {
const requests = [_][2][]const u8{
.{ "GET", "/api/users" },
.{ "POST", "/api/login" },
.{ "GET", "/health" },
.{ "GET", "/missing" },
};
log.info("Server starting on port 8080", .{});
for (requests) |req| {
const result = handleRequest(req[0], req[1]);
if (result.status >= 400) {
log.warn("{any}", .{result});
} else {
log.info("{any}", .{result});
}
}
log.info("Processed {d} requests", .{requests.len});
}
This combines everything: scoped logging for the module identifier, custom format() on the Request struct so each request prints cleanly, log level selection based on the status code (warnings for 4xx errors), and compile-time format string validation throughout. No runtime formatting overhead for disabled log levels. No string allocations for the log messages themselves.
In a real server you'd swap the default log function for one that adds timestamps and writes to a file or log aggregator. The rest of the code stays identical -- that's the power of having the log interface decoupled from the output backend.
Exercises
Create a
Moneystruct withcents: i64andcurrency: []const u8fields. Implement a customformat()function that prints it as$12.34for USD,E12.34for EUR, and12.34 ???for unknown currencies. Handle negative amounts with a leading minus sign (e.g.-$5.00). Test it withstd.debug.printusing both{any}and by feeding it intostd.fmt.bufPrintto verify it works with both output targets.Build a
TablePrinterstruct that takes column headers (as a slice of strings) and column widths (as a slice ofusize), and has methodsprintHeader(writer)andprintRow(writer, values)that produce aligned, padded table output. Use it to print a table with columns "Name" (width 15), "Score" (width 8), and "Grade" (width 6). Print at least 4 rows of data. The header should have a separator line made of dashes underneath it.Write a
hexDumpfunction that takes a[]const u8slice and awriter, and outputs a hex dump in the classic format: offset on the left, hex bytes in the middle (grouped in pairs of 8 with extra space between groups), and ASCII printable characters on the right. Non-printable bytes should show as.. Test it with a string like"Hello, Zig!\x00\x01\x02\xff World". Example output line:00000000 48 65 6c 6c 6f 2c 20 5a 69 67 21 00 01 02 ff 20 |Hello, Z ig!.... |
Dus, wat hebben we nou geleerd?
std.debug.printwrites to stderr and is stripped from release builds -- use it for debugging, not for user-facing output;- Format specifiers (
{d},{x},{s},{any},{b},{o},{e},{c}) cover integers, hex, strings, debug-any, binary, octal, scientific, and characters; - Padding and alignment (
{d:0>8},{s:<20},{d:^10}) let you build aligned tables and zero-padded hex without any string manipulation; std.io.getStdOut().writer()is for actual program output -- buffered writing withstd.io.bufferedWriterfor performance;std.logprovides structured logging with compile-time level filtering --debug,info,warn,err-- and scoped loggers for module identification;- Custom
format()on structs makes your types work with the entire format ecosystem --{any},print,bufPrint,allocPrint, all of it; - Format strings are validated at compile time -- wrong specifier? Wrong argument count? Wrong type? Compile error, not runtime crash;
std.fmt.bufPrintandstd.fmt.allocPrintbuild formatted strings into buffers without printing -- essential for constructing paths, headers, messages.
This might seem like "just printing" but formatting infrastructure is one of those things that pervades everything you build. The compile-time validation alone saves hours of debugging. And the custom format() pattern means your domain types integrate seamlessly with every output mechanism in the standard library -- once you implement it, your types work everywhere.
Thanks for your time!