I/O Operations in Zig
Learn input and output in Zig - formatted and buffered console output, reading from stdin, and writing and reading files with explicit error handling, all with Docker-ready examples
Input and output are where Zig’s “no hidden anything” philosophy becomes most visible. Every I/O operation can fail — a disk fills up, a pipe closes, a file doesn’t exist — and Zig refuses to sweep that under the rug. Nearly every function in this tutorial returns an error union, and the compiler forces you to acknowledge it with try, catch, or an if/else capture.
The other thing Zig makes explicit is memory. Reading a line from the terminal or a whole file from disk needs somewhere to put the bytes, and Zig never allocates behind your back: you either hand the function a fixed buffer you declared yourself, or you pass an allocator and take responsibility for freeing what comes back. This is the systems-programming mindset — the same discipline C demands, but with the compiler checking your error paths.
Zig’s I/O is organized around readers and writers: small interfaces that standard input, standard output, and files all provide. Once you can print to one writer, you can print to any of them — the formatted-output skills from the console section transfer directly to files.
In this tutorial you’ll format and buffer console output, read and parse input from stdin, write a file, and read it back — handling a missing file gracefully along the way.
Formatted Console Output
You met stdout.print in Hello World; here’s what the formatting system can actually do. Placeholders like {s} and {d} are checked against the argument types at compile time — a mismatched format string is a compile error, not a runtime crash. You can also control width, alignment, precision, and numeric base.
Because writing to a terminal one small piece at a time is slow, the standard library provides std.io.bufferedWriter, which collects output in memory and sends it in one system call when you flush. This buffered-vs-unbuffered distinction is a classic systems-programming concern that Zig surfaces rather than hides.
Create a file named formatted_output.zig:
| |
Each print call returns an error union, so each gets a try — if stdout is a closed pipe, the error propagates out of main instead of being silently lost. Forgetting bw.flush() is the classic buffered-writer bug: the program exits and the buffered text never appears.
Reading from Standard Input
Input mirrors output: std.io.getStdIn().reader() gives you a reader. The workhorse for line-oriented input is readUntilDelimiterOrEof, which fills a buffer you provide and returns an optional slice — null means end of input. No hidden allocation: if a line won’t fit in your buffer, that’s an error, not a silent heap allocation.
Create a file named read_input.zig:
| |
Notice the layers of failure handling in a few lines: try on the read (I/O can fail), orelse return on the optional (input can end early), and try on parseInt (the text might not be a number). Zig makes each possibility a visible, separate decision.
Writing to a File
File output goes through std.fs.cwd(), a handle to the current working directory. createFile creates (or truncates) a file and returns a File; defer file.close() guarantees the file is closed no matter how the function exits — Zig’s answer to forgetting fclose. A file exposes the same writer interface as stdout, so writer().print works identically.
Create a file named write_file.zig:
| |
writeAll sends raw bytes; writer().print adds formatting on top. The for (influences, 1..) |lang, i| loop pairs each language with a counter starting at 1 — the same formatted-output code you’d write for the terminal, pointed at a file instead.
Reading a File with Error Handling
Reading a whole file needs memory sized to the file’s contents, which isn’t known at compile time — so this is where an allocator enters. readToEndAlloc takes the allocator plus a maximum size (a guard against accidentally slurping a gigabyte), and the caller must free the result. The example also shows idiomatic Zig error handling: a catch with a switch that handles FileNotFound gracefully and re-raises anything else.
Create a file named read_file.zig:
| |
Every resource acquired is paired with a defer that releases it: the allocator is deinitialized, the file is closed, the buffer is freed. std.mem.splitScalar iterates over the contents without copying — each line is a slice into the buffer you already own.
Running with Docker
Run every example with the Alpine Zig image. Because the current directory is mounted into the container, write_file.zig creates languages.txt right in your working directory, where read_file.zig can find it.
| |
Note the extra -i flag on the read_input.zig command — without it, Docker doesn’t connect your terminal’s input to the program, and the reads see end-of-input immediately.
Expected Output
Running formatted_output.zig:
Zig first appeared in 2016
version: 0.14
[ 42] right-aligned in 6 columns
[42 ] left-aligned in 6 columns
255 in hex: ff, in binary: 11111111
Running read_input.zig interactively (typing Ada and 1990 at the prompts):
What is your name? Ada
What year were you born? 1990
Hello, Ada! You turn 36 in 2026.
Running write_file.zig:
Wrote languages.txt
Running read_file.zig:
Languages that influenced Zig:
1. C
2. C++
3. Rust
4. Go
(5 non-empty lines)
Key Concepts
- Every I/O call can fail — Reads, writes, opens, and even
printreturn error unions; the compiler forces you to handle them withtry,catch, orif/else, so no failure is silently dropped. - Readers and writers are uniform — stdin, stdout, and files all expose the same reader/writer interfaces, so
printand line-reading code works unchanged across them. - You supply the memory — Line reads fill a buffer you declare; whole-file reads take an allocator and a size cap. Zig never allocates behind your back.
- Buffered output needs a flush —
std.io.bufferedWriterbatches writes into one system call for speed, but nothing appears untilflush(); forgetting it is the classic bug. deferpairs acquisition with release —defer file.close()anddefer allocator.free(contents)run on every exit path, preventing leaked file handles and memory.- Handle specific errors, propagate the rest — The
catch |err| switch (err)pattern deals with expected failures likeerror.FileNotFoundand re-raises everything else withreturn err. - Format strings are compile-time checked — Mismatched placeholders and argument types are compile errors, and specifiers like
{d:.2},{d:>6},{x}, and{b}control precision, alignment, and base.
Next Steps
You’ve now covered the Zig fundamentals: Hello World, variables and types, operators, control flow, functions, and I/O. From here, explore Zig’s distinctive advanced features — comptime metaprogramming, allocator strategies, and seamless C interoperability — or revisit the language overview for the story of how Zig is being used in projects like Bun and TigerBeetle.
Running Today
All examples can be run using Docker:
docker pull kassany/alpine-ziglang:0.14.0
Comments
Loading comments...
Leave a Comment