7 I/O, Streams & Formatting
- 0.15 breaking: Use
std.fs.File.stdout()instead ofstd.io.getStdOut(), explicit buffering now required - Writers/Readers: Generic interfaces via vtables (uniform API across files, sockets, buffers)
- Formatting:
writer.print("Hello {s}\n", .{name})- compile-time format checking - Files:
std.fs.cwd().openFile(), alwaysdefer file.close() - Buffering: Wrap with
std.io.bufferedWriter()for performance - Jump to: Writers/Readers §4.2 | Formatting §4.3 | File I/O §4.4
7.1 Overview
Zig provides a consistent I/O abstraction through its Writer and Reader interfaces. These generic interfaces enable uniform I/O operations across different backends—files, network sockets, memory buffers—without sacrificing performance or control. The standard library uses a vtable-based approach, allowing you to write code that works with any I/O source or destination.
Version Note: Significant API changes occurred between Zig 0.14.x and 0.15.x for stdout/stderr access and writer buffering. This chapter marks version-specific patterns with 🕐 0.14.x for legacy code and ✅ 0.15+ for current patterns. Most file I/O operations remain compatible across versions.
This chapter covers obtaining writers and readers, formatting output, managing stream lifetimes, and practical patterns from production Zig codebases. Understanding these patterns is essential for CLI tools, servers, build systems, and any program that reads or writes data.
7.2 Core Concepts
Writers and Readers
Zig’s I/O abstraction centers on two generic interfaces: Writer for output and Reader for input. Both use vtables to provide polymorphic behavior without runtime overhead.
Obtaining stdout and stderr writers:
🕐 0.14.x:
✅ 0.15+:
const std = @import("std");
const stdout = std.fs.File.stdout();
const stderr = std.fs.File.stderr();
// Buffered writer (requires explicit buffer)
var buf: [4096]u8 = undefined;
var file_writer = stdout.writer(&buf);
try file_writer.interface.print("Hello!\n", .{});
try file_writer.interface.flush();
// Unbuffered writer
var unbuffered = stdout.writer(&.{}); // Empty slice = unbuffered
try unbuffered.interface.writeAll("Direct output\n");The key difference in 0.15+ is explicit buffering: you pass a buffer slice to file.writer(), and the returned File.Writer contains an interface: Io.Writer field that provides formatting methods. Passing an empty slice creates an unbuffered writer.
Basic formatting example:
const std = @import("std");
pub fn main() !void {
const stdout = std.fs.File.stdout();
var buf: [256]u8 = undefined;
var writer = stdout.writer(&buf);
try writer.interface.print("Hello from stdout! Number: {d}\n", .{42});
try writer.interface.print("Hex: 0x{x}, Binary: 0b{b}\n", .{ 255, 5 });
try writer.interface.flush();
}File I/O Patterns
Opening and reading files follows consistent patterns across versions:
const std = @import("std");
pub fn readEntireFile(path: []const u8, allocator: std.mem.Allocator) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // Always close on scope exit
// Read entire file with 1MB limit
const contents = try file.readToEndAlloc(allocator, 1024 * 1024);
return contents; // Caller must free
}Writing to files:
pub fn writeToFile(path: []const u8, data: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
// Buffered writing (recommended for files)
var buf: [4096]u8 = undefined;
var file_writer = file.writer(&buf);
try file_writer.interface.writeAll(data);
try file_writer.interface.flush();
}Streaming file reads:
For large files, stream data instead of loading everything into memory:
pub fn processFileLine(path: []const u8) !void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buf: [4096]u8 = undefined;
var file_reader = file.reader(&buf);
while (true) {
const line = file_reader.readUntilDelimiterOrEof(&buf, '\n') catch |err| switch (err) {
error.StreamTooLong => {
// Line longer than buffer, skip to next newline
try file_reader.skipUntilDelimiterOrEof('\n');
continue;
},
else => return err,
} orelse break; // EOF
// Process line...
std.debug.print("{s}\n", .{line});
}
}Formatting and Print
Zig’s std.fmt module provides format specifiers for the print function:
| Specifier | Type | Example | Output |
|---|---|---|---|
{} |
Any | print("{}", .{42}) |
42 |
{d} |
Decimal | print("{d}", .{42}) |
42 |
{x} |
Hex (lower) | print("{x}", .{255}) |
ff |
{X} |
Hex (upper) | print("{X}", .{255}) |
FF |
{o} |
Octal | print("{o}", .{8}) |
10 |
{b} |
Binary | print("{b}", .{5}) |
101 |
{s} |
String | print("{s}", .{"hello"}) |
hello |
{e} |
Scientific | print("{e}", .{1000.0}) |
1.0e+03 |
{d:.2} |
Float precision | print("{d:.2}", .{3.14159}) |
3.14 |
{s:<10} |
Left align | print("'{s:<10}'", .{"hi"}) |
'hi ' |
{s:>10} |
Right align | print("'{s:>10}'", .{"hi"}) |
' hi' |
{s:^10} |
Center | print("'{s:^10}'", .{"hi"}) |
' hi ' |
Custom formatting for user types:
Implement the format function to make your types printable:
const Point = struct {
x: f32,
y: f32,
pub fn format(
self: Point,
comptime fmt_str: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = options;
_ = fmt_str;
try writer.print("Point({d:.2}, {d:.2})", .{ self.x, self.y });
}
};
// Usage:
const p = Point{ .x = 3.14, .y = 2.71 };
try writer.print("Location: {}\n", .{p}); // Output: Location: Point(3.14, 2.71)For types with multiple format modes, inspect fmt_str:
const Color = struct {
r: u8,
g: u8,
b: u8,
pub fn format(
self: Color,
comptime fmt_str: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = options;
if (std.mem.eql(u8, fmt_str, "hex")) {
try writer.print("#{x:0>2}{x:0>2}{x:0>2}", .{ self.r, self.g, self.b });
} else {
try writer.print("rgb({d}, {d}, {d})", .{ self.r, self.g, self.b });
}
}
};
// Usage:
const color = Color{ .r = 255, .g = 128, .b = 64 };
try writer.print("Default: {}\n", .{color}); // rgb(255, 128, 64)
try writer.print("Hex: {hex}\n", .{color}); // #ff8040Stream Lifetime Management
Use defer for cleanup (see Ch5 for comprehensive coverage):
Use errdefer when subsequent operations might fail:
Multiple resources with proper cleanup order:
pub fn complexOperation(allocator: std.mem.Allocator) !void {
const file1 = try std.fs.cwd().createFile("file1.txt", .{});
errdefer file1.close();
const file2 = try std.fs.cwd().createFile("file2.txt", .{});
errdefer file2.close();
const buffer = try allocator.alloc(u8, 1024);
errdefer allocator.free(buffer);
// Do work...
// Success path: clean up in reverse order
allocator.free(buffer);
file2.close();
file1.close();
}Arena pattern for bulk cleanup:
When multiple allocations share a lifetime, use ArenaAllocator:
pub fn processBatch() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // Frees all allocations at once
const allocator = arena.allocator();
const file = try std.fs.cwd().createFile("output.txt", .{});
defer file.close();
var buf: [256]u8 = undefined;
var file_writer = file.writer(&buf);
// Multiple allocations—all freed by arena.deinit()
for (0..10) |i| {
const line = try std.fmt.allocPrint(allocator, "Line {d}\n", .{i});
try file_writer.interface.writeAll(line);
// No need to free 'line'—arena handles it
}
try file_writer.interface.flush();
}7.3 Code Examples
Fixed Buffer Stream (Zero Allocation)
For situations where heap allocation is undesirable, use fixedBufferStream:
This pattern appears in TigerBeetle’s StatsD metrics formatting, where allocation-free formatting is critical for performance.
Buffered vs Unbuffered Performance
Buffering significantly improves performance for many small writes:
const std = @import("std");
pub fn demonstrateBuffering() !void {
const iterations = 1000;
// Unbuffered (slower)
{
const file = try std.fs.cwd().createFile("unbuffered.txt", .{});
defer file.close();
var writer = file.writer(&.{}); // Empty slice = unbuffered
var timer = try std.time.Timer.start();
for (0..iterations) |i| {
try writer.interface.print("Line {d}\n", .{i});
}
const unbuffered_time = timer.read();
std.debug.print("Unbuffered: {d}ns\n", .{unbuffered_time});
}
// Buffered (faster)
{
const file = try std.fs.cwd().createFile("buffered.txt", .{});
defer file.close();
var buf: [4096]u8 = undefined;
var writer = file.writer(&buf);
var timer = try std.time.Timer.start();
for (0..iterations) |i| {
try writer.interface.print("Line {d}\n", .{i});
}
try writer.interface.flush();
const buffered_time = timer.read();
std.debug.print("Buffered: {d}ns\n", .{buffered_time});
}
}Typical results show 5-10x speedup for buffered writes with small individual operations.
Ownership Transfer Pattern
When building types that manage I/O resources, implement clear ownership semantics:
const FileBuffer = struct {
file: std.fs.File,
buffer: []u8,
allocator: std.mem.Allocator,
pub fn init(path: []const u8, allocator: std.mem.Allocator) !FileBuffer {
const file = try std.fs.cwd().openFile(path, .{});
errdefer file.close();
const buffer = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
errdefer allocator.free(buffer);
return FileBuffer{
.file = file,
.buffer = buffer,
.allocator = allocator,
};
}
pub fn deinit(self: *FileBuffer) void {
self.allocator.free(self.buffer);
self.file.close();
}
};
// Usage:
var fb = try FileBuffer.init("data.txt", allocator);
defer fb.deinit();
// Use fb.buffer...7.4 Common Pitfalls
1. Forgetting to Flush Buffered Output
Problem: Buffered data may not be written to the underlying stream without an explicit flush.
Solution: Always flush before closing or when you need data to be visible:
2. Not Closing File Handles
Problem: File descriptors leak if not closed, eventually exhausting system resources.
Solution: Use defer to ensure cleanup:
3. Using debug.print in Production
Problem: std.debug.print() is for debugging only and may not work when stderr is redirected or unavailable.
Solution: Use proper stderr writers for production logging:
4. Incorrect Buffer Sizing
Problem: Buffers that are too small cause frequent flushes, reducing performance.
Solution: Use appropriate buffer sizes (4KB-8KB for files):
5. Stream Lifetime Confusion
Problem: Returning a writer whose buffer or file has gone out of scope.
Solution: Ensure buffer and file outlive the writer:
6. Missing Buffer Parameter
Problem: Writers require an explicit buffer parameter.
Solution: Always pass a buffer (empty slice for unbuffered):
7.5 In Practice
TigerBeetle: Correctness-Focused I/O
TigerBeetle, a distributed financial database, demonstrates I/O patterns prioritizing correctness and observability.
Fixed Buffer Streams for Metrics - Uses std.io.fixedBufferStream() for zero-allocation StatsD metrics formatting - Source: src/trace/statsd.zig:59-85 - Pattern: Compile-time buffer sizing for worst-case metric strings
Direct I/O with Sector Alignment - Opens journal files with O_DIRECT flag to bypass page cache - Source: src/io/linux.zig:1433-1570 - Graceful fallback when Direct I/O unavailable - Block device vs regular file handling
Latent Sector Error (LSE) Recovery - Binary search subdivision to isolate failed sectors on read errors - Source: src/storage.zig:279-384 - Zeros unreadable sectors for graceful degradation - AIMD-based recovery throttling
Ghostty: Event-Driven Terminal I/O
Ghostty, a terminal emulator, shows modern async I/O patterns with the xev library.
PTY Stream Management - Uses xev.Stream.initFd() for async pseudo-terminal I/O - Source: src/termio/Exec.zig:128-129, src/termio/Exec.zig:502-516 - Write queue with buffer pooling to reduce allocation overhead
Config File Reading - XDG-compliant path resolution with fallbacks - Source: src/config/file_load.zig:136-166 - Comprehensive validation: file type, size checks before reading
Fixed Buffer Writers for String Conversion - Stack-allocated buffers for config value serialization - Source: src/config/io.zig:99 - Pattern: var writer: std.Io.Writer = .fixed(&buf);
Bun: High-Performance Buffered I/O
Bun, a JavaScript runtime, demonstrates performance-optimized I/O for module loading.
Reference-Counted I/O Readers - Buffered readers with async deinit queues - Source: src/shell/IOReader.zig:1-150 - Pattern: Ref-counting prevents premature resource cleanup in async contexts
Dynamic Buffers with ArrayListUnmanaged - Uses std.ArrayListUnmanaged for buffers without storing allocators - Reduces struct size and indirection overhead for hot-path I/O
ZLS: LSP Message Formatting
The Zig Language Server demonstrates I/O patterns for protocol communication.
Fixed Buffer Logging - 4KB stack buffer for log message formatting with overflow handling - Source: src/main.zig:50-100 - Gracefully handles buffer overflow with “…” suffix - Pattern: var writer: std.Io.Writer = .fixed(&buffer);
Unbuffered stderr for Critical Messages - Uses std.fs.File.stderr().writer(&.{}) for immediate error output - Source: src/main.zig:98
zigimg: Binary Format Parsing
zigimg, an image encoding/decoding library, demonstrates structured I/O patterns for binary format parsing.
Streaming Decoders with Fixed Buffers
const std = @import("std");
const zigimg = @import("zigimg");
pub fn loadImage(path: []const u8, allocator: std.mem.Allocator) !zigimg.Image {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buf: [8192]u8 = undefined;
var file_reader = file.reader(&buf);
// Format detection from magic bytes
const image = try zigimg.Image.fromFile(allocator, &file_reader.interface);
// Caller owns image.pixels - must call image.deinit()
return image;
}Key Patterns: - Stream-based chunk parsing without loading entire file into memory - Source: src/formats/png.zig - Validates chunk CRCs and structure as data streams in
Multi-Format I/O Abstraction:
// Generic API works across PNG, JPEG, BMP, etc.
pub fn processImage(reader: anytype, allocator: std.mem.Allocator) !void {
const image = try zigimg.Image.fromReader(allocator, reader);
defer image.deinit();
std.debug.print("Format: {s}, Size: {}x{}\n", .{
@tagName(image.format),
image.width,
image.height,
});
// Access pixel data
const pixels = image.pixels.asBytes();
// Process pixels...
}Allocator-Aware Design: - Explicit allocator threading for pixel buffer allocation - Arena allocator pattern for temporary decode buffers - Caller-owned pixel data with clear ownership semantics
See also: Chapter 4 (Memory & Allocators) for allocator patterns used in image decoding.
zap: HTTP Server Streaming
zap, a high-performance HTTP server framework, shows production-grade request/response streaming patterns.
Buffered Response Writers
const zap = @import("zap");
fn handleRequest(req: *zap.Request, res: *zap.Response) !void {
// Stack-allocated buffer for response headers
var header_buf: [1024]u8 = undefined;
// Write response with explicit buffering control
try res.setHeader("Content-Type", "application/json");
try res.write("{\"status\":\"ok\"}");
// Explicit flush for streaming response
try res.flush();
}
pub fn main() !void {
var server = zap.Server.init(.{
.port = 8080,
.on_request = handleRequest,
});
try server.listen();
}Key Patterns: - Pre-allocated response buffers for common HTTP scenarios - Source: src/http.zig - Stack-allocated buffers for headers, dynamic allocation for large bodies - Explicit flush control for chunked transfer encoding
Zero-Copy Request Body Handling:
fn handleUpload(req: *zap.Request, res: *zap.Response) !void {
// Body is a slice into connection buffer - no allocation
const body = req.body();
// Parse in-place without copying
if (std.mem.indexOf(u8, body, "filename=")) |idx| {
const filename_slice = body[idx + 9 ..];
// Process without allocating...
}
try res.write("Upload received");
}Event Loop Integration: - Tight integration with epoll/kqueue for async I/O - Non-blocking reads with automatic buffer management - Connection pooling with buffer reuse to minimize allocations
See also: Chapter 8 (Async & Concurrency) for zap’s event loop architecture and concurrency patterns.
7.6 Summary
Zig’s I/O abstraction provides explicit control over buffering, resource lifetimes, and formatting. Key decisions:
Buffering Strategy: - Use buffered I/O (4KB-8KB buffers) for files and network streams - Use unbuffered I/O for interactive terminal output and critical errors - Use fixed buffer streams when heap allocation is undesirable
Version Migration: - 0.14.x to 0.15+: Replace std.io.getStdOut() with std.fs.File.stdout() - Pass explicit buffers to file.writer(&buf) or &.{} for unbuffered - Access formatting through writer.interface.print() instead of writer.print()
Resource Management: - Always use defer for cleanup on all paths (success and error) - Use errdefer for cleanup only on error paths - Consider arena allocators when multiple allocations share a lifetime
Performance: - Buffered I/O typically provides 5-10x speedup for small writes - Pre-allocate buffers on the stack when size is known - Use writeAll for static strings; reserve print for actual formatting
The explicit nature of 0.15+ buffering may seem verbose initially, but it provides clarity about when and how much buffering occurs—essential for systems programming where I/O behavior must be predictable.
7.7 References
- Zig Standard Library – Io.zig (0.15.2)
- Zig Standard Library – fmt.zig (0.15.2)
- Zig Standard Library – fs/File.zig (0.15.2)
- TigerBeetle – Fixed buffer metrics formatting (src/trace/statsd.zig:59-85)
- TigerBeetle – Direct I/O implementation (src/io/linux.zig:1433-1570)
- TigerBeetle – LSE error recovery (src/storage.zig:279-384)
- Ghostty – Event loop stream management (src/termio/Exec.zig)
- Ghostty – Config file patterns (src/config/file_load.zig:136-166)
- Bun – Buffered I/O with reference counting (src/shell/IOReader.zig)
- ZLS – Fixed buffer logging (src/main.zig:50-100)
- zigimg – Binary format parsing (src/formats/png.zig)
- zigimg – Multi-format I/O abstraction (src/Image.zig)
- zap – HTTP server streaming patterns (src/http.zig)
- zig.guide – Readers and Writers (standard-library/readers-and-writers)