Writing a file means sending bytes from your program to the operating system so they can be stored on disk.
Writing a file means sending bytes from your program to the operating system so they can be stored on disk.
Reading files and writing files are closely related. In both cases, you work with bytes. The main difference is direction.
When reading, bytes move from a file into your program.
When writing, bytes move from your program into a file.
In Zig, writing files usually follows this pattern:
const file = try create_or_open_the_file;
defer file.close(io);
try file.writeAll(io, data);The important ideas are the same as before:
Open or create the file.
Handle errors.
Write bytes.
Close the file.
Use defer so cleanup stays near setup.
Zig 0.16 moved many file system and blocking I/O operations into the newer std.Io interface. This means many current examples use std.Io.Dir, std.Io.File, and an explicit io value passed into I/O calls. The official Zig 0.16 documentation shows this style in its “Hello World” example, where stdout is accessed through std.Io.File.stdout() and written through writeStreamingAll(init.io, ...).
Writing to Standard Output
Before writing to a file, start with standard output.
Standard output is the normal output stream of a program. In a terminal, this usually means text printed to the screen.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
try std.Io.File.stdout().writeStreamingAll(init.io, "Hello, stdout!\n");
}This program writes bytes to stdout.
std.Io.File.stdout()gets the stdout file handle.
writeStreamingAll(init.io, "Hello, stdout!\n")writes all the bytes from the string.
The word All matters. It means the function tries to write the complete slice, not only part of it.
Creating a File
To write to a new file, create it first.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "output.txt", .{});
defer file.close(io);
try file.writeAll(io, "Hello from Zig!\n");
}After running the program, you should have a file named output.txt containing:
Hello from Zig!This line creates the file in the current working directory:
const file = try std.Io.Dir.cwd().createFile(io, "output.txt", .{});std.Io.Dir.cwd() means the current working directory.
createFile asks the operating system to create or open a file for writing.
The try is necessary because file creation can fail. For example, the directory might not allow writing, the path might be invalid, or the disk might be full.
This line closes the file later:
defer file.close(io);This line writes bytes:
try file.writeAll(io, "Hello from Zig!\n");Again, writing can fail. The disk might run out of space. The file might be on a broken device. The operating system might reject the operation.
Zig makes that failure visible.
Strings Are Byte Slices
This call writes a string literal:
try file.writeAll(io, "Hello from Zig!\n");A Zig string literal is a sequence of bytes. Its type behaves like a constant byte slice.
So writing text is really writing bytes.
This means these two ideas are the same at the file system level:
"abc"and:
[_]u8{ 'a', 'b', 'c' }Both are bytes. The file system does not know whether they are text, JSON, CSV, or binary data. Your program gives the bytes meaning.
Writing Several Pieces
You can write several pieces one after another:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "names.txt", .{});
defer file.close(io);
try file.writeAll(io, "Alice\n");
try file.writeAll(io, "Bob\n");
try file.writeAll(io, "Charlie\n");
}The file contains:
Alice
Bob
CharlieEach write appends bytes at the current file position.
The file position starts at the beginning. After writing Alice\n, the position moves forward. The next write continues from there.
Overwriting Existing Files
Creating a file may overwrite an existing file, depending on the create options.
A common intent is: create the file if needed, and replace the old contents.
In many examples, this is written with a truncate option:
const file = try std.Io.Dir.cwd().createFile(io, "output.txt", .{
.truncate = true,
});truncate = true means old contents should be removed.
So if output.txt used to contain:
old data
old data
old dataand your program writes:
new datathe final file contains only:
new dataTruncation is destructive. Use it when replacing the file is really what you want.
Appending to a File
Sometimes you do not want to replace the file. You want to add data to the end.
That is called appending.
The exact API details for opening a file in append mode can vary across Zig versions, so check the local standard library docs for your installed compiler with:
zig stdThe shape of the code is still the same:
const file = try open_file_for_append;
defer file.close(io);
try file.writeAll(io, "new line\n");The concept matters more than the option name:
Overwrite replaces existing contents.
Append keeps existing contents and writes after them.
Writing Formatted Text
Many files are built from values.
For example, suppose you want to write:
name: Alice
score: 42You can format text before writing it.
A simple approach is to use a stack buffer:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "score.txt", .{});
defer file.close(io);
var buffer: [128]u8 = undefined;
const line1 = try std.fmt.bufPrint(&buffer, "name: {s}\n", .{"Alice"});
try file.writeAll(io, line1);
const line2 = try std.fmt.bufPrint(&buffer, "score: {}\n", .{42});
try file.writeAll(io, line2);
}std.fmt.bufPrint writes formatted text into a buffer and returns the part that was used.
This line:
const line1 = try std.fmt.bufPrint(&buffer, "name: {s}\n", .{"Alice"});returns a slice.
Then this line writes that slice to the file:
try file.writeAll(io, line1);The same buffer can be reused because line1 has already been written before line2 is created.
Using a Writer
For larger formatted output, writing through a writer can be more convenient.
A writer is an object that knows how to receive bytes. Files, memory buffers, and other output destinations can expose writer-like interfaces.
Because Zig 0.16 changed I/O significantly, the exact writer setup may differ from older tutorials. Older std.fs.File.writer() examples may need updating. Zig 0.16 release notes describe major work around the new I/O system and writer internals, so local docs are the safest source for final API details.
The idea is:
var writer = make_a_writer_for_the_file;
try writer.print("x = {}\n", .{123});
try writer.flush();Two points matter.
First, formatted writing can fail, so it uses try.
Second, some writers buffer data. If a writer buffers data, flush sends the buffered bytes to the final destination.
For beginners, writeAll is easier to understand. Learn writers after you are comfortable with raw file writing.
Writing Binary Data
Files are bytes, so binary writing is natural.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "data.bin", .{});
defer file.close(io);
const bytes = [_]u8{ 0x41, 0x42, 0x43, 0x0a };
try file.writeAll(io, &bytes);
}This writes four bytes:
41 42 43 0aThose bytes also happen to mean:
ABCin ASCII-like text.
But the file itself only stores bytes.
This distinction is important. A text file is a byte file interpreted as text. A binary file is a byte file interpreted by some binary format.
Writing Struct Data Carefully
A beginner might want to write a struct directly to a file.
For example:
const Header = struct {
magic: u32,
version: u16,
};Be careful.
The in-memory layout of a normal struct is not always the same as the file format you want. There can be padding, alignment, and endianness issues.
For file formats, write fields deliberately.
For example, write known bytes:
try file.writeAll(io, "ZIG0");Then write numbers with a clear byte order.
The standard library has helpers for endian-aware integer handling, and those belong in later sections. For now, remember the rule:
Do not treat memory layout as a file format unless you intentionally designed it that way.
Writing a Small CSV File
CSV is a simple text format where rows are separated by newlines and columns are separated by commas.
Here is a small example:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "people.csv", .{});
defer file.close(io);
try file.writeAll(io, "name,age\n");
try file.writeAll(io, "Alice,30\n");
try file.writeAll(io, "Bob,25\n");
}The output file contains:
name,age
Alice,30
Bob,25This is enough for simple data.
Real CSV has escaping rules for commas, quotes, and newlines inside fields. But the basic idea is still just writing text bytes in a known format.
Writing Generated Lines
Now write several lines in a loop.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "numbers.txt", .{});
defer file.close(io);
var buffer: [64]u8 = undefined;
for (1..6) |i| {
const line = try std.fmt.bufPrint(&buffer, "number: {}\n", .{i});
try file.writeAll(io, line);
}
}The file contains:
number: 1
number: 2
number: 3
number: 4
number: 5Notice the pattern:
Format into a buffer.
Write the used slice.
Repeat.
The buffer is fixed-size. If the formatted text does not fit, bufPrint returns an error.
That is useful. Zig does not silently overflow the buffer.
Handling Write Errors
Writing can fail.
This example handles one possible error path broadly:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = std.Io.Dir.cwd().createFile(io, "output.txt", .{}) catch |err| {
std.debug.print("could not create file: {}\n", .{err});
return;
};
defer file.close(io);
file.writeAll(io, "hello\n") catch |err| {
std.debug.print("could not write file: {}\n", .{err});
return;
};
}This style is useful for command-line tools. Instead of printing a stack trace, the program prints a short message.
For libraries, you usually should not print inside the function. Return the error instead. Let the caller decide what to show the user.
write vs writeAll
Many I/O APIs distinguish between writing some bytes and writing all bytes.
A function named write may write only part of the input and return how many bytes it wrote.
A function named writeAll keeps going until all bytes are written or an error happens.
For beginners, prefer writeAll when writing normal file contents.
This is safer:
try file.writeAll(io, data);This requires more care:
const n = try file.write(io, data);If n is smaller than data.len, not all bytes were written.
Partial writes are especially important for streams, sockets, pipes, and some operating system situations. File writing can also be affected by errors.
The name writeAll communicates your intent clearly.
Closing and Flushing
Closing a file tells the operating system you are finished with it.
defer file.close(io);Some output APIs also have flushing.
Flushing means: push buffered data to the underlying destination now.
If you write directly with simple file calls, closing usually handles final cleanup. If you use a buffered writer, you may need to call flush.
The pattern is:
try writer.print("hello\n", .{});
try writer.flush();Without flushing, some buffered data may remain in memory longer than you expect.
The Core Pattern
Most beginner file writing code has this shape:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const file = try std.Io.Dir.cwd().createFile(io, "output.txt", .{});
defer file.close(io);
try file.writeAll(io, "some bytes\n");
}That is the minimum useful pattern.
You can add formatting:
var buffer: [128]u8 = undefined;
const text = try std.fmt.bufPrint(&buffer, "value = {}\n", .{123});
try file.writeAll(io, text);You can add loops:
for (0..10) |i| {
const line = try std.fmt.bufPrint(&buffer, "{}\n", .{i});
try file.writeAll(io, line);
}You can add error handling:
const file = std.Io.Dir.cwd().createFile(io, "output.txt", .{}) catch |err| {
std.debug.print("create failed: {}\n", .{err});
return;
};The base idea does not change.
What You Should Remember
Writing files in Zig is explicit.
You choose the file path.
You create or open the file.
You decide whether to overwrite or append.
You write bytes.
You handle errors.
You close the file.
Use writeAll when you want to write a whole slice.
Use std.fmt.bufPrint when you want to turn values into text before writing.
Use defer immediately after opening or creating the file.
The file system stores bytes. Your program decides whether those bytes are text, JSON, CSV, binary data, or something else.