Skip to content
v1.0.0-zig0.15.2

Filesystem API

Reading config files at startup, writing logs, processing data pipelines, managing temp directories — filesystem operations are everywhere. The fs module gives you two ways to work with files: synchronous (File) for simple scripts and startup logic, and async (AsyncFile) for high-concurrency servers where blocking the event loop is not an option.

Most of the time you’ll reach for the convenience functions — readFile, writeFile, copy — which handle opening, reading/writing, and closing in a single call. When you need fine-grained control (seeking, partial reads, metadata inspection), drop down to File or AsyncFile handles.

const fs = @import("volt").fs;
// One-liner: read an entire file into memory
const data = try fs.readFile(allocator, "config.json");
defer allocator.free(data);
// One-liner: write a string to a file (creates or truncates)
try fs.writeFile("output.txt", "Hello, world!");
// File handle for more control
var file = try fs.File.open("data.bin");
defer file.close();
var buf: [4096]u8 = undefined;
const n = try file.read(&buf);
// Directory operations
try fs.createDirAll("logs/2026/02");
var iter = try fs.readDir("logs/2026/02");
defer iter.close();
while (try iter.next()) |entry| {
std.debug.print("{s} ({} bytes)\n", .{ entry.name, entry.size });
}
// Async file I/O (requires Io handle -- uses io_uring on Linux, blocking pool elsewhere)
var async_file = try fs.AsyncFile.open(io, "large_dataset.bin");
defer async_file.close();
const chunk = try async_file.read(&buf);
const fs = @import("volt").fs;

All types and functions are accessed through the fs namespace: fs.File, fs.readFile, fs.createDir, etc.


These handle the full open-read/write-close lifecycle in one call. Prefer these for simple operations.

pub fn readFile(allocator: Allocator, path: []const u8) ![]u8

Read the entire contents of a file into a newly allocated buffer. Caller owns the returned slice.

const config = try fs.readFile(allocator, "settings.json");
defer allocator.free(config);
const parsed = try std.json.parseFromSlice(Config, allocator, config, .{});
pub fn readFileString(allocator: Allocator, path: []const u8) ![]u8

Like readFile, but validates that the contents are valid UTF-8. Returns error.InvalidUtf8 if validation fails.

const template = try fs.readFileString(allocator, "email_template.txt");
defer allocator.free(template);
pub fn writeFile(path: []const u8, data: []const u8) !void

Write data to a file, creating it if it doesn’t exist, truncating it if it does.

const output = try std.json.stringifyAlloc(allocator, result, .{});
defer allocator.free(output);
try fs.writeFile("results.json", output);
pub fn appendFile(path: []const u8, data: []const u8) !void

Append data to a file, creating it if it doesn’t exist. Useful for logs and audit trails.

const timestamp = std.time.timestamp();
var buf: [128]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "[{d}] Request processed\n", .{timestamp}) catch return;
try fs.appendFile("app.log", line);
pub fn copy(src: []const u8, dst: []const u8) !void

Copy a file from src to dst. Creates the destination if it doesn’t exist, truncates if it does.

// Backup before overwriting
try fs.copy("database.db", "database.db.bak");
pub fn rename(old: []const u8, new: []const u8) !void

Rename or move a file or directory. Atomic on most filesystems when source and destination are on the same mount.

// Atomic write pattern: write to temp, then rename into place
try fs.writeFile("config.json.tmp", new_config);
try fs.rename("config.json.tmp", "config.json");
pub fn exists(path: []const u8) !bool

Check if a path exists. Returns an error if the check itself fails (e.g., permission denied on parent directory).

if (try fs.exists("config.toml")) {
const config = try fs.readFile(allocator, "config.toml");
defer allocator.free(config);
// ...
}
pub fn tryExists(path: []const u8) bool

Check if a path exists, returning false on any error. Convenient when you don’t care about the failure reason.

const config_path = if (fs.tryExists("config.local.toml"))
"config.local.toml"
else
"config.toml";

A handle to an open file. Provides read, write, seek, and metadata operations. Works without a runtime — no async machinery needed.

