In the previous tutorial, we built CLI tools with Clap. Now we learn File I/O — reading files, writing files, working with paths, and walking directories.
File I/O is something every program needs. Rust makes it safe and explicit. Every file operation returns a Result, so you always handle errors. No silent failures. No corrupted data.
Path and PathBuf
Before reading or writing files, you need to understand paths. Rust has two path types:
Path— a borrowed reference to a path (like&str)PathBuf— an owned path (likeString)
use std::path::{Path, PathBuf};
fn main() {
// Path is borrowed — created from a string
let path = Path::new("/home/user/documents/report.txt");
println!("File name: {:?}", path.file_name()); // "report.txt"
println!("Extension: {:?}", path.extension()); // "txt"
println!("Stem: {:?}", path.file_stem()); // "report"
println!("Parent: {:?}", path.parent()); // "/home/user/documents"
println!("Is absolute: {}", path.is_absolute()); // true
// PathBuf is owned — you can modify it
let mut buf = PathBuf::from("/home/user");
buf.push("documents");
buf.push("report.txt");
println!("Built: {}", buf.display()); // /home/user/documents/report.txt
// Change extension
buf.set_extension("pdf");
println!("Changed: {}", buf.display()); // /home/user/documents/report.pdf
}
Use Path in function parameters (borrows the path). Use PathBuf when you need to own or build a path.
Joining Paths
Never concatenate path strings manually. Use join():
use std::path::Path;
fn main() {
let base = Path::new("/home/user");
let full = base.join("documents").join("report.txt");
println!("{}", full.display());
// /home/user/documents/report.txt
}
join() handles path separators for you. It works on Linux, macOS, and Windows.
Path Components
You can iterate over the parts of a path:
use std::path::Path;
fn main() {
let path = Path::new("/home/user/file.txt");
for component in path.components() {
println!("{:?}", component);
}
// RootDir, Normal("home"), Normal("user"), Normal("file.txt")
// Ancestors: parent, grandparent, etc.
for ancestor in path.ancestors() {
println!("{}", ancestor.display());
}
// /home/user/file.txt
// /home/user
// /home
// /
}
Reading Files
Read Entire File as String
The simplest way to read a file:
use std::fs;
use std::path::Path;
fn main() -> std::io::Result<()> {
let content = fs::read_to_string("config.txt")?;
println!("File has {} characters", content.len());
Ok(())
}
fs::read_to_string() reads the entire file into a String. It returns Result<String, io::Error>. This works well for small files. For large files, use buffered reading.
Read as Bytes
For binary files, read raw bytes:
use std::fs;
fn main() -> std::io::Result<()> {
let bytes = fs::read("image.png")?;
println!("File is {} bytes", bytes.len());
Ok(())
}
Buffered Reading with BufReader
For large files, read line by line with BufReader:
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
fn count_lines(path: &Path) -> std::io::Result<usize> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut count = 0;
for line in reader.lines() {
let _line = line?; // each line is a Result
count += 1;
}
Ok(count)
}
fn main() -> std::io::Result<()> {
let path = Path::new("large_file.txt");
let lines = count_lines(path)?;
println!("File has {} lines", lines);
Ok(())
}
BufReader wraps a file and adds an internal buffer. Instead of making a system call for every byte, it reads chunks at a time. This is much faster for line-by-line reading.
Read First N Lines
Sometimes you only need the beginning of a file:
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
fn read_first_n_lines(path: &Path, n: usize) -> std::io::Result<Vec<String>> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let lines: Result<Vec<_>, _> = reader.lines().take(n).collect();
lines
}
take(n) stops after n lines. The file is not read beyond that point.
Writing Files
Write Entire File
The simplest way:
use std::fs;
fn main() -> std::io::Result<()> {
fs::write("output.txt", "Hello, World!\n")?;
Ok(())
}
This creates the file if it does not exist. If it exists, it overwrites the content completely.
Buffered Writing with BufWriter
For writing many lines, use BufWriter:
use std::fs;
use std::io::{BufWriter, Write};
use std::path::Path;
fn write_report(path: &Path, data: &[(&str, u32)]) -> std::io::Result<()> {
let file = fs::File::create(path)?;
let mut writer = BufWriter::new(file);
writeln!(writer, "Name,Score")?;
for (name, score) in data {
writeln!(writer, "{},{}", name, score)?;
}
writer.flush()?;
Ok(())
}
fn main() -> std::io::Result<()> {
let data = vec![
("Alex", 95),
("Sam", 87),
("Jordan", 92),
];
write_report(Path::new("report.csv"), &data)?;
Ok(())
}
BufWriter collects writes in a buffer and flushes them in chunks. Always call flush() at the end. Without it, the last chunk might not be written. When BufWriter is dropped, it flushes automatically, but errors from that flush are silently ignored.
Appending to Files
To add content without overwriting:
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
fn append_log(path: &Path, message: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(path)?;
writeln!(file, "{}", message)?;
Ok(())
}
fn main() -> std::io::Result<()> {
append_log(Path::new("app.log"), "Server started")?;
append_log(Path::new("app.log"), "Request received")?;
Ok(())
}
OpenOptions gives you fine control:
.append(true)— write at the end, do not overwrite.create(true)— create the file if it does not exist.read(true)— open for reading.write(true)— open for writing (overwrites from the start).truncate(true)— clear the file on open
File Operations
Copy, Rename, Delete
use std::fs;
use std::path::Path;
fn main() -> std::io::Result<()> {
// Copy a file — returns bytes copied
let bytes_copied = fs::copy("source.txt", "backup.txt")?;
println!("Copied {} bytes", bytes_copied);
// Rename or move a file
fs::rename("old_name.txt", "new_name.txt")?;
// Delete a file
fs::remove_file("temp.txt")?;
Ok(())
}
fs::rename() works as a move operation too. You can rename a file into a different directory.
File Metadata
Get information about a file without reading it:
use std::fs;
use std::path::Path;
fn print_file_info(path: &Path) -> std::io::Result<()> {
let metadata = fs::metadata(path)?;
println!("Size: {} bytes", metadata.len());
println!("Is file: {}", metadata.is_file());
println!("Is directory: {}", metadata.is_dir());
println!("Read-only: {}", metadata.permissions().readonly());
if let Ok(modified) = metadata.modified() {
println!("Last modified: {:?}", modified);
}
Ok(())
}
Directory Operations
Create Directories
use std::fs;
fn main() -> std::io::Result<()> {
// Create directory and all parents (like mkdir -p)
fs::create_dir_all("output/reports/2026")?;
// Create only one directory (parent must exist)
fs::create_dir("output/reports/2026/march")?;
Ok(())
}
create_dir_all() is usually what you want. It creates parent directories and does nothing if the directory already exists.
List Directory Contents
use std::fs;
use std::path::Path;
fn list_files(dir: &Path) -> std::io::Result<Vec<String>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name() {
files.push(name.to_string_lossy().to_string());
}
}
}
files.sort();
Ok(files)
}
Filter by Extension
use std::fs;
use std::path::{Path, PathBuf};
fn find_by_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
let mut matches = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(file_ext) = path.extension() {
if file_ext == ext {
matches.push(path);
}
}
}
}
matches.sort();
Ok(matches)
}
fn main() -> std::io::Result<()> {
let rust_files = find_by_extension(Path::new("src"), "rs")?;
for f in &rust_files {
println!("{}", f.display());
}
Ok(())
}
Walking Directories Recursively
fs::read_dir() only lists one level. To find all files in nested directories, use recursion:
use std::fs;
use std::path::{Path, PathBuf};
fn walk_directory(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut result = Vec::new();
walk_recursive(dir, &mut result)?;
result.sort();
Ok(result)
}
fn walk_recursive(dir: &Path, result: &mut Vec<PathBuf>) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_recursive(&path, result)?;
} else {
result.push(path);
}
}
}
Ok(())
}
fn main() -> std::io::Result<()> {
let all_files = walk_directory(Path::new("src"))?;
println!("Found {} files", all_files.len());
for f in &all_files {
println!(" {}", f.display());
}
Ok(())
}
Calculate Directory Size
use std::fs;
use std::path::Path;
fn directory_size(dir: &Path) -> std::io::Result<u64> {
let files = walk_directory(dir)?;
let mut total = 0u64;
for file in &files {
if let Ok(meta) = fs::metadata(file) {
total += meta.len();
}
}
Ok(total)
}
Temp Files
For temporary data, use the system temp directory:
use std::fs;
use std::path::PathBuf;
fn create_temp_file(prefix: &str, content: &str) -> std::io::Result<PathBuf> {
let dir = std::env::temp_dir();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!("{}_{}.tmp", prefix, timestamp);
let path = dir.join(name);
fs::write(&path, content)?;
Ok(path)
}
fn main() -> std::io::Result<()> {
let tmp = create_temp_file("myapp", "temporary data")?;
println!("Created: {}", tmp.display());
// Clean up when done
fs::remove_file(&tmp)?;
Ok(())
}
For production code, consider the tempfile crate. It handles cleanup automatically and guarantees unique names.
Error Handling Patterns
File I/O functions return io::Result<T>. Here are common patterns:
Match on Specific Errors
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
fn read_or_create(path: &Path, default: &str) -> std::io::Result<String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == ErrorKind::NotFound => {
fs::write(path, default)?;
Ok(default.to_string())
}
Err(e) => Err(e),
}
}
You can match on ErrorKind::NotFound, ErrorKind::PermissionDenied, ErrorKind::AlreadyExists, and more.
Create Parent Directories Before Writing
use std::fs;
use std::path::Path;
fn safe_write(path: &Path, content: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
fs::write(path, content)
}
This prevents “directory not found” errors.
When to Use What
| Method | Best For |
|---|---|
fs::read_to_string() | Small text files (under a few MB) |
BufReader | Large files, line-by-line processing |
fs::read() | Binary files (images, archives) |
fs::write() | Writing small content at once |
BufWriter | Writing many lines or large output |
OpenOptions | Appending, custom open modes |
A simple rule: if the file might be larger than 10 MB, use BufReader or BufWriter.
Source Code
You can find the complete source code for this tutorial on GitHub:
kemalcodes/rust-tutorial (branch: tutorial-25-file-io)
What’s Next?
In the next tutorial, we learn Macros — writing macro_rules! for code generation, repetition patterns, and practical examples.