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.
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.
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.
Try It Yourself
Build a simple CSV-style log parser that reads records, filters by a field, and writes matching rows to a new destination.
use std::io::{self, BufRead, BufReader, Write, Cursor};
struct Record {
name: String,
score: u32,
}
fn parse_record(line: &str) -> Option<Record> {
let parts: Vec<&str> = line.splitn(2, ',').collect();
if parts.len() != 2 {
return None;
}
let name = parts[0].trim().to_string();
let score = parts[1].trim().parse::<u32>().ok()?;
Some(Record { name, score })
}
fn filter_high_scores(
reader: impl io::Read,
writer: &mut impl Write,
threshold: u32,
) -> io::Result<usize> {
let buffered = BufReader::new(reader);
let mut written = 0;
for line in buffered.lines() {
let line = line?;
if let Some(record) = parse_record(&line) {
if record.score >= threshold {
writeln!(writer, "{},{}", record.name, record.score)?;
written += 1;
}
}
}
Ok(written)
}
fn main() {
let csv_data = "\
Alice, 92
Bob, 55
Charlie, 88
Diana, 73
Eve, 97
Frank, 61";
let reader = Cursor::new(csv_data);
let mut output = Cursor::new(Vec::new());
let count = filter_high_scores(reader, &mut output, 80).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
println!("Records with score >= 80 ({} found):\n{}", count, result);
}
Try changing the threshold value or adding a new field to the record format.
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