Guide contents

Lesson 52 of 94

Reading & Writing Files

File I/O in Rust is split between two modules: std::fs works with whole files and the filesystem, and std::io defines the traits for reading and writing streams of bytes (Read, BufRead, Write, Seek). Every operation that can fail returns io::Result<T>, which is Result<T, io::Error>. The error handling from the previous section applies directly, and ? does the propagating.

Task Rust
Read a whole file as text fs::read_to_string(path)
Write text to a file fs::write(path, text)
Read a whole file as bytes fs::read(path)
Read line by line BufReader::new(file).lines() or read_line
Write formatted output write! / writeln! / write_all
Buffered writing BufWriter
Append to a file OpenOptions::new().append(true)
Close a file automatic: a file closes when it goes out of scope
Error type io::Error
File not found io::ErrorKind::NotFound
Byte streams the Read / Write traits
Seeking the Seek trait

The examples create files with relative names such as notes.txt. Run with cargo run, they appear in the project directory, next to Cargo.toml.

Part 1: Whole files

1. Writing and reading a whole file

fs::write creates the file, or replaces its contents if it exists. fs::read_to_string reads it back into a String. main returns io::Result<()>, so ? can pass any error up, and the program exits with an error message if one happens.

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

fn main() -> io::Result<()> {
    fs::write("greeting.txt", "Hello from Rust!\nSecond line\n")?;

    let text = fs::read_to_string("greeting.txt")?;
    print!("{text}");
    println!("{} bytes, {} lines", text.len(), text.lines().count());
    Ok(())
}
Output
Hello from Rust!
Second line
29 bytes, 2 lines

Both functions accept anything path-like: a &str, a String, or a Path (the next lesson covers paths).

2. When the file isn't there

A failed read returns an io::Error. Its kind() tells you what went wrong, so you can handle one case, such as a missing file, and pass the rest on. It's an ordinary value.

Rust
use std::fs;
use std::io::{self, ErrorKind};

fn load_notes(path: &str) -> io::Result<String> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::from("(no notes yet)")),
        Err(e) => Err(e),
    }
}

fn main() -> io::Result<()> {
    println!("{:?}", load_notes("missing.txt")?);
    fs::write("notes.txt", "buy milk")?;
    println!("{:?}", load_notes("notes.txt")?);

    let err = fs::read_to_string("missing.txt").unwrap_err();
    println!("kind: {:?}", err.kind());
    println!("message: {err}");
    Ok(())
}
Output
"(no notes yet)"
"buy milk"
kind: NotFound
message: No such file or directory (os error 2)

Other common kinds are PermissionDenied, AlreadyExists, InvalidData, and UnexpectedEof. Prefer trying the operation and handling the error over checking exists() first: another program can delete or create the file between your check and your read.

3. Error messages that name the file

Look at the message above: No such file or directory (os error 2). It doesn't say which file. In an application with many files, add that yourself. With anyhow (from thiserror & anyhow), with_context wraps the error with a message:

Rustexits with an error
use anyhow::{Context, Result};
use std::fs;

fn read_config(path: &str) -> Result<String> {
    fs::read_to_string(path).with_context(|| format!("could not read config file {path:?}"))
}

fn main() -> Result<()> {
    let config = read_config("settings.toml")?;
    println!("{config}");
    Ok(())
}
Output
Error: could not read config file "settings.toml"

Caused by:
    No such file or directory (os error 2)

In a library, give your error enum a variant that stores the path next to the io::Error source, as Custom Error Types showed.

4. Bytes instead of text

fs::read returns the raw bytes as a Vec<u8>, and fs::write accepts bytes as well as text. Here, slice patterns recognize file formats by their first bytes (their "magic number").

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

fn file_type(bytes: &[u8]) -> &'static str {
    match bytes {
        [0x89, b'P', b'N', b'G', ..] => "PNG image",
        [b'%', b'P', b'D', b'F', ..] => "PDF document",
        [b'P', b'K', 3, 4, ..] => "ZIP archive (also .jar and .docx)",
        _ => "unknown",
    }
}

