Skip to content
AgileLogics
6 min read

File I/O

Prerequisites: Shared state: Arc & Mutex

File operations live in std::fs (whole-file helpers and the File type) and std::io (reading/writing traits and buffering). Every operation that touches the file system can fail, so they all return io::Result<T>, which is short for Result<T, io::Error>. That makes ? the natural way to write file code.

Syntax

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

fn demo() -> io::Result<()> {
    fs::write("notes.txt", "first line\n")?;                // create or overwrite
    let text = fs::read_to_string("notes.txt")?;           // whole file as a String

    let mut file = OpenOptions::new().append(true).open("notes.txt")?;
    writeln!(file, "second line")?;                        // append with formatting

    let reader = BufReader::new(File::open("notes.txt")?);
    for line in reader.lines() {                           // one line at a time
        let line = line?;                                  // each line is an io::Result<String>
        println!("{line}");
    }
    print!("{text}");
    fs::remove_file("notes.txt")?;
    Ok(())
}

fn main() {
    demo().unwrap();
}
  • fs::read_to_string and fs::write are the simplest choice for small files.
  • For large files, wrap a File in a BufReader and read line by line, so the whole file never sits in memory at once. .lines() strips the line endings.
  • File::create(path) opens a file for writing (truncating it); OpenOptions lets you choose append, create, truncate and so on.
  • Writing with write!/writeln! needs the Write trait in scope; reading lines needs BufRead.
  • Files are closed automatically when the File value is dropped.

Example

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

fn write_report(path: &str) -> io::Result<()> {
    let file = File::create(path)?;
    let mut out = BufWriter::new(file); // batches many small writes into few system calls
    writeln!(out, "name,score")?;
    for (name, score) in [("ada", 92), ("grace", 88), ("linus", 75)] {
        writeln!(out, "{name},{score}")?;
    }
    out.flush()?; // push anything still buffered to the file
    Ok(())
}

fn average_score(path: &str) -> io::Result<f64> {
    let reader = BufReader::new(File::open(path)?);
    let mut total = 0.0;
    let mut count = 0;
    for line in reader.lines().skip(1) {
        let line = line?;
        if let Some((_, score)) = line.split_once(',') {
            let score: f64 = score
                .trim()
                .parse()
                .map_err(|_| io::Error::new(ErrorKind::InvalidData, format!("bad score in '{line}'")))?;
            total += score;
            count += 1;
        }
    }
    Ok(total / count as f64)
}

fn main() -> io::Result<()> {
    let path = "scores.csv";
    write_report(path)?;

    let contents = fs::read_to_string(path)?;
    println!("file has {} lines", contents.lines().count());
    println!("average: {:.1}", average_score(path)?);

    match fs::read_to_string("does-not-exist.txt") {
        Ok(_) => println!("unexpectedly found it"),
        Err(e) if e.kind() == ErrorKind::NotFound => println!("missing file handled"),
        Err(e) => return Err(e),
    }

    fs::remove_file(path)?;
    println!("cleaned up: {}", !fs::exists(path)?);
    Ok(())
}

Expected output

file has 4 lines
average: 85.0
missing file handled
cleaned up: true

Relative paths such as "scores.csv" are resolved against the current working directory — the directory you ran cargo run from, not the location of the source file. For portable paths built from parts, use std::path::Path and PathBuf (Path::new("data").join("scores.csv")).

Common mistakes

  • Calling writeln! on a file without importing std::io::Write: error[E0599]: cannot write into `File` with a hint to add use std::io::Write;. The same applies to BufRead for .lines().
  • Using ? in a main that returns (). Declare fn main() -> io::Result<()> (or Result<(), Box<dyn Error>>) and end with Ok(()).
  • Forgetting that File::create truncates an existing file. Use OpenOptions::new().append(true) to add to the end instead.
  • Forgetting to handle each line's Result: for line in reader.lines() yields io::Result<String>, so write let line = line?; before using it.
  • Reading a huge file with read_to_string. Stream it with BufReader instead.
  • Assuming a BufWriter has written everything before you read the file back. Call flush() (or drop the writer) first.

Practice

Write a program that saves three lines of text to todo.txt, appends a fourth line with OpenOptions, then reads the file back with a BufReader and prints each line numbered from 1. Delete the file at the end.

Show solution
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};

fn main() -> io::Result<()> {
    let path = "todo.txt";
    fs::write(path, "buy milk\nwrite tests\nread chapter 16\n")?;

    let mut file = OpenOptions::new().append(true).open(path)?;
    writeln!(file, "go for a walk")?;
    drop(file); // close before reading

    let reader = BufReader::new(File::open(path)?);
    for (i, line) in reader.lines().enumerate() {
        println!("{}. {}", i + 1, line?);
    }

    fs::remove_file(path)?;
    Ok(())
}
1. buy milk
2. write tests
3. read chapter 16
4. go for a walk