Skip to main content
Rust beginner Lesson 26 of 30

File I/O in Rust

Read and write files, work with paths, handle directories, and use buffered I/O in Rust.

Reading a File

Reading a file is one of the most common I/O tasks. fs::read_to_string is the simplest option — it opens the file, reads the entire contents into a String, and closes it in one call. It returns a Result because the file might not exist, you might lack permission, or any number of other OS-level failures can occur. Always handle the error rather than unwrapping in production code.

use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    // Read entire file into a String — simple but loads the whole file into memory
    let content = fs::read_to_string("hello.txt")?;
    println!("file contents:\n{}", content);

    // Read as raw bytes when the file is not guaranteed to be UTF-8
    let bytes = fs::read("hello.txt")?;
    println!("file size: {} bytes", bytes.len());

    Ok(())
}

Writing a File

fs::write is the counterpart to fs::read_to_string — it writes a string or byte slice to a file, creating it if it does not exist and overwriting it if it does. For more control over what happens to an existing file, use OpenOptions.

use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    // Write a string — creates the file, truncates if it already exists
    fs::write("output.txt", "Hello, file!\n")?;

    // Write bytes
    fs::write("data.bin", &[0u8, 1, 2, 3, 4])?;

    // Append to an existing file using OpenOptions
    use std::fs::OpenOptions;
    use std::io::Write;

    let mut file = OpenOptions::new()
        .append(true)   // do not truncate — add to the end
        .create(true)   // create if it does not exist
        .open("output.txt")?;

    writeln!(file, "appended line")?;

    Ok(())
}

Buffered I/O

Reading a file line by line with a BufReader is far more memory-efficient than loading the whole file into a String. The buffer reads a chunk from the OS at a time (typically 8 KB) and serves lines from that chunk, making it suitable for files of any size. BufWriter provides the same benefit on the write side, batching small writes into fewer, larger OS calls.

use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};

fn main() -> Result<(), io::Error> {
    // BufReader — read line by line without loading the whole file
    let file = File::open("input.txt")?;
    let reader = BufReader::new(file);

    for (line_num, line) in reader.lines().enumerate() {
        let line = line?; // each line() call returns Result<String>
        println!("{}: {}", line_num + 1, line);
    }

    // BufWriter — batch small writes to reduce OS syscall overhead
    let out = File::create("output.txt")?;
    let mut writer = BufWriter::new(out);

    for i in 0..1000 {
        writeln!(writer, "line {}", i)?;
    }
    // BufWriter flushes automatically when dropped, but explicit flush is good practice
    writer.flush()?;

    Ok(())
}

Working with Paths

Raw string paths are fragile across operating systems — Windows uses \, Unix uses /, and concatenating paths with string operations is error-prone. Path and PathBuf are the correct abstractions. Path is a borrowed path slice (like &str), and PathBuf is an owned, growable path (like String).

use std::path::{Path, PathBuf};

fn main() {
    // PathBuf — owned, mutable path
    let mut path = PathBuf::from("/home/user");
    path.push("documents");        // joins with the OS separator
    path.push("report.txt");

    println!("{}", path.display()); // /home/user/documents/report.txt

    // Path queries
    println!("{:?}", path.file_name());      // Some("report.txt")
    println!("{:?}", path.extension());      // Some("txt")
    println!("{:?}", path.parent());         // Some("/home/user/documents")
    println!("{:?}", path.file_stem());      // Some("report")

    // Existence checks
    let p = Path::new("Cargo.toml");
    println!("exists: {}", p.exists());
    println!("is file: {}", p.is_file());
    println!("is dir: {}", p.is_dir());

    // Build paths from components — OS-agnostic
    let config = Path::new("config").join("app").join("settings.toml");
    println!("{}", config.display()); // config/app/settings.toml (or config\app\settings.toml on Windows)
}

Reading and Writing Structured Data