fn main() -> io::Result<()> {
    fs::write("logo.png", [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])?;
    fs::write("report.pdf", b"%PDF-1.7 ...")?;
    fs::write("data.bin", vec![0u8; 16])?;

    for name in ["logo.png", "report.pdf", "data.bin"] {
        let bytes = fs::read(name)?;
        println!("{name:<10} {:>2} bytes  {}", bytes.len(), file_type(&bytes));
    }
    Ok(())
}
Output
logo.png    8 bytes  PNG image
report.pdf 12 bytes  PDF document
data.bin   16 bytes  unknown

5. Text that isn't valid UTF-8

A Rust String is always valid UTF-8, so read_to_string refuses a file in another encoding instead of silently producing garbage. String::from_utf8_lossy converts anyway, replacing invalid bytes with �.

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

fn main() -> io::Result<()> {
    fs::write("latin1.txt", [b'c', b'a', b'f', 0xE9, b'\n'])?; // "café" in ISO-8859-1, not UTF-8

    match fs::read_to_string("latin1.txt") {
        Ok(text) => println!("text: {text}"),
        Err(e) => println!("read_to_string failed: {e} ({:?})", e.kind()),
    }

    let bytes = fs::read("latin1.txt")?;
    println!("lossy: {}", String::from_utf8_lossy(&bytes).trim_end());
    Ok(())
}
Output
read_to_string failed: stream did not contain valid UTF-8 (InvalidData)
lossy: caf�

To read other encodings properly, decode the bytes with a crate such as encoding_rs.

Part 2: Readers and writers

6. Reading line by line

Reading a whole file is fine for small files. For large ones, wrap the File in a BufReader, which reads big chunks from disk and hands out one line at a time. Memory use stays constant however big the file is. lines() needs the BufRead trait in scope, and it removes the \n (or \r\n) from each line.

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

fn main() -> io::Result<()> {
    fs::write("app.log", "INFO started\nWARN disk 81%\nINFO GET /orders\nERROR db timeout\nINFO GET /health\nERROR db timeout\n")?;

    let reader = BufReader::new(File::open("app.log")?);
    let (mut info, mut warn, mut error) = (0, 0, 0);
    for (index, line) in reader.lines().enumerate() {
        let line = line?; // each line can fail, for example on invalid UTF-8
        if line.starts_with("ERROR") {
            error += 1;
            println!("line {}: {line}", index + 1);
        } else if line.starts_with("WARN") {
            warn += 1;
        } else {
            info += 1;
        }
    }
    println!("info={info} warn={warn} error={error}");
    Ok(())
}
Output
line 4: ERROR db timeout
line 6: ERROR db timeout
info=3 warn=1 error=2

7. Reusing one buffer with read_line

lines() allocates a new String for every line. read_line appends the next line to a buffer you own, so one allocation serves the whole file. It returns the number of bytes read, and 0 at the end of the file. The line keeps its \n, and the buffer must be cleared before the next call.

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

fn main() -> io::Result<()> {
    fs::write("numbers.txt", "10\n20\n  30  \nx\n40\n")?;

    let mut reader = BufReader::new(File::open("numbers.txt")?);
    let mut line = String::new();
    let (mut sum, mut bad) = (0, 0);
    while reader.read_line(&mut line)? > 0 {
        match line.trim().parse::<i64>() {
            Ok(n) => sum += n,
            Err(_) => bad += 1,
        }
        line.clear(); // read_line appends, so empty the buffer each time
    }
    println!("sum={sum}, unparsable lines={bad}");
    Ok(())
}
Output
sum=100, unparsable lines=1

8. Writing with the Write trait, and automatic closing

File::create opens a file for writing, creating it or emptying it. The write! and writeln! macros work like format! but write into anything that implements Write; write_all writes raw bytes. There's no close(): a file is closed when its variable goes out of scope.

Rust
use std::fs::{self, File};
use std::io::{self, Write};

fn main() -> io::Result<()> {
    {
        let mut file = File::create("invoice.txt")?;
        writeln!(file, "INVOICE #{}", 1042)?;
        for (item, cents) in [("keyboard", 4_999), ("mouse", 1_950)] {
            writeln!(file, "{item:<10}{:>8.2}", cents as f64 / 100.0)?;
        }
        file.write_all(b"-- thank you --\n")?;
    } // `file` goes out of scope here, which closes it

    print!("{}", fs::read_to_string("invoice.txt")?);
    Ok(())
}
Output
INVOICE #1042
keyboard     49.99
mouse        19.50
-- thank you --

