Reading and Writing Files

Almost every real program eventually needs to touch the filesystem — reading a config file, writing a log, or processing a data file. Rust handles this through two modules, std::fs and std::io, built around a simple idea: a file is just a resource with an owner, and every operation that might fail returns a Result instead of throwing an exception or silently returning garbage. This lesson covers reading, writing, appending, buffered I/O, and the mistakes that trip up newcomers.

Overview: How File I/O Works in Rust

When you open a file in Rust, the operating system hands back a low-level file handle (a file descriptor on Unix, a HANDLE on Windows). Rust wraps that raw handle in a File struct from std::fs. That struct owns the handle, the same way any Rust value owns its data. This has a direct, practical consequence: you never have to manually close a file. When a File value goes out of scope, Rust’s ownership system runs its destructor (the Drop implementation), which closes the underlying handle automatically. There is no try/finally, no with open(...), no risk of forgetting to close a handle and leaking file descriptors — the compiler enforces cleanup for you, deterministically, the moment ownership ends.

The second core idea is that filesystem operations are fallible, and Rust never hides that. A file might not exist, permissions might be wrong, the disk might be full — anything that can go wrong when talking to the OS is represented in the type system. Nearly every function in std::fs and every method on File returns Result<T, std::io::Error> (aliased as io::Result<T>). You cannot accidentally ignore a failed file operation the way you might miss a thrown exception; the compiler will not let you use the value inside a Result without acknowledging the Err case first (or explicitly choosing to panic with .unwrap()).

The third idea is that Rust separates what kind of thing you’re reading or writing from from how. Two traits, std::io::Read and std::io::Write, describe the ability to pull bytes out of something or push bytes into something. File implements both. So does a TCP socket, standard input, standard output, and even an in-memory Vec<u8>. This means a function written against R: Read works identically whether the actual data comes from a file, the network, or memory — you write the logic once. Finally, raw file reads and writes each cost a system call, which is relatively expensive. BufReader and BufWriter wrap a File and batch small reads/writes into larger chunks in memory, dramatically reducing the number of syscalls for line-by-line or byte-by-byte work.

Traced example of the mental model: File::open("data.txt") asks the OS for a handle and returns Result<File, io::Error>. You use ? or a match to get the File out (or bail out on error). You call methods from Read or Write on it. When the variable holding the File goes out of scope — end of the function, end of a block — Rust drops it and the OS handle is released, with no extra code from you.

Syntax

The building blocks you’ll use for almost all file work:

Form Purpose
std::fs::read_to_string(path) Reads an entire file into a new String. Returns Result<String, io::Error>.
std::fs::write(path, contents) Creates (or truncates) a file and writes bytes/a string to it in one call. Returns Result<(), io::Error>.
File::open(path) Opens an existing file for reading. Fails if the file doesn’t exist. Returns Result<File, io::Error>.
File::create(path) Creates a new file for writing, truncating it if it already exists. Returns Result<File, io::Error>.
OpenOptions::new().append(true).open(path) Fine-grained control: append instead of truncate, create-if-missing, read+write, and so on.
BufReader::new(file) / .lines() Wraps a reader in a buffer; .lines() gives an iterator of Result<String, io::Error>, one per line.
BufWriter::new(file) Wraps a writer in a buffer to batch small writes.
file.read_to_string(&mut String) / file.write_all(&[u8]) Read/Write trait methods for pulling or pushing bytes directly on a File.
write!(file, ...) / writeln!(file, ...) Formatted writes to anything implementing Write, just like println! but targeting a file.
? Propagates an Err out of the current function immediately; the function must return a compatible Result.

Examples

Example 1: The simplest way — fs::write and fs::read_to_string

use std::fs;

fn main() -> Result<(), std::io::Error> {
    fs::write("hello.txt", "Hello, file system!")?;
    let contents = fs::read_to_string("hello.txt")?;
    println!("{}", contents);
    Ok(())
}

Output:

Hello, file system!

For quick one-shot jobs, fs::write and fs::read_to_string are the whole toolkit: no handle to manage, one call to write, one call to read. main returns Result<(), std::io::Error> so the ? operator can bail out early and print a helpful error automatically if something goes wrong — no manual error handling needed for a small script like this.

Example 2: Explicit File handles with Read and Write

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

