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 (like String)
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

MethodBest For
fs::read_to_string()Small text files (under a few MB)
BufReaderLarge files, line-by-line processing
fs::read()Binary files (images, archives)
fs::write()Writing small content at once
BufWriterWriting many lines or large output
OpenOptionsAppending, 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.