The methods come from the Write trait, so it must be imported. Forgetting is a common first error, and the compiler says exactly what's missing:

Rust✗ does not compile · E0599
use std::fs::File;

fn main() -> std::io::Result<()> {
    let mut file = File::create("hello.txt")?;
    file.write_all(b"hello")?;
    Ok(())
}
Output
error[E0599]: no method named `write_all` found for struct `File` in the current scope
 --> src/main.rs:5:10
  |
5 |     file.write_all(b"hello")?;
  |          ^^^^^^^^^
  |
 --> /rustc/<version>/library/std/src/io/mod.rs:1875:7
  |
  = note: the method is available for `File` here
  |
  = help: items from traits can only be used if the trait is in scope
help: there is a method `write_all_at` with a similar name, but with different arguments
 --> /rustc/<version>/library/std/src/os/unix/fs.rs:330:4
help: trait `Write` which provides `write_all` is implemented but not in scope; perhaps you want to import it
  |
1 + use std::io::Write;
  |

9. BufWriter for many small writes

Every write on a File is a separate request to the operating system. BufWriter collects writes in memory and passes them on in large chunks; for many small writes, the difference is large. Call flush() when you're done: dropping a BufWriter flushes too, but it has no way to report an error at that point, so a failed write would go unnoticed.

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

fn main() -> io::Result<()> {
    let mut out = BufWriter::new(File::create("squares.csv")?);
    writeln!(out, "n,square")?;
    for n in 1..=10_000u64 {
        writeln!(out, "{n},{}", n * n)?;
    }
    out.flush()?; // report write errors instead of losing them

    let text = fs::read_to_string("squares.csv")?;
    println!("{} lines, {} bytes", text.lines().count(), text.len());
    println!("last line: {}", text.lines().last().unwrap_or(""));
    Ok(())
}
Output
10001 lines, 134285 bytes
last line: 10000,100000000

10. Appending, and refusing to overwrite

OpenOptions chooses exactly how to open a file: append, create, create_new (fail if it exists), truncate, read, and write. File::open and File::create are shortcuts for the two most common combinations.

Rust
use std::fs::{self, OpenOptions};
use std::io::{self, ErrorKind, Write};

fn append_line(path: &str, line: &str) -> io::Result<()> {
    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
    writeln!(file, "{line}")
}

fn main() -> io::Result<()> {
    let _ = fs::remove_file("audit.log"); // start fresh; ignore "not found"
    append_line("audit.log", "ana logged in")?;
    append_line("audit.log", "ana exported report")?;
    append_line("audit.log", "ana logged out")?;
    print!("{}", fs::read_to_string("audit.log")?);

    // create_new: fail if the file already exists.
    match OpenOptions::new().write(true).create_new(true).open("audit.log") {
        Ok(_) => println!("created"),
        Err(e) if e.kind() == ErrorKind::AlreadyExists => println!("audit.log already exists, not overwriting"),
        Err(e) => return Err(e),
    }
    Ok(())
}
Output
ana logged in
ana exported report
ana logged out
audit.log already exists, not overwriting

let _ = fs::remove_file(...) deliberately ignores the result: if there was no file to remove, that's fine.

11. Functions that accept any reader

A function that takes a file path can only read files. One that takes impl BufRead works with a file, standard input, a network connection, or bytes in memory. That makes it trivial to test: a &[u8] is a BufRead, so tests pass text directly.

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

// Works with any buffered input: a file, stdin, a socket, or bytes in memory.
fn longest_line(input: impl BufRead) -> io::Result<Option<String>> {
    let mut best: Option<String> = None;
    for line in input.lines() {
        let line = line?;
        // Keep `line` if there's no best yet, or if it's longer than the best.
        if best.as_ref().is_none_or(|b| line.len() > b.len()) {
            best = Some(line);
        }
    }
    Ok(best)
}