MethodDescription
File.open(path)Open for reading
File.create(path)Create or truncate for writing
File.createNew(path)Create new file, fail if exists
File.openWithOptions(path, opts)Open with custom OpenOptions
// Read an existing file
var input = try fs.File.open("data.csv");
defer input.close();
// Create (or overwrite) an output file
var output = try fs.File.create("report.csv");
defer output.close();
// Fail if the file already exists (safe for lock files, temp files)
var lock = try fs.File.createNew("/tmp/myapp.lock");
defer lock.close();
MethodSignatureDescription
readfn read(buf: []u8) !usizeRead up to buf.len bytes, returns bytes read
readAllfn readAll(buf: []u8) !usizeFill the buffer completely (retries on partial reads)
preadfn pread(buf: []u8, offset: u64) !usizeRead at a specific offset without seeking
readToEndfn readToEnd(allocator: Allocator) ![]u8Read entire remaining contents
readVectoredfn readVectored(iovecs: []posix.iovec) !usizeScatter read into multiple buffers
var file = try fs.File.open("binary.dat");
defer file.close();
// Read a fixed-size header
var header: [64]u8 = undefined;
const n = try file.readAll(&header);
if (n < 64) return error.UnexpectedEof;
// Read the rest into a dynamic buffer
const body = try file.readToEnd(allocator);
defer allocator.free(body);
MethodSignatureDescription
writefn write(data: []const u8) !usizeWrite up to data.len bytes, returns bytes written
writeAllfn writeAll(data: []const u8) !voidWrite all bytes (retries on partial writes)
pwritefn pwrite(data: []const u8, offset: u64) !usizeWrite at a specific offset without seeking
writeVectoredfn writeVectored(iovecs: []const posix.iovec_const) !usizeGather write from multiple buffers
var file = try fs.File.create("output.bin");
defer file.close();
try file.writeAll(&header_bytes);
try file.writeAll(payload);
try file.syncAll(); // Flush to disk
MethodSignatureDescription
seekTofn seekTo(offset: u64) !voidSeek to absolute position
seekByfn seekBy(offset: i64) !voidSeek relative to current position
rewindfn rewind() !voidSeek to the beginning
getPosfn getPos() !u64Get current position
getLenfn getLen() !u64Get file length
var file = try fs.File.open("index.bin");
defer file.close();
// Jump to a record by index
const record_size: u64 = 256;
try file.seekTo(record_idx * record_size);
var record: [256]u8 = undefined;
_ = try file.readAll(&record);
MethodSignatureDescription
syncAllfn syncAll() !voidFlush data + metadata to disk
syncDatafn syncData() !voidFlush data only (faster, skips metadata)
metadatafn metadata() !MetadataGet file metadata
setLenfn setLen(len: u64) !voidTruncate or extend the file
setPermissionsfn setPermissions(perms: Permissions) !voidSet file permissions
var file = try fs.File.create("important.dat");
defer file.close();
try file.writeAll(critical_data);
try file.syncAll(); // Ensure data survives a crash

Files support standard io.Reader and io.Writer interfaces for composition with buffered I/O, compression, serialization, etc.

var file = try fs.File.open("data.txt");
defer file.close();
var reader = file.reader();
// Use with any code that accepts an io.Reader
pub fn close(self: *File) void

Close the file handle. Always use defer file.close() immediately after opening.


An async file handle that integrates with the Volt runtime. On Linux with io_uring, file operations are truly asynchronous — submitted directly to the kernel’s completion ring. On other platforms (macOS, BSD, Windows), operations run on the blocking thread pool so they don’t stall the event loop.

MethodDescription
AsyncFile.open(io, path)Open for reading
AsyncFile.create(io, path)Create or truncate for writing
AsyncFile.createNew(io, path)Create new file, fail if exists
var file = try fs.AsyncFile.open(io, "large_dataset.csv");
defer file.close();
var buf: [8192]u8 = undefined;
const n = try file.read(&buf);

These are the async equivalents of the top-level convenience functions. They require an Io handle.

// Read entire file without blocking the event loop
const data = try fs.readFileAsync(io, allocator, "big_file.json");
defer allocator.free(data);
// Write without blocking
try fs.writeFileAsync(io, "output.dat", processed_data);
// Append without blocking
try fs.appendFileAsync(io, "events.log", log_line);
FunctionSignatureDescription
readFileAsyncfn(io, allocator, path) ![]u8Read entire file asynchronously
writeFileAsyncfn(io, path, data) !voidWrite file asynchronously
appendFileAsyncfn(io, path, data) !voidAppend to file asynchronously