fn main() -> io::Result<()> {
    let mut file = File::create("notes.txt")?;
    file.write_all(b"Line one\n")?;
    file.write_all(b"Line two\n")?;

    let mut file = File::open("notes.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    print!("{}", contents);

    Ok(())
}

Output:

Line one
Line two

File::create truncates (or makes) the file for writing; write_all takes a byte slice, which is why the literals are prefixed with b. Note that both write_all and read_to_string only exist because Write and Read are imported — without those use lines, the methods wouldn’t be found (see Common Mistakes). The second File::open reuses the name file via shadowing, opening a fresh, independent handle for reading.

Example 3: Buffered line-by-line reading, plus appending

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

fn main() -> io::Result<()> {
    let mut file = File::create("log.txt")?;
    writeln!(file, "start")?;
    writeln!(file, "processing")?;
    writeln!(file, "done")?;

    let mut file = OpenOptions::new().append(true).open("log.txt")?;
    writeln!(file, "cleanup")?;

    let file = File::open("log.txt")?;
    let reader = BufReader::new(file);
    let mut count = 0;
    for line in reader.lines() {
        let line = line?;
        println!("{}: {}", count, line);
        count += 1;
    }
    println!("Total lines: {}", count);

    Ok(())
}

Output:

0: start
1: processing
2: done
3: cleanup
Total lines: 4

This is closer to real code: a log file is created and written, then reopened with OpenOptions in append mode so a later line doesn’t wipe out the earlier ones, then read back with a BufReader. .lines() yields one Result<String, io::Error> per line (with the newline already stripped), so each iteration uses ? to unwrap it.

How It Works Step by Step

Walking through Example 3: File::create issues an OS call to create/truncate log.txt and returns a File owning that handle. Each writeln! call is itself a direct write system call — File does no buffering of its own, so every line is physically written before the next statement runs. When the name file is shadowed by the second let mut file = ..., the original File value isn’t dropped immediately; it’s simply no longer reachable by that name and will be dropped at the end of main. That’s harmless here because every write already reached disk via its syscall. The append-mode handle then adds a fourth line. Finally, a third handle opens the file for reading and is wrapped in BufReader: instead of issuing a syscall for every line, the reader pulls a large chunk of the file into an internal buffer and serves .lines() out of memory, issuing far fewer syscalls than raw byte-by-byte reads would. When main returns, every File still in scope is dropped in reverse order of creation, and each drop closes its OS handle — cleanup you never had to write.

Common Mistakes

Mistake 1: Calling a trait method without importing the trait

write_all and read_to_string aren’t inherent methods on File — they belong to the Write and Read traits. If you don’t bring the trait into scope with use, the compiler can’t find the method, even though File implements it.

use std::fs::File;

fn main() -> std::io::Result<()> {
    let mut file = File::create("out.txt")?;
    file.write_all(b"data")?;
    Ok(())
}

This fails to compile with an error like no method named \`write_all\` found for struct \`File\` ... the trait \`std::io::Write\` which provides \`write_all\` is implemented but not in scope. The fix is a one-line import:

use std::fs::File;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut file = File::create("out.txt")?;
    file.write_all(b"data")?;
    println!("Data written successfully");
    Ok(())
}

Output:

Data written successfully

Mistake 2: Reaching for .unwrap() on a fallible file operation

It’s tempting to write .unwrap() after every file call, but that turns a missing file, a permissions error, or a full disk into an instant panic that crashes the whole program.

fn main() {
    let contents = std::fs::read_to_string("config.toml").unwrap();
    println!("Config has {} bytes", contents.len());
}

If config.toml doesn’t exist, this panics with something like called \`Result::unwrap()\` on an \`Err\` value: Os { code: 2, kind: NotFound, ... } — an abrupt crash with no chance to recover or explain what happened to the user. Prefer matching on the Result so the failure path is an ordinary branch of your program instead of a panic:

use std::fs;

fn main() {
    fs::write("config.toml", "debug = true").expect("setup failed");

    match fs::read_to_string("config.toml") {
        Ok(contents) => println!("Config has {} bytes", contents.len()),
        Err(e) => println!("Could not read config.toml: {}", e),
    }
}

Output:

Config has 12 bytes

Here the file is guaranteed to exist because the program just wrote it, so the Ok branch runs — but the same match would print a friendly message instead of crashing if the file were missing. Reserve .unwrap()/.expect() for cases where a missing file truly means your program cannot continue, and even then prefer .expect("message") over bare .unwrap() so the panic explains itself.

Best Practices

  • Prefer fs::read_to_string / fs::write for simple, whole-file jobs; drop to File + OpenOptions only when you need append mode, streaming, or fine control.
  • Wrap files in BufReader/BufWriter whenever you’re doing many small reads or writes (line-by-line parsing, byte-by-byte processing) — the difference in syscall count is significant on large files.
  • Let errors propagate with ? from a function returning Result rather than .unwrap()-ing every call; reserve .unwrap()/.expect() for cases where failure truly means the program cannot continue.
  • Take &str or generic impl AsRef<Path> parameters for paths passed into your own functions rather than forcing callers to have a String or PathBuf already.
  • Use OpenOptions explicitly (.append(true), .create(true), .truncate(true)) instead of guessing which of File::open/File::create gives the behavior you want — the intent is clearer in the code.
  • Remember you never need to call a `.close()` method — let the `File` go out of scope, or use `drop(file)` if you need to release the handle earlier in the same scope (e.g., before reopening the same path).

Practice Exercises

  • Write a program that creates a file called numbers.txt, writes the numbers 1 through 5 to it (one per line) using a loop and writeln!, then reads the file back with BufReader::lines() and prints the sum. Expected output: Sum: 15.
  • Write a function fn line_count(path: &str) -> std::io::Result<usize> that opens a file, wraps it in a BufReader, and returns how many lines it contains without ever calling .unwrap() inside the function body.
  • Modify Example 3 so that instead of appending "cleanup" with OpenOptions, you accidentally use File::create again. Predict (then reason through) what the final read-back would print, and explain in a comment why the earlier lines disappeared.

Summary

  • File owns its OS handle; the handle closes automatically via Drop when the File goes out of scope — no manual close needed.
  • File operations return Result<T, io::Error>; use ? to propagate errors and match/if let to handle them, rather than defaulting to .unwrap().
  • fs::read_to_string/fs::write are the simplest whole-file APIs; File::open/File::create/OpenOptions give you streaming and fine-grained control.
  • The Read and Write traits must be imported with use std::io::{Read, Write} before their methods (read_to_string, write_all) are callable on a File.
  • BufReader/BufWriter batch small reads/writes into fewer system calls — use them for line-by-line or byte-by-byte work.
  • OpenOptions::new().append(true) lets you add to a file without erasing its existing contents, unlike File::create, which truncates.