Intermediate

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const std = @import("std");

pub fn main() !void {
    // Wrap stdout in a buffer - output accumulates in memory
    var bw = std.io.bufferedWriter(std.io.getStdOut().writer());
    const stdout = bw.writer();

    const language = "Zig";
    const year: u16 = 2016;
    const version: f64 = 0.14;

    // Placeholders are type-checked at compile time
    try stdout.print("{s} first appeared in {d}\n", .{ language, year });

    // Precision, width, and alignment
    try stdout.print("version: {d:.2}\n", .{version});
    try stdout.print("[{d:>6}] right-aligned in 6 columns\n", .{42});
    try stdout.print("[{d:<6}] left-aligned in 6 columns\n", .{42});

    // Alternate bases: hexadecimal and binary
    try stdout.print("255 in hex: {x}, in binary: {b}\n", .{ @as(u8, 255), @as(u8, 255) });

    // Nothing reaches the terminal until the buffer is flushed
    try bw.flush();
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const std = @import("std");

pub fn main() !void {
    const stdin = std.io.getStdIn().reader();
    const stdout = std.io.getStdOut().writer();

    try stdout.print("What is your name? ", .{});
    var name_buf: [64]u8 = undefined;
    const name_line = (try stdin.readUntilDelimiterOrEof(&name_buf, '\n')) orelse return;
    // Trim a trailing \r so the program also works with Windows line endings
    const name = std.mem.trimRight(u8, name_line, "\r");

    try stdout.print("What year were you born? ", .{});
    var year_buf: [16]u8 = undefined;
    const year_line = (try stdin.readUntilDelimiterOrEof(&year_buf, '\n')) orelse return;
    const year_str = std.mem.trimRight(u8, year_line, "\r");

    // Parsing can fail too - parseInt returns an error union
    const year = try std.fmt.parseInt(i32, year_str, 10);

    try stdout.print("Hello, {s}! You turn {d} in 2026.\n", .{ name, 2026 - year });
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const std = @import("std");

pub fn main() !void {
    // Create (or truncate) a file in the current directory
    const file = try std.fs.cwd().createFile("languages.txt", .{});
    defer file.close();

    // Write raw bytes directly
    try file.writeAll("Languages that influenced Zig:\n");

    // Or use the same writer interface as stdout
    const writer = file.writer();
    const influences = [_][]const u8{ "C", "C++", "Rust", "Go" };
    for (influences, 1..) |lang, i| {
        try writer.print("{d}. {s}\n", .{ i, lang });
    }

    std.debug.print("Wrote languages.txt\n", .{});
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Handle a missing file explicitly; propagate any other error
    const file = std.fs.cwd().openFile("languages.txt", .{}) catch |err| switch (err) {
        error.FileNotFound => {
            std.debug.print("languages.txt not found - run write_file.zig first\n", .{});
            return;
        },
        else => return err,
    };
    defer file.close();

    // Read the entire file (up to 1 MiB) into allocated memory
    const contents = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(contents);

    std.debug.print("{s}", .{contents});

    // Process the contents line by line
    var lines = std.mem.splitScalar(u8, contents, '\n');
    var count: usize = 0;
    while (lines.next()) |line| {
        if (line.len > 0) count += 1;
    }
    std.debug.print("({d} non-empty lines)\n", .{count});
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Pull the Zig image
docker pull kassany/alpine-ziglang:0.14.0

# Formatted output
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run formatted_output.zig

# Reading stdin needs the -i flag to keep input connected
docker run --rm -i -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run read_input.zig

# Write a file, then read it back
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run write_file.zig
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run read_file.zig

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 print return error unions; the compiler forces you to handle them with try, catch, or if/else, so no failure is silently dropped.
  • Readers and writers are uniform — stdin, stdout, and files all expose the same reader/writer interfaces, so print and 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 flushstd.io.bufferedWriter batches writes into one system call for speed, but nothing appears until flush(); forgetting it is the classic bug.
  • defer pairs acquisition with releasedefer file.close() and defer 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 like error.FileNotFound and re-raises everything else with return 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
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining