Skip to editor content
learningrust.orglesson 21 of 26

File I/O

Working with files is a fundamental skill in any systems language. Rust's standard library provides a powerful, ergonomic API for reading and writing files through std::fs and std::io. Because file operations can fail for many reasons — missing paths, permission errors, disk full — Rust's Result type makes error handling explicit and safe.

In this lesson you will learn how to read entire files, write content to disk, process files line-by-line, and append to existing files.

Reading Files

The simplest way to read a file into memory is fs::read_to_string. It opens the file, reads all bytes, decodes them as UTF-8, and returns a String.

use std::io::{self, Read, Cursor};

fn read_contents(reader: &mut impl Read) -> io::Result<String> {
    let mut contents = String::new();
    reader.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    // In a real program you would open a file:
    // let mut file = std::fs::File::open("hello.txt").unwrap();
    // Here we simulate a file with Cursor for the playground:
    let mut fake_file = Cursor::new("Hello, Rust!\nFile I/O is easy.\n");

    match read_contents(&mut fake_file) {
        Ok(contents) => println!("File contents:\n{}", contents),
        Err(e) => eprintln!("Error reading file: {}", e),
    }
}

Notice the function accepts impl Read rather than a concrete file type. This means the same function works with real files, network sockets, in-memory buffers, or anything else that implements the Read trait — a classic example of Rust's trait-based polymorphism.

Writing Files

Writing content to a file is equally straightforward with fs::write, which creates or overwrites a file in a single call.

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

fn write_report(writer: &mut impl Write, lines: &[&str]) -> io::Result<()> {
    for line in lines {
        writeln!(writer, "{}", line)?;
    }
    Ok(())
}

fn main() {
    // Real usage: let mut file = std::fs::File::create("report.txt").unwrap();
    let mut output = Cursor::new(Vec::new());

    let data = vec![
        "=== Monthly Report ===",
        "Items processed: 42",
        "Errors: 0",
        "Status: OK",
    ];

    write_report(&mut output, &data).expect("write failed");

    // Inspect what was written
    let written = String::from_utf8(output.into_inner()).unwrap();
    println!("{}", written);
}

The writeln! macro works exactly like println! but sends output to any Write implementor instead of stdout. The ? operator propagates I/O errors up the call stack cleanly.

Buffered I/O

Reading or writing one byte at a time causes many small system calls, which is slow. BufReader and BufWriter wrap any Read/Write source with an internal buffer, dramatically improving throughput for line-by-line processing.

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

fn count_lines(reader: impl io::Read) -> io::Result<usize> {
    let buffered = BufReader::new(reader);
    let mut count = 0;

    for line in buffered.lines() {
        let _line = line?; // propagate any I/O error
        count += 1;
    }

    Ok(count)
}

fn word_frequency(reader: impl io::Read) -> io::Result<Vec<(String, usize)>> {
    let buffered = BufReader::new(reader);
    let mut freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();

    for line in buffered.lines() {
        for word in line?.split_whitespace() {
            let key = word.to_lowercase().trim_matches(|c: char| !c.is_alphabetic()).to_string();
            if !key.is_empty() {
                *freq.entry(key).or_insert(0) += 1;
            }
        }
    }

    let mut result: Vec<(String, usize)> = freq.into_iter().collect();
    result.sort_by(|a, b| b.1.cmp(&a.1));
    Ok(result)
}

fn main() {
    let text = "the quick brown fox\njumps over the lazy dog\nthe fox was quick";
    let reader = Cursor::new(text);

    let line_count = count_lines(Cursor::new(text)).unwrap();
    println!("Lines: {}", line_count);

    let freq = word_frequency(reader).unwrap();
    println!("\nWord frequencies:");
    for (word, count) in freq.iter().take(5) {
        println!("  {:>10}: {}", word, count);
    }
}

BufReader::lines() returns an iterator of io::Result<String>. Each call to .lines() reads up to the next newline from the internal buffer, flushing to the underlying reader only when needed.

Appending to Files

When you want to add data to an existing file without overwriting it, use OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;

fn append_log(path: &str, message: &str) -> std::io::Result<()> {
    let mut file = OpenOptions::new()
        .create(true)   // create if it doesn't exist
        .append(true)   // don't truncate, seek to end
        .open(path)?;

    writeln!(file, "{}", message)?;
    Ok(())
}

OpenOptions is a builder that controls exactly how the file is opened. Common flags include .read(true), .write(true), .create(true), .truncate(true), and .append(true).

Error Handling Patterns

File I/O is one of the most common places you will use the ? operator and custom error types. A typical pattern uses Box<dyn Error> for quick scripts and a custom enum for libraries.

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

// Quick approach for scripts / main functions
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let contents = fs::read_to_string(path)?;
    Ok(contents)
}

// Library approach: define a domain error
#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(String),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

fn parse_numbers(path: &str) -> Result<Vec<i32>, AppError> {
    let contents = fs::read_to_string(path)?; // io::Error -> AppError via From
    contents
        .lines()
        .map(|line| {
            line.trim()
                .parse::<i32>()
                .map_err(|e| AppError::Parse(e.to_string()))
        })
        .collect()
}

Implementing From<io::Error> for your error type lets the ? operator convert automatically, keeping function bodies clean.

Try It Yourself

Build a simple CSV-style log parser that reads records, filters by a field, and writes matching rows to a new destination.

use std::io::{self, BufRead, BufReader, Write, Cursor};

struct Record {
    name: String,
    score: u32,
}

fn parse_record(line: &str) -> Option<Record> {
    let parts: Vec<&str> = line.splitn(2, ',').collect();
    if parts.len() != 2 {
        return None;
    }
    let name = parts[0].trim().to_string();
    let score = parts[1].trim().parse::<u32>().ok()?;
    Some(Record { name, score })
}

fn filter_high_scores(
    reader: impl io::Read,
    writer: &mut impl Write,
    threshold: u32,
) -> io::Result<usize> {
    let buffered = BufReader::new(reader);
    let mut written = 0;

    for line in buffered.lines() {
        let line = line?;
        if let Some(record) = parse_record(&line) {
            if record.score >= threshold {
                writeln!(writer, "{},{}", record.name, record.score)?;
                written += 1;
            }
        }
    }

    Ok(written)
}

fn main() {
    let csv_data = "\
Alice, 92
Bob, 55
Charlie, 88
Diana, 73
Eve, 97
Frank, 61";

    let reader = Cursor::new(csv_data);
    let mut output = Cursor::new(Vec::new());

    let count = filter_high_scores(reader, &mut output, 80).unwrap();

    let result = String::from_utf8(output.into_inner()).unwrap();
    println!("Records with score >= 80 ({} found):\n{}", count, result);
}

Try changing the threshold value or adding a new field to the record format.

Key Takeaways

  • fs::read_to_string and fs::write cover the most common file operations in a single call
  • Accept impl Read / impl Write in function signatures to write testable, reusable I/O code
  • Wrap readers and writers with BufReader / BufWriter when processing data line-by-line or in small chunks
  • Use OpenOptions when you need fine-grained control over how a file is opened (append, create, truncate)
  • All file operations return io::Result<T> — use ? to propagate errors and keep code clean
  • Implementing From<io::Error> for your error type enables seamless error conversion with ?

Pro Tip: Prefer designing your functions around impl Read and impl Write traits instead of std::fs::File directly. This makes unit testing trivial — swap the real file for a Cursor<Vec<u8>> — and keeps your logic decoupled from the file system. The pattern pays dividends the moment you need to process data from a network stream or an in-memory buffer without changing your parsing code.

Next Steps

Now that you can work with files, we'll explore macros — Rust's metaprogramming feature that lets you write code that generates code.

Next lesson

Macros

Learn how to use and create Rust macros for metaprogramming and code generation at compile time

25 min