File I/O
Working with files is a fundamental skill in any systems language. Rust's standard library provides a powerful, ergonomic API for reading and writing files through std::fs and std::io. Because file operations can fail for many reasons — missing paths, permission errors, disk full — Rust's Result type makes error handling explicit and safe.
In this lesson you will learn how to read entire files, write content to disk, process files line-by-line, and append to existing files.
Reading Files
The simplest way to read a file into memory is fs::read_to_string. It opens the file, reads all bytes, decodes them as UTF-8, and returns a String.
use std::io::{self, Read, Cursor};
fn read_contents(reader: &mut impl Read) -> io::Result<String> {
let mut contents = String::new();
reader.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
// In a real program you would open a file:
// let mut file = std::fs::File::open("hello.txt").unwrap();
// Here we simulate a file with Cursor for the playground:
let mut fake_file = Cursor::new("Hello, Rust!\nFile I/O is easy.\n");
match read_contents(&mut fake_file) {
Ok(contents) => println!("File contents:\n{}", contents),
Err(e) => eprintln!("Error reading file: {}", e),
}
}
Notice the function accepts impl Read rather than a concrete file type. This means the same function works with real files, network sockets, in-memory buffers, or anything else that implements the Read trait — a classic example of Rust's trait-based polymorphism.
Why the examples use Cursor instead of real files
Every snippet on this page runs in a fresh, empty sandbox that is thrown away the moment it finishes. There is no hello.txt sitting on disk for a snippet to open, and a file one snippet writes is gone before the next one starts. So each example here wraps its bytes in a std::io::Cursor, which is an in-memory buffer that implements Read and Write exactly as a File does — the real std::fs calls sit right above it as comments, and swapping one for the other is a one-line change precisely because the functions take impl Read / impl Write. This is also the single best argument for writing I/O code against the traits rather than against File: the same trick that makes these snippets runnable is the trick that makes real file code unit-testable.
A Cursor is a reader, though, and readers have a property that surprises almost everyone the first time it bites. Trace this one carefully before you run it:
Predict
The same reader is read to a String twice in a row, with no error handling skipped and no reassignment in between. Work out exactly what each of the three printed lines says before running.
use std::io::{Read, Cursor};
fn main() {
let mut source = Cursor::new("alpha\nbeta\n");
let mut first = String::new();
source.read_to_string(&mut first).unwrap();
let mut second = String::new();
source.read_to_string(&mut second).unwrap();
println!("first = {:?}", first);
println!("second = {:?}", second);
println!("lengths: {} and {}", first.len(), second.len());
}The answer is (c): first holds the whole "alpha\nbeta\n" and second is empty, giving lengths 11 and 0. A reader is a one-shot stream carrying a position — read_to_string drains from wherever that position is and leaves it at the end, so the second call finds nothing and returns Ok(0). Note that it succeeds: in std::io, running out of input is not an error, which is what makes this bug so quiet. A real File behaves identically. Whenever you need the same bytes twice, hold on to the String you already read or build a second reader over the same source — and that is precisely why the buffered example below constructs Cursor::new(text) a second time rather than reusing the one it already has.
Writing Files
Writing content to a file is equally straightforward with fs::write, which creates or overwrites a file in a single call.
use std::io::{self, Write, Cursor};
fn write_report(writer: &mut impl Write, lines: &[&str]) -> io::Result<()> {
for line in lines {
writeln!(writer, "{}", line)?;
}
Ok(())
}
fn main() {
// Real usage: let mut file = std::fs::File::create("report.txt").unwrap();
let mut output = Cursor::new(Vec::new());
let data = vec![
"=== Monthly Report ===",
"Items processed: 42",
"Errors: 0",
"Status: OK",
];
write_report(&mut output, &data).expect("write failed");
// Inspect what was written
let written = String::from_utf8(output.into_inner()).unwrap();
println!("{}", written);
}
The writeln! macro works exactly like println! but sends output to any Write implementor instead of stdout. The ? operator propagates I/O errors up the call stack cleanly.
Buffered I/O
Reading or writing one byte at a time causes many small system calls, which is slow. BufReader and BufWriter wrap any Read/Write source with an internal buffer, dramatically improving throughput for line-by-line processing.
use std::io::{self, BufRead, BufReader, Cursor};
fn count_lines(reader: impl io::Read) -> io::Result<usize> {
let buffered = BufReader::new(reader);
let mut count = 0;
for line in buffered.lines() {
let _line = line?; // propagate any I/O error
count += 1;
}
Ok(count)
}
fn word_frequency(reader: impl io::Read) -> io::Result<Vec<(String, usize)>> {
let buffered = BufReader::new(reader);
let mut freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for line in buffered.lines() {
for word in line?.split_whitespace() {
let key = word.to_lowercase().trim_matches(|c: char| !c.is_alphabetic()).to_string();
if !key.is_empty() {
*freq.entry(key).or_insert(0) += 1;
}
}
}
let mut result: Vec<(String, usize)> = freq.into_iter().collect();
result.sort_by(|a, b| b.1.cmp(&a.1));
Ok(result)
}
fn main() {
let text = "the quick brown fox\njumps over the lazy dog\nthe fox was quick";
let reader = Cursor::new(text);
let line_count = count_lines(Cursor::new(text)).unwrap();
println!("Lines: {}", line_count);
let freq = word_frequency(reader).unwrap();
println!("\nWord frequencies:");
for (word, count) in freq.iter().take(5) {
println!(" {:>10}: {}", word, count);
}
}
BufReader::lines() returns an iterator of io::Result<String>. Each call to .lines() reads up to the next newline from the internal buffer, flushing to the underlying reader only when needed. One detail matters more than it looks: lines() strips the trailing newline but nothing else. A line that held four spaces arrives as a four-space String, not as an empty one — and real files are full of those.
That distinction is the whole of the next bug. The program compiles without a warning, runs without a panic, and prints a count that is simply wrong. Commit a hypothesis before you touch it:
Debug
clean_lines should return the three real config lines, trimmed. It runs clean and reports four. Work out which input line is sneaking through and why the emptiness check fails to catch it, then fix it.
use std::io::{self, BufRead, BufReader, Cursor};
/// Returns every non-blank line, with the surrounding whitespace removed.
fn clean_lines(reader: impl io::Read) -> io::Result<Vec<String>> {
let buffered = BufReader::new(reader);
let mut out = Vec::new();
for line in buffered.lines() {
let line = line?;
if !line.is_empty() {
out.push(line.trim().to_string());
}
}
Ok(out)
}
fn main() {
// A config file as it really arrives: blank separators, one of which is
// not empty at all but a couple of stray spaces.
let text = "name = workbench\n\n \n version = 2\n\nmode = fast\n";
let cleaned = clean_lines(Cursor::new(text)).unwrap();
println!("{} usable lines", cleaned.len());
for line in &cleaned {
println!(" {:?}", line);
}
}Expected output: 3 usable lines
"name = workbench"
"version = 2"
"mode = fast"
The check runs before the trim, so it is asking about the raw line. Two of the separators really are empty strings and are dropped correctly, but the line holding three spaces is a three-character String — not empty — so it survives the check, gets trimmed on its way into the vector, and lands there as "". Hence four lines instead of three, with an empty quoted string sitting in the output as the tell. The fix is to normalise first and decide second: bind let line = line.trim(); and test that. Deciding whether to keep a value before normalising it is the most common line-processing bug there is, and lines() feeds it directly by removing the newline and nothing else.
Appending to Files
When you want to add data to an existing file without overwriting it, use OpenOptions:
use std::fs::OpenOptions;
use std::io::Write;
fn append_log(path: &str, message: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create(true) // create if it doesn't exist
.append(true) // don't truncate, seek to end
.open(path)?;
writeln!(file, "{}", message)?;
Ok(())
}
OpenOptions is a builder that controls exactly how the file is opened. Common flags include .read(true), .write(true), .create(true), .truncate(true), and .append(true).
Error Handling Patterns
File I/O is one of the most common places you will use the ? operator and custom error types. A typical pattern uses Box<dyn Error> for quick scripts and a custom enum for libraries.
use std::fs;
use std::io;
// Quick approach for scripts / main functions
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?;
Ok(contents)
}
// Library approach: define a domain error
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(String),
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
fn parse_numbers(path: &str) -> Result<Vec<i32>, AppError> {
let contents = fs::read_to_string(path)?; // io::Error -> AppError via From
contents
.lines()
.map(|line| {
line.trim()
.parse::<i32>()
.map_err(|e| AppError::Parse(e.to_string()))
})
.collect()
}
Implementing From<io::Error> for your error type lets the ? operator convert automatically, keeping function bodies clean.
Every function in this lesson has taken impl Read or &mut impl Write rather than a concrete File, and that choice is doing more work than it looks. Close the page and answer this from memory — it is really a question from Traits & Generics:
Recall
Without scrolling up: in *Traits & Generics* you learned what impl Trait means in a function's ARGUMENT position. This lesson writes fn count_lines(reader: impl io::Read). What is the compiler actually doing with that parameter, and what does it cost at run time?
impl Read in argument position is shorthand for an anonymous generic parameter — fn count_lines(reader: impl Read) and fn count_lines<R: Read>(reader: R) compile to exactly the same thing. The compiler monomorphises, generating one specialised copy per concrete type you pass, so dispatch is resolved at compile time and there is no vtable and no indirection. That is the payoff behind every Cursor on this page: the swap to a real File changes which copy gets generated and nothing else. The one thing the sugar costs you is a name for the type, so when a signature needs two arguments of the same type, or you want to turbofish it, write <R: Read> out in full. The contrast case is Box<dyn Read>, which gets you one compiled copy and a runtime vtable lookup instead.
Try It Yourself
Reading about Read and Write is not the same as wiring a real pipeline through them. This is a build task: a small program that reports its own pass/fail. Two functions are stubbed out — one that pulls structured rows out of a reader and one that formats a summary into a writer — and a battery of assert_eq! calls in fn main checks both. Run it as-is and it panics immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
The input is deliberately messy in exactly the ways this lesson warned about: a comment line, an empty separator, a whitespace-only separator, and one line that will never parse. Both functions take traits rather than a File — impl io::Read in, &mut impl Write out — so main can hand them a Cursor and inspect what was written, which is the same testability argument the Retrieval above made. Nothing above spells out both answers, so you will have to assemble them.
Build
Finish the build. Two functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement both until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
// BufRead and BufReader are imported for you; TODO 1 needs them.
#![allow(unused_imports)]
use std::io::{self, BufRead, BufReader, Cursor, Write};
// TODO 1: read the source line by line and return one (name, count) pair per
// usable line. A usable line is "name: number". SKIP blank/whitespace-only
// lines, lines starting with '#', and lines that do not parse as a number.
// Wrap the reader in BufReader::new and iterate .lines(). Trim BEFORE you
// decide whether to keep a line.
fn read_report(reader: impl io::Read) -> io::Result<Vec<(String, u32)>> {
let _ = reader;
Ok(Vec::new())
}
// TODO 2: write one "name = count" line per row, then a final "TOTAL = n" line
// holding the sum of every count. Use writeln! and the ? operator.
fn write_summary(writer: &mut impl Write, rows: &[(String, u32)]) -> io::Result<()> {
let _ = (writer, rows);
Ok(())
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let input = "# daily counts\nalpha: 12\n\n \nbeta: 7\nbroken line\ngamma: 3\n";
let rows = read_report(Cursor::new(input)).expect("read failed");
assert_eq!(
rows,
vec![
("alpha".to_string(), 12),
("beta".to_string(), 7),
("gamma".to_string(), 3),
],
"read_report should skip comments, blank lines and unparseable lines"
);
let mut sink = Cursor::new(Vec::new());
write_summary(&mut sink, &rows).expect("write failed");
let written = String::from_utf8(sink.into_inner()).expect("valid utf-8");
assert_eq!(
written,
"alpha = 12\nbeta = 7\ngamma = 3\nTOTAL = 22\n",
"write_summary should emit one line per row plus a TOTAL line"
);
println!("All checks passed.");
print!("{}", written);
}Expected output: All checks passed.
alpha = 12
beta = 7
gamma = 3
TOTAL = 22
Once it passes, try two variations and predict each before running:
- Move the trim. In
read_report, test the raw line —if line.is_empty() { continue; }— and trim only when pushing. Predict what the first check reports before running. It fails: the whitespace-only separator is not empty, so it survives the skip, thensplit_once(':')finds no colon and it is dropped anyway. The count still comes out at three and the check passes — which is the uncomfortable part. Trimming late is only harmless here because a second filter happens to catch the same line; remove thesplit_onceguard and the bug is live again. - Drop the TOTAL line. In
write_summary, delete the finalwriteln!for the total. Decide which check fails and what the message shows before running. The secondassert_eq!fails and prints both strings in full, so you can see the two outputs diverge only at the end — a concrete demonstration of whyassert_eq!on the whole written buffer beats asserting on its length.
Key Takeaways
fs::read_to_stringandfs::writecover the most common file operations in a single call- Accept
impl Read/impl Writein function signatures to write testable, reusable I/O code - Wrap readers and writers with
BufReader/BufWriterwhen processing data line-by-line or in small chunks - Use
OpenOptionswhen you need fine-grained control over how a file is opened (append, create, truncate) - All file operations return
io::Result<T>— use?to propagate errors and keep code clean - Implementing
From<io::Error>for your error type enables seamless error conversion with?
Pro Tip: Prefer designing your functions around
impl Readandimpl Writetraits instead ofstd::fs::Filedirectly. This makes unit testing trivial — swap the real file for aCursor<Vec<u8>>— and keeps your logic decoupled from the file system. The pattern pays dividends the moment you need to process data from a network stream or an in-memory buffer without changing your parsing code.
Next Steps
Now that you can work with files, we'll explore macros — Rust's metaprogramming feature that lets you write code that generates code.
Next lesson
Macros
Learn how to use and create Rust macros for metaprogramming and code generation at compile time
25 min