fn main() -> io::Result<()> {
    // In a test: bytes in memory, no file needed.
    println!("{:?}", longest_line("short\nthe longest line\nmid-sized\n".as_bytes())?);
    println!("{:?}", longest_line("".as_bytes())?);

    // In the program: a real file.
    fs::write("poem.txt", "Roses are red\nViolets are blue\nRust is fast\n")?;
    println!("{:?}", longest_line(BufReader::new(File::open("poem.txt")?))?);
    Ok(())
}
Output
Some("the longest line")
None
Some("Violets are blue")

std::io::Cursor is the other common in-memory reader: it wraps a Vec<u8> or String and also supports seeking.

12. Functions that accept any writer

The same idea works for output. A report function that takes &mut impl Write can write to a file, to the terminal, or into a Vec<u8>, which is how tests check output.

Rust
use std::fs::{self, File};
use std::io::{self, Write};

struct Sale {
    region: &'static str,
    cents: u64,
}

fn write_report(out: &mut impl Write, sales: &[Sale]) -> io::Result<()> {
    writeln!(out, "{:<8}{:>10}", "region", "total")?;
    for s in sales {
        writeln!(out, "{:<8}{:>10.2}", s.region, s.cents as f64 / 100.0)?;
    }
    let total: u64 = sales.iter().map(|s| s.cents).sum();
    writeln!(out, "{:<8}{:>10.2}", "ALL", total as f64 / 100.0)
}

fn main() -> io::Result<()> {
    let sales = [Sale { region: "north", cents: 1_250_000 }, Sale { region: "south", cents: 980_050 }];

    let mut buffer = Vec::new(); // a Vec<u8> is a Write: perfect for tests
    write_report(&mut buffer, &sales)?;
    println!("{} bytes written to memory", buffer.len());

    write_report(&mut File::create("report.txt")?, &sales)?;
    print!("{}", fs::read_to_string("report.txt")?);

    write_report(&mut io::stdout(), &sales[..1]) // or straight to the terminal
}
Output
76 bytes written to memory
region       total
north     12500.00
south      9800.50
ALL       22300.50
region       total
north     12500.00
ALL       12500.00

13. Reading in chunks

For binary data, or when lines don't matter, read into a fixed-size buffer with read. It returns how many bytes it put into the buffer, which may be fewer than the buffer holds, and 0 at the end of the input. io::copy runs this loop for you when all you want is to move bytes from a reader to a writer.

Rust
use std::fs::{self, File};
use std::io::{self, Read};

// The FNV-1a hash of everything the reader produces, and its length.
fn fnv1a(mut input: impl Read) -> io::Result<(u64, usize)> {
    let mut hash: u64 = 0xcbf29ce484222325;
    let mut buf = [0u8; 4096];
    let mut total = 0;
    loop {
        let n = input.read(&mut buf)?;
        if n == 0 {
            break;
        }
        for &byte in &buf[..n] {
            hash ^= byte as u64;
            hash = hash.wrapping_mul(0x100000001b3);
        }
        total += n;
    }
    Ok((hash, total))
}

fn main() -> io::Result<()> {
    let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
    fs::write("big.bin", &data)?;
    let (hash, size) = fnv1a(File::open("big.bin")?)?;
    println!("{size} bytes, hash {hash:016x}");

    let copied = io::copy(&mut File::open("big.bin")?, &mut File::create("big-copy.bin")?)?;
    let (copy_hash, _) = fnv1a(File::open("big-copy.bin")?)?;
    println!("copied {copied} bytes, same hash: {}", copy_hash == hash);
    Ok(())
}
Output
100000 bytes, hash e796b565d8a90d28
copied 100000 bytes, same hash: true

14. Jumping around with Seek

Files of fixed-size records can be read and updated in place. seek moves the position; read_exact fills the whole buffer or fails with UnexpectedEof.

Rust
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};

const RECORD: usize = 16; // 12 bytes of name + 4 bytes of score

fn encode(name: &str, score: u32) -> [u8; RECORD] {
    let mut rec = [b' '; RECORD];
    let len = name.len().min(12);
    rec[..len].copy_from_slice(&name.as_bytes()[..len]);
    rec[12..].copy_from_slice(&score.to_le_bytes());
    rec
}