Builder pattern for fine-grained control over how files are opened. Every setter returns a new OpenOptions, so calls can be chained.

FieldDefaultDescription
readfalseOpen for reading
writefalseOpen for writing
appendfalseWrites go to end of file
truncatefalseTruncate file to 0 bytes on open
createfalseCreate file if it doesn’t exist
create_newfalseCreate file, fail if it already exists
mode0o666Unix permissions for new files (modified by umask)
custom_flags0Platform-specific open flags
// Read + write (update in place)
var file = try fs.OpenOptions.new()
.setRead(true)
.setWrite(true)
.open("database.db");
defer file.close();
// Append-only log file
var log = try fs.OpenOptions.new()
.setWrite(true)
.setCreate(true)
.setAppend(true)
.open("audit.log");
defer log.close();
// Create with restricted permissions (Unix)
var secret = try fs.OpenOptions.new()
.setWrite(true)
.setCreateNew(true)
.setMode(0o600) // owner read/write only
.open("/etc/myapp/secret.key");
defer secret.close();

pub fn createDir(path: []const u8) !void

Create a single directory. Fails if the parent doesn’t exist or the directory already exists.

pub fn createDirAll(path: []const u8) !void

Create a directory and all missing parent directories. Succeeds silently if the directory already exists.

// Ensure the full path exists before writing
try fs.createDirAll("data/exports/2026/02");
try fs.writeFile("data/exports/2026/02/report.csv", csv_data);
pub fn createDirMode(path: []const u8, mode: u32) !void

Create a directory with specific Unix permissions.

try fs.createDirMode("/var/run/myapp", 0o755);
pub fn removeDir(path: []const u8) !void

Remove an empty directory. Fails if the directory is not empty.

pub fn removeDirAll(allocator: Allocator, path: []const u8) !void

Remove a directory and all its contents recursively. Use with caution.

// Clean up temp directory after processing
try fs.removeDirAll(allocator, "/tmp/myapp-work");
pub fn removeFile(path: []const u8) !void

Remove a single file.

pub fn readDir(path: []const u8) !ReadDir

Open a directory for iteration. Returns a ReadDir iterator.

var iter = try fs.readDir("uploads/");
defer iter.close();
while (try iter.next()) |entry| {
if (entry.isFile()) {
std.debug.print("File: {s}\n", .{entry.name});
}
}

Iterator over directory entries. Call next() to get the next DirEntry, or null when done.

MethodDescription
next() !?DirEntryGet next entry, or null at end
close()Close the directory handle

A single entry from a directory listing.

Field/MethodDescription
nameEntry name (filename only, not full path)
isFile()Returns true if this is a regular file
isDir()Returns true if this is a directory
isSymlink()Returns true if this is a symbolic link

Builder for creating directories with options.

var builder = fs.DirBuilder.new();
builder.setRecursive(true);
builder.setMode(0o750);
try builder.create("path/to/dir");

pub fn metadata(path: []const u8) !Metadata

Get metadata for a file or directory. Follows symlinks.

const meta = try fs.metadata("data.db");
std.debug.print("Size: {} bytes\n", .{meta.size()});
std.debug.print("Type: {s}\n", .{if (meta.isFile()) "file" else "directory"});
pub fn symlinkMetadata(path: []const u8) !Metadata

Get metadata for a path without following symlinks. If the path is a symlink, returns metadata about the symlink itself.

MethodReturn TypeDescription
fileType()FileTypeFile, directory, symlink, etc.
isFile()boolIs a regular file?
isDir()boolIs a directory?
isSymlink()boolIs a symbolic link?
size()u64Size in bytes
permissions()PermissionsFile permissions
modified()i64Last modified (Unix epoch seconds)
accessed()i64Last accessed (Unix epoch seconds)
created()?i64Creation time (platform-dependent, may be null)
modifiedTime()SystemTimeLast modified as SystemTime
accessedTime()SystemTimeLast accessed as SystemTime
const meta = try fs.metadata("report.pdf");
// Check age
const mtime = meta.modifiedTime();
if (mtime.elapsed().asSecs() > 86400) {
std.debug.print("File is more than a day old\n", .{});
}
// Check size
if (meta.size() > 100 * 1024 * 1024) {
std.debug.print("Warning: file is over 100 MB\n", .{});
}

