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::writefor simple, whole-file jobs; drop toFile+OpenOptionsonly when you need append mode, streaming, or fine control. - Wrap files in
BufReader/BufWriterwhenever 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 returningResultrather than.unwrap()-ing every call; reserve.unwrap()/.expect()for cases where failure truly means the program cannot continue. - Take
&stror genericimpl AsRef<Path>parameters for paths passed into your own functions rather than forcing callers to have aStringorPathBufalready. - Use
OpenOptionsexplicitly (.append(true),.create(true),.truncate(true)) instead of guessing which ofFile::open/File::creategives 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 andwriteln!, then reads the file back withBufReader::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 aBufReader, and returns how many lines it contains without ever calling.unwrap()inside the function body. - Modify Example 3 so that instead of appending
"cleanup"withOpenOptions, you accidentally useFile::createagain. Predict (then reason through) what the final read-back would print, and explain in a comment why the earlier lines disappeared.
Summary
Fileowns its OS handle; the handle closes automatically viaDropwhen theFilegoes out of scope — no manual close needed.- File operations return
Result<T, io::Error>; use?to propagate errors andmatch/if letto handle them, rather than defaulting to.unwrap(). fs::read_to_string/fs::writeare the simplest whole-file APIs;File::open/File::create/OpenOptionsgive you streaming and fine-grained control.- The
ReadandWritetraits must be imported withuse std::io::{Read, Write}before their methods (read_to_string,write_all) are callable on aFile. BufReader/BufWriterbatch 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, unlikeFile::create, which truncates.