fn read_record(file: &mut File, index: u64) -> io::Result<(String, u32)> {
    let mut rec = [0u8; RECORD];
    file.seek(SeekFrom::Start(index * RECORD as u64))?;
    file.read_exact(&mut rec)?;
    let name = String::from_utf8_lossy(&rec[..12]).trim_end().to_string();
    let score = u32::from_le_bytes([rec[12], rec[13], rec[14], rec[15]]);
    Ok((name, score))
}

fn main() -> io::Result<()> {
    let mut file = OpenOptions::new().read(true).write(true).create(true).truncate(true).open("scores.dat")?;
    for (name, score) in [("ana", 120), ("ben", 95), ("cy", 310)] {
        file.write_all(&encode(name, score))?;
    }
    println!("record 2: {:?}", read_record(&mut file, 2)?);
    println!("record 0: {:?}", read_record(&mut file, 0)?);

    // Overwrite record 1 in place.
    file.seek(SeekFrom::Start(RECORD as u64))?;
    file.write_all(&encode("ben", 99))?;
    println!("record 1: {:?}", read_record(&mut file, 1)?);
    println!("file size: {} bytes", file.metadata()?.len());

    match read_record(&mut file, 7) {
        Ok(r) => println!("record 7: {r:?}"),
        Err(e) => println!("record 7: {:?}", e.kind()),
    }
    Ok(())
}
Output
record 2: ("cy", 310)
record 0: ("ana", 120)
record 1: ("ben", 99)
file size: 48 bytes
record 7: UnexpectedEof

Part 3: Managing files

15. Copying, renaming, and removing

fs::copy, fs::rename, and fs::remove_file copy, move, and delete files. fs::metadata returns a file's size, type, and timestamps, and fs::exists checks whether a path exists (it can also fail, for example without permission to look).

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

fn main() -> io::Result<()> {
    fs::write("draft.md", "# Plan\n\n- ship it\n")?;
    let bytes = fs::copy("draft.md", "draft-backup.md")?; // returns the number of bytes copied
    println!("copied {bytes} bytes");

    fs::rename("draft.md", "final.md")?; // rename or move, within one filesystem
    println!("draft.md exists: {}", fs::exists("draft.md")?);
    println!("final.md is {} bytes", fs::metadata("final.md")?.len());

    fs::remove_file("draft-backup.md")?;
    match fs::remove_file("draft-backup.md") {
        Ok(()) => println!("removed"),
        Err(e) => println!("second remove failed: {:?}", e.kind()),
    }
    Ok(())
}
Output
copied 18 bytes
draft.md exists: false
final.md is 18 bytes
second remove failed: NotFound

16. Saving safely: write, then rename

If a program crashes halfway through fs::write, the file is left half-written, and the old contents are gone. The safe way to replace a file is to write the new contents to a temporary file next to it and then rename it over the original. On the same filesystem, the rename happens at once: other programs see either the old file or the new one, never a mix.

Rust
use std::fs::{self, File};
use std::io::{self, Write};

fn save_atomically(path: &str, contents: &str) -> io::Result<()> {
    let tmp = format!("{path}.tmp");
    let mut file = File::create(&tmp)?;
    file.write_all(contents.as_bytes())?;
    file.sync_all()?; // wait until the bytes have reached the disk
    fs::rename(&tmp, path)
}

fn main() -> io::Result<()> {
    save_atomically("state.json", r#"{"version":1,"items":[]}"#)?;
    save_atomically("state.json", r#"{"version":2,"items":["a"]}"#)?;
    println!("{}", fs::read_to_string("state.json")?);
    println!("temp file left behind: {}", fs::exists("state.json.tmp")?);
    Ok(())
}
Output
{"version":2,"items":["a"]}
temp file left behind: false

Part 4: Realistic examples

17. Realistic example: loading a CSV file with line numbers in errors

A loader for a simple CSV file. Its error type distinguishes I/O failures from bad data, and bad data reports the line number, which is what a user needs to fix the file. (For real CSV, with quoting and escaping, use the csv crate.)

Rust
use std::fmt;
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader};

struct Order {
    id: u32,
    customer: String,
    cents: u64,
}

#[derive(Debug)]
enum LoadError {
    Io(io::Error),
    Parse { line: usize, message: String },
}

impl fmt::Display for LoadError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            LoadError::Io(e) => write!(f, "{e}"),
            LoadError::Parse { line, message } => write!(f, "line {line}: {message}"),
        }
    }
}

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

fn parse_line(line: &str) -> Result<Order, String> {
    let fields: Vec<&str> = line.split(',').map(str::trim).collect();
    let [id, customer, amount] = fields[..] else {
        return Err(format!("expected 3 fields, found {}", fields.len()));
    };
    let id = id.parse().map_err(|_| format!("bad id {id:?}"))?;
    let amount: f64 = amount.parse().map_err(|_| format!("bad amount {amount:?}"))?;
    Ok(Order { id, customer: customer.to_string(), cents: (amount * 100.0).round() as u64 })
}

fn load_orders(path: &str) -> Result<Vec<Order>, LoadError> {
    let reader = BufReader::new(File::open(path)?);
    let mut orders = Vec::new();
    for (index, line) in reader.lines().enumerate().skip(1) {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let order = parse_line(&line).map_err(|message| LoadError::Parse { line: index + 1, message })?;
        orders.push(order);
    }
    Ok(orders)
}

fn main() -> io::Result<()> {
    fs::write("orders.csv", "id,customer,amount\n1,Acme,120.50\n2,Globex,99.99\n\n3,Initech,15\n")?;
    fs::write("broken.csv", "id,customer,amount\n1,Acme,120.50\n2,Globex\n")?;
    fs::write("typo.csv", "id,customer,amount\n1,Acme,12O.50\n")?;

    for path in ["orders.csv", "broken.csv", "typo.csv", "nope.csv"] {
        match load_orders(path) {
            Ok(orders) => {
                let total: u64 = orders.iter().map(|o| o.cents).sum();
                let names: Vec<String> = orders.iter().map(|o| format!("#{} {}", o.id, o.customer)).collect();
                println!("{path}: {} (total {}.{:02})", names.join(", "), total / 100, total % 100);
            }
            Err(e) => println!("{path}: {e}"),
        }
    }
    Ok(())
}
Output
orders.csv: #1 Acme, #2 Globex, #3 Initech (total 235.49)
broken.csv: line 3: expected 3 fields, found 2
typo.csv: line 2: bad amount "12O.50"
nope.csv: No such file or directory (os error 2)

18. Realistic example: an append-only journal

Some applications never rewrite their data file: they append one line per event and rebuild their state by replaying the file at startup. Appends are cheap, and the file is a complete history. This one keeps account balances.

Rust
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, ErrorKind, Write};

struct Journal {
    path: String,
}

impl Journal {
    fn record(&self, event: &str) -> io::Result<()> {
        let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
        writeln!(file, "{event}")
    }

    fn replay(&self) -> io::Result<BTreeMap<String, i64>> {
        let mut balances = BTreeMap::new();
        let file = match File::open(&self.path) {
            Ok(f) => f,
            Err(e) if e.kind() == ErrorKind::NotFound => return Ok(balances), // no journal yet
            Err(e) => return Err(e),
        };
        for (index, line) in BufReader::new(file).lines().enumerate() {
            let line = line?;
            let bad = |why: &str| io::Error::new(ErrorKind::InvalidData, format!("line {}: {why}: {line:?}", index + 1));
            let [op, account, amount] = line.split_whitespace().collect::<Vec<_>>()[..] else {
                return Err(bad("expected 3 fields"));
            };
            let amount: i64 = amount.parse().map_err(|_| bad("bad amount"))?;
            let balance = balances.entry(account.to_string()).or_insert(0);
            match op {
                "deposit" => *balance += amount,
                "withdraw" => *balance -= amount,
                _ => return Err(bad("unknown operation")),
            }
        }
        Ok(balances)
    }
}