File permission bits. Provides methods for querying Unix permission modes.

const meta = try fs.metadata("script.sh");
const perms = meta.permissions();
if (!perms.isExecutable()) {
try fs.setPermissions("script.sh", perms.withExecutable(true));
}
pub fn setPermissions(path: []const u8, perms: Permissions) !void

Set permissions on a file or directory.

Enum representing the type of a filesystem entry:

VariantDescription
.fileRegular file
.directoryDirectory
.sym_linkSymbolic link
.block_deviceBlock device
.character_deviceCharacter device
.named_pipeNamed pipe (FIFO)
.unix_domain_socketUnix domain socket
.unknownUnknown type

A point in time from the filesystem. Provides ergonomic methods for working with timestamps.

MethodDescription
elapsed()Duration since this time (returns Duration)
durationSince(other)Duration between two system times

pub fn hardLink(src: []const u8, dst: []const u8) !void

Create a hard link. Both paths will point to the same underlying data.

pub fn symlink(target: []const u8, link_path: []const u8) !void

Create a symbolic link at link_path pointing to target.

try fs.symlink("config.production.toml", "config.toml");
pub fn readLink(allocator: Allocator, path: []const u8) ![]u8

Read the target of a symbolic link. Caller owns the returned path.

const target = try fs.readLink(allocator, "config.toml");
defer allocator.free(target);
std.debug.print("config.toml -> {s}\n", .{target});

pub fn canonicalize(allocator: Allocator, path: []const u8) ![]u8

Resolve a path to its canonical, absolute form. Resolves all symlinks and removes . and .. components.

const abs = try fs.canonicalize(allocator, "../data/input.csv");
defer allocator.free(abs);
// e.g., "/home/user/project/data/input.csv"

Write to a temporary file, then atomically rename it into place. This prevents readers from ever seeing a partially-written file:

const fs = @import("volt").fs;
fn atomicWrite(path: []const u8, data: []const u8) !void {
const tmp_path = path ++ ".tmp";
try fs.writeFile(tmp_path, data);
try fs.rename(tmp_path, path);
}
const fs = @import("volt").fs;
fn loadConfig(allocator: std.mem.Allocator) ![]u8 {
// Try local override first, then fall back to default
const path = if (fs.tryExists("config.local.toml"))
"config.local.toml"
else if (fs.tryExists("config.toml"))
"config.toml"
else
return error.NoConfigFile;
return fs.readFile(allocator, path);
}
const fs = @import("volt").fs;
fn rotateLogIfNeeded(log_path: []const u8, max_size: u64) !void {
const meta = fs.metadata(log_path) catch return; // No file = nothing to rotate
if (meta.size() < max_size) return;
// Rotate: current -> .1, .1 -> .2, etc.
fs.rename(log_path ++ ".1", log_path ++ ".2") catch {};
try fs.rename(log_path, log_path ++ ".1");
}

Process all files in a directory tree:

const std = @import("std");
const fs = @import("volt").fs;
fn processDirectory(allocator: std.mem.Allocator, path: []const u8) !void {
var iter = try fs.readDir(path);
defer iter.close();
while (try iter.next()) |entry| {
if (entry.isDir()) {
// Recurse into subdirectories
const subpath = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.name });
defer allocator.free(subpath);
try processDirectory(allocator, subpath);
} else if (entry.isFile()) {
std.debug.print("Processing: {s}/{s}\n", .{ path, entry.name });
}
}
}

Use AsyncFile inside the runtime to process many files without blocking I/O workers:

const volt = @import("volt");
const fs = volt.fs;
fn processFiles(io: volt.Io, paths: []const []const u8) void {
for (paths) |path| {
const f = io.@"async"(struct {
fn run(io_inner: volt.Io, p: []const u8) void {
// AsyncFile uses io_uring on Linux, blocking pool elsewhere
const data = fs.readFileAsync(io_inner, std.heap.page_allocator, p) catch return;
defer std.heap.page_allocator.free(data);
// Process the data...
}
}.run, .{ io, path });
f.detach(); // fire-and-forget: each file processes independently
}
}