20.2Reading and writing files

Last updated June 13, 2026

A command-line argument usually names a file, and the tool's job is to read it, do something, and maybe write a result. Rust's file handling comes in two layers: a pair of one-line conveniences for the common case where the whole file fits comfortably in memory, and a lower-level, buffered approach for files too big to slurp all at once. Start with the easy layer; reach for the other only when you need to.

Reading a whole file

std::fs::read_to_string reads an entire file into a single String. It returns a Result, because reading a file is exactly the kind of thing that fails for reasons outside your control, the file might not exist, you might lack permission, the disk might hiccup. This is chapter 12's whole reason for existing, and here it is in the wild:

use std::fs;

fn main() {
    let contents = fs::read_to_string("poem.txt")
        .expect("should have been able to read poem.txt");
    println!("File is {} bytes:\n{contents}", contents.len());
}

Given a poem.txt containing two lines:

roses are red
violets are blue
File is 30 bytes:
roses are red
violets are blue

The expect here crashes with a message if the read fails, fine for a quick script. In a real tool you'd propagate the error with ? and let main return Result (lesson 12.3), which the project at the end of this chapter does. Either way, the failure is visible in the type: read_to_string hands you a Result, and you can't get at the String without acknowledging the read might not have worked.

Writing a whole file

The mirror image is std::fs::write, which writes a string (or byte slice) to a file, creating it if it doesn't exist and replacing it if it does:

use std::fs;

fn main() {
    let report = "scores: 9, 7, 8\ntotal: 24\n";
    fs::write("report.txt", report).expect("should have been able to write report.txt");
    println!("wrote report.txt");
}
wrote report.txt

After running it, report.txt contains the two lines. fs::write is the whole-file write: it's atomic from your code's point of view, no opening, no closing, no flushing to think about. For "produce this file from this string," it's all you need. Note the "replaces if it exists" behavior, fs::write truncates an existing file, it does not append. (Appending is a job for the File API below, opened in append mode.)

Best practice

For files that fit in memory, default to fs::read_to_string and fs::write. They're one line, they handle opening and closing for you, and they put the failure in a Result you can't ignore. Only move to the buffered File API below when a file is too large to hold all at once, or when you need to stream, append, or process line by line as you go.

Big files: File, BufReader, BufWriter

read_to_string loads the entire file into memory. For a log file that's tens of gigabytes, that's a non-starter. The lower-level tools let you process a file a piece at a time. You open a File, then wrap it in a buffer so you're not making a separate, slow system call for every line.

Why the buffer matters connects to something chapter 1 left dangling. Talking to the operating system, asking it to hand over the next chunk of a file, or to accept the next chunk of output, is comparatively expensive. A buffer is an in-memory holding area that batches those conversations: a BufReader grabs a big block at a time and serves you lines from memory, and a BufWriter accumulates your writes and sends them to the OS in big blocks. Reading a file line by line:

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

fn main() {
    let file = File::open("big.txt").expect("could not open big.txt");
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = line.expect("could not read a line");
        if line.contains("ERROR") {
            println!("{line}");
        }
    }
}

reader.lines() is an iterator (chapter 19) that yields one Result<String> per line, reading from the buffer and refilling it from the file as needed. At no point is the whole file in memory, only the current line and one block of buffer. This is how you grep a huge log without exhausting RAM. Each line is a Result because any read can fail partway through.

Writing through a buffer is the same idea in reverse, with one rule you must not forget:

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

fn main() {
    let file = File::create("out.txt").expect("could not create out.txt");
    let mut writer = BufWriter::new(file);

    for i in 1..=3 {
        writeln!(writer, "line {i}").expect("write failed");
    }

    writer.flush().expect("flush failed");
    println!("done");
}
done

out.txt then holds three lines. writeln! is println!'s file-bound cousin: same brace formatting, but it writes to the buffer you name instead of to the screen. The crucial line is writer.flush(). A BufWriter holds your writes in memory until its buffer fills; whatever is still sitting in the buffer when the program ends needs to be flushed, pushed out to the actual file. A BufWriter does flush automatically when it's dropped at the end of scope, but calling flush explicitly makes the timing visible and lets you handle a write error there instead of having it swallowed silently during the drop.

Buffered output and early exit: the chapter-7 warning, paid off

Lesson 7.8 warned that std::process::exit flushes standard output but abandons other buffered destinations. Here's the destination it meant. If you write through a BufWriter and then call exit (or panic) before the buffer is flushed, whatever's still in the buffer is lost, and you get a mysteriously truncated file. exit skips the drop that would have flushed it. The fix is exactly the flush() above: push the buffer out yourself before any early exit, and don't rely on drop to save you when you're about to cut the program short. That promised-truncation bug from chapter 7 is this, made concrete.

Quiz time

Question #1

When should you use fs::read_to_string, and when should you reach for File plus BufReader instead?

Show solution

Use fs::read_to_string when the whole file fits comfortably in memory, it's one line and loads the entire contents into a String. Reach for File + BufReader (and .lines()) when the file is too large to hold all at once, or when you want to stream/process it line by line without loading it fully. The buffered reader keeps only a block in memory at a time.

Question #2

Why do read_to_string and write return Result?

Show solution

Because file operations can fail for reasons outside your program's control: the file may not exist, you may lack permission, the disk may be full or error out. That's the "exceptional, recoverable" situation Result exists for (chapter 12). The Result forces you to acknowledge the failure, by propagating it with ? or handling it, before you can use the value.

Question #3

You write three lines through a BufWriter and then your program calls std::process::exit(0). The output file ends up empty. What happened, and how do you fix it?

Show solution

The three writeln!s went into the BufWriter's in-memory buffer, which hadn't been flushed to the file yet. process::exit ends the program immediately, skipping the drop that would normally flush the buffer (lesson 7.8: exit flushes stdout but not other buffered destinations), so the buffered bytes are discarded and the file stays empty. Fix it by calling writer.flush() before exiting, so the buffered data is written out.

Files are one destination. The next lesson (20.3) covers the three streams every command-line program is born with, standard input, standard output, and standard error, and how they make your tool composable with others.