fn main() -> io::Result<()> {
    let _ = fs::remove_file("bank.journal");
    let journal = Journal { path: String::from("bank.journal") };
    println!("before: {:?}", journal.replay()?);

    for event in ["deposit ana 500", "deposit ben 300", "withdraw ana 120", "deposit ana 40"] {
        journal.record(event)?;
    }
    println!("after replay: {:?}", journal.replay()?);

    journal.record("withdraw ben lots")?;
    println!("after a bad line: {}", journal.replay().unwrap_err());
    Ok(())
}
Output
before: {}
after replay: {"ana": 420, "ben": 300}
after a bad line: line 5: bad amount: "withdraw ben lots"

io::Error::new creates an io::Error of your own, here with the InvalidData kind, so the function can keep returning io::Result.

19. Realistic example: the most common words in a large file

BufReader and a HashMap handle a file of any size: only the counts are kept in memory, never the whole text.

Rust
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader};

fn top_words(path: &str, n: usize) -> io::Result<Vec<(String, usize)>> {
    let mut counts: HashMap<String, usize> = HashMap::new();
    for line in BufReader::new(File::open(path)?).lines() {
        for word in line?.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
            *counts.entry(word.to_lowercase()).or_insert(0) += 1;
        }
    }
    let mut sorted: Vec<(String, usize)> = counts.into_iter().collect();
    sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    sorted.truncate(n);
    Ok(sorted)
}

fn main() -> io::Result<()> {
    let text = "The quick brown fox jumps over the lazy dog.\nThe dog sleeps; the fox runs!\n";
    fs::write("story.txt", text.repeat(1_000))?;
    println!("{} bytes", fs::metadata("story.txt")?.len());
    for (word, count) in top_words("story.txt", 4)? {
        println!("{word:<6} {count}");
    }
    Ok(())
}
Output
75000 bytes
the    4000
dog    2000
fox    2000
brown  1000

20. Realistic example: splitting a log into one file per day

Reading one file and writing several: each day gets its own BufWriter, kept in a map until the input is done, then flushed.

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

fn split_by_day(input: &str) -> io::Result<BTreeMap<String, usize>> {
    let mut writers: BTreeMap<String, BufWriter<File>> = BTreeMap::new();
    let mut counts = BTreeMap::new();

    for line in BufReader::new(File::open(input)?).lines() {
        let line = line?;
        let Some(day) = line.get(..10) else { continue }; // lines start with "2026-09-27"
        if !writers.contains_key(day) {
            let file = File::create(format!("log-{day}.txt"))?;
            writers.insert(day.to_string(), BufWriter::new(file));
        }
        let message = line.get(11..).unwrap_or("");
        writeln!(writers.get_mut(day).unwrap(), "{message}")?;
        *counts.entry(day.to_string()).or_insert(0) += 1;
    }

    for writer in writers.values_mut() {
        writer.flush()?;
    }
    Ok(counts)
}

fn main() -> io::Result<()> {
    fs::write(
        "server.log",
        "2026-09-26 09:00 start\n2026-09-26 17:30 stop\n2026-09-27 08:55 start\n2026-09-27 12:01 deploy v2\n2026-09-27 18:00 stop\n",
    )?;
    for (day, lines) in split_by_day("server.log")? {
        println!("log-{day}.txt: {lines} lines");
    }
    print!("{}", fs::read_to_string("log-2026-09-27.txt")?);
    Ok(())
}
Output
log-2026-09-26.txt: 2 lines
log-2026-09-27.txt: 3 lines
08:55 start
12:01 deploy v2
18:00 stop

Summary

  • fs::read_to_string, fs::read, and fs::write handle whole files in one call.
  • Every I/O operation returns io::Result. Match on e.kind() to handle specific cases like NotFound, and add the file name to error messages with with_context or your own error type.
  • BufReader + lines() (or read_line with a reused buffer) reads large files line by line; read with a buffer reads binary data in chunks.
  • File::create + writeln! writes text; import std::io::Write for the methods. Wrap files in BufWriter for many small writes and call flush() at the end.
  • OpenOptions covers appending, create_new, and read-write access; Seek moves around in a file.
  • Files close automatically when they go out of scope.
  • Write functions against impl BufRead, impl Read, and impl Write rather than paths, so tests can use in-memory bytes.
  • Replace important files by writing a temporary file and renaming it.

Next: Paths & Directories.