Most real programs need to read and write structured data, not raw text. Combining serde with a format crate like serde_json or toml lets you serialize and deserialize Rust structs directly. The derive macros generate all the conversion code — you just describe the shape of your data.

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
use std::fs;

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
    tags: Vec<String>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialize to JSON and write to file
    let config = Config {
        host: "localhost".to_string(),
        port: 8080,
        debug: true,
        tags: vec!["web".to_string(), "api".to_string()],
    };

    let json = serde_json::to_string_pretty(&config)?;
    fs::write("config.json", &json)?;
    println!("wrote config:\n{}", json);

    // Read file and deserialize back into a struct
    let content = fs::read_to_string("config.json")?;
    let loaded: Config = serde_json::from_str(&content)?;
    println!("loaded: {:?}", loaded);

    Ok(())
}

Directory Operations

Working with directories — listing contents, creating, removing, and walking recursively — uses the std::fs module. For recursive directory walking in production code, the walkdir crate is significantly more ergonomic than rolling your own recursion.

use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    // Create directories — create_dir_all creates intermediate directories too
    fs::create_dir_all("output/reports/2024")?;

    // List directory contents — returns an iterator of DirEntry
    for entry in fs::read_dir("src")? {
        let entry = entry?;
        let path = entry.path();
        let metadata = entry.metadata()?;

        if metadata.is_file() {
            println!("file: {} ({} bytes)", path.display(), metadata.len());
        } else if metadata.is_dir() {
            println!("dir:  {}", path.display());
        }
    }

    // Remove a file
    // fs::remove_file("output.txt")?;

    // Remove an empty directory
    // fs::remove_dir("output/reports/2024")?;

    // Remove a directory and all its contents
    // fs::remove_dir_all("output")?;

    Ok(())
}

Environment Variables and Temporary Files

Environment variables are the standard way to configure applications without hardcoding paths or secrets. The tempfile crate creates temporary files and directories that are automatically deleted when they go out of scope — essential for test fixtures and intermediate processing.

use std::env;
use std::io::{self, Write};

fn main() -> Result<(), io::Error> {
    // Read environment variables
    match env::var("HOME") {
        Ok(home) => println!("home: {}", home),
        Err(_) => println!("HOME not set"),
    }

    // Iterate all environment variables
    for (key, value) in env::vars() {
        if key.starts_with("RUST") {
            println!("{}={}", key, value);
        }
    }

    // Command-line arguments
    let args: Vec<String> = env::args().collect();
    println!("program: {}", args[0]);
    if args.len() > 1 {
        println!("first arg: {}", args[1]);
    }

    Ok(())
}

Copying, Moving, and Renaming Files

use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    // Copy a file — returns the number of bytes copied
    let bytes = fs::copy("source.txt", "destination.txt")?;
    println!("copied {} bytes", bytes);

    // Rename or move a file within the same filesystem
    fs::rename("old_name.txt", "new_name.txt")?;

    // Get file metadata
    let meta = fs::metadata("Cargo.toml")?;
    println!("size: {} bytes", meta.len());
    println!("readonly: {}", meta.permissions().readonly());
    println!("modified: {:?}", meta.modified()?);

    Ok(())
}

Reading Stdin

Reading from standard input is essential for CLI tools. stdin().lock() provides a buffered reader over stdin, and .lines() gives you an iterator that yields each line as the user types it.

use std::io::{self, BufRead};

fn main() {
    let stdin = io::stdin();

    // Read line by line until EOF (Ctrl+D on Unix, Ctrl+Z on Windows)
    for line in stdin.lock().lines() {
        match line {
            Ok(l) => println!("you typed: {}", l),
            Err(e) => {
                eprintln!("error reading stdin: {}", e);
                break;
            }
        }
    }
}

Frequently Asked Questions

What is the difference between fs::read_to_string and BufReader?
fs::read_to_string reads the entire file into a String at once — simple but uses memory proportional to file size. BufReader wraps a File and reads in chunks, which is more memory-efficient for large files or when you want to process line by line.
How do I handle file paths across operating systems?
Use std::path::Path and PathBuf instead of raw strings. They handle path separator differences (/ vs \) and provide methods for joining, extension manipulation, and querying path components.
What is the difference between File::create and File::open?
File::open opens an existing file for reading. File::create creates a new file for writing, truncating it if it already exists. For more control (append, read+write), use OpenOptions.