Rust file reading
last modified October 25, 2026
In this article we show how to read files in Rust.
The standard library provides file operations in the std::fs
and std::io modules. For simple cases, the
fs::read_to_string and fs::read functions read a
whole file in one call. For more control, we open the file with
File::open and read from it with the methods of the
Read trait. The BufReader type adds buffering and
line-by-line reading through the BufRead trait.
All I/O operations can fail: the file may not exist, we may lack permissions,
or the data may not be valid UTF-8. The functions therefore return
io::Result<T>, and in the examples the errors are passed up
with the ? operator from a main function that
returns io::Result<()>.
The examples were tested with Rust 1.97 and the 2024 edition. They are run as
Cargo projects with cargo run -q; the data files are located in
the directory from which the program is run. Relative paths are resolved
against the current working directory, not against the location of the
source file.
We use the following data files.
sky blue falcon forest rock water storm sun cloud river
The words.txt file contains ten words, each on a separate line.
The Battle of Thermopylae was fought between an alliance of Greek city-states, led by King Leonidas of Sparta, and the Persian Empire of Xerxes I over the course of three days, during the second Persian invasion of Greece.
The thermopylae.txt file contains three lines of text.
Reading a file into a string
The simplest way to read a text file is the fs::read_to_string
function. It opens the file, reads its whole contents, and returns it as a
String.
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let content = fs::read_to_string("words.txt")?;
print!("{content}");
Ok(())
}
The program reads the words.txt file and prints its contents.
fn main() -> io::Result<()> {
The main function returns io::Result<()>, so
that we can use the ? operator inside it. If an error occurs,
it is printed and the program exits with a non-zero code.
let content = fs::read_to_string("words.txt")?;
The function returns an error if the file does not exist, cannot be read, or does not contain valid UTF-8. The file is closed when the function returns.
print!("{content}");
The contents end with a newline, so we use print! instead of
println!.
$ cargo run -q sky blue falcon forest rock water storm sun cloud river
Opening a file
The File::open function opens a file in read-only mode. The
returned File implements the Read trait.
use std::fs::File;
use std::io::{self, Read};
fn main() -> io::Result<()> {
let mut file = File::open("thermopylae.txt")?;
let mut content = String::new();
let n = file.read_to_string(&mut content)?;
println!("read {n} bytes");
print!("{content}");
Ok(())
}
We open the file and read it into a string buffer.
let mut file = File::open("thermopylae.txt")?;
The file variable must be mutable, because reading advances the position of the file cursor.
let mut content = String::new(); let n = file.read_to_string(&mut content)?;
The read_to_string method of the Read trait appends
the data to the given String and returns the number of bytes
read. Since it appends, the same buffer can collect the contents of several
files. The file is closed automatically when the file variable
goes out of scope.
$ cargo run -q read 223 bytes The Battle of Thermopylae was fought between an alliance of Greek city-states, led by King Leonidas of Sparta, and the Persian Empire of Xerxes I over the course of three days, during the second Persian invasion of Greece.
Reading line by line
Large files should not be loaded into memory at once. The
BufReader wraps a reader and reads the data in larger blocks.
Its lines method returns an iterator over the lines of the file.
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn main() -> io::Result<()> {
let file = File::open("words.txt")?;
let reader = BufReader::new(file);
for (i, line) in reader.lines().enumerate() {
let line = line?;
println!("{:>2}: {line}", i + 1);
}
Ok(())
}
We print the lines of the file together with line numbers.
let reader = BufReader::new(file);
The BufReader has an internal buffer of 8 KiB. Without it, many
small reads from a File would each result in a system call.
for (i, line) in reader.lines().enumerate() {
let line = line?;
println!("{:>2}: {line}", i + 1);
}
The lines method comes from the BufRead trait. Each
item is an io::Result<String>, because reading any line
may fail. The returned strings do not contain the newline character; a
trailing \r from Windows line endings is removed as well.
$ cargo run -q 1: sky 2: blue 3: falcon 4: forest 5: rock 6: water 7: storm 8: sun 9: cloud 10: river
Lines of a string
If the file is small, we can read it with read_to_string and
process it with the lines method of str. The lines
are then slices of a single string, and no allocation is needed for them.
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let content = fs::read_to_string("words.txt")?;
let words: Vec<&str> = content.lines().collect();
let long: Vec<&str> = words.iter().copied().filter(|w| w.len() > 4).collect();
println!("{} words", words.len());
println!("first: {}, last: {}", words[0], words[words.len() - 1]);
println!("longer than four letters: {long:?}");
Ok(())
}
We collect the words into a vector of string slices and filter the words that are longer than four letters.
let words: Vec<&str> = content.lines().collect();
The slices borrow from the content string, so the string must
live as long as the vector.
$ cargo run -q 10 words first: sky, last: river longer than four letters: ["falcon", "forest", "water", "storm", "cloud", "river"]
Reusing a line buffer
The lines iterator allocates a new String for each
line. The read_line method reads a line into an existing buffer,
which can be reused.
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn main() -> io::Result<()> {
let mut reader = BufReader::new(File::open("thermopylae.txt")?);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break;
}
println!("{n:>3} bytes: {}", line.trim_end());
}
Ok(())
}
We read the lines into the same buffer and print their sizes in bytes.
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break;
}
The read_line method appends to the buffer, so we clear it first.
It returns the number of bytes read; zero means the end of the file. Unlike
lines, it keeps the newline character, which is counted in the
sizes and removed with trim_end for printing.
$ cargo run -q 79 bytes: The Battle of Thermopylae was fought between an alliance of Greek city-states, 76 bytes: led by King Leonidas of Sparta, and the Persian Empire of Xerxes I over the 68 bytes: course of three days, during the second Persian invasion of Greece.
Reading bytes
The fs::read function reads the whole file into a
Vec<u8>. It is used for binary files, or when we do not
know whether the data is valid text.
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let data = fs::read("words.txt")?;
println!("{} bytes", data.len());
println!("first eight: {:?}", &data[..8]);
println!("newlines: {}", data.iter().filter(|&&b| b == b'\n').count());
match String::from_utf8(data) {
Ok(text) => println!("valid UTF-8, {} lines", text.lines().count()),
Err(e) => println!("not UTF-8: {e}"),
}
Ok(())
}
We read the bytes of the words.txt file, count the newline
bytes, and try to convert the data into a string.
println!("first eight: {:?}", &data[..8]);
The bytes are printed as numbers; for example, 115 is the ASCII code of
s and 10 is the newline.
match String::from_utf8(data) {
Ok(text) => println!("valid UTF-8, {} lines", text.lines().count()),
Err(e) => println!("not UTF-8: {e}"),
}
The String::from_utf8 function takes ownership of the vector and
checks that it contains valid UTF-8. The conversion does not copy the data.
$ cargo run -q 56 bytes first eight: [115, 107, 121, 10, 98, 108, 117, 101] newlines: 10 valid UTF-8, 10 lines
The read_to_end method
The read_to_end method reads all bytes until the end of the
file and appends them to a vector. The String::from_utf8_lossy
function converts bytes into text and replaces invalid sequences.
use std::fs::File;
use std::io::{self, Read};
fn main() -> io::Result<()> {
let mut file = File::open("words.txt")?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
buf.extend_from_slice(b"\xF0\x28\x8C\x28\n");
let text = String::from_utf8_lossy(&buf);
print!("{text}");
Ok(())
}
We read the file into a buffer and append a few bytes that are not valid UTF-8.
buf.extend_from_slice(b"\xF0\x28\x8C\x28\n");
A byte string literal can contain arbitrary bytes. The sequence is not a valid UTF-8 character.
let text = String::from_utf8_lossy(&buf);
The function returns a Cow<str>. If the data is valid, it
borrows it without copying; otherwise it creates a new string, where each
invalid sequence is replaced with the U+FFFD replacement character.
$ cargo run -q sky blue falcon forest rock water storm sun cloud river �(�(
Reading in chunks
The read method reads at most as many bytes as fit into the
given buffer and returns their number. Reading in fixed-size chunks keeps the
memory usage constant regardless of the file size.
use std::fs::File;
use std::io::{self, Read};
fn main() -> io::Result<()> {
let mut file = File::open("words.txt")?;
let mut buf = [0u8; 16];
let mut total = 0;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
total += n;
println!("{n:>2} bytes: {:?}", String::from_utf8_lossy(&buf[..n]));
}
println!("total: {total} bytes");
Ok(())
}
We read the file with a sixteen-byte buffer.
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
The method may return fewer bytes than the buffer size, so only the first
n bytes of the buffer are valid. Zero bytes mean the end of the
file. In practice, the buffer is much larger, for example 8 KiB.
println!("{n:>2} bytes: {:?}", String::from_utf8_lossy(&buf[..n]));
The chunks are printed with the debug format, which shows the newline
characters as \n. The chunk boundaries do not follow the lines;
a chunk may also end in the middle of a multi-byte UTF-8 character, which
must be taken into account when decoding text.
$ cargo run -q 16 bytes: "sky\nblue\nfalcon\n" 16 bytes: "forest\nrock\nwate" 16 bytes: "r\nstorm\nsun\nclou" 8 bytes: "d\nriver\n" total: 56 bytes
Parsing numbers
A common task is to read numbers from a file. In this example, the file contains an empty line and an invalid value.
12 4 -7 33 8 abc 15
The numbers.txt file contains integers, one per line.
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let content = fs::read_to_string("numbers.txt")?;
let mut vals = Vec::new();
for (i, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
match line.parse::<i32>() {
Ok(n) => vals.push(n),
Err(e) => eprintln!("line {}: {line:?}: {e}", i + 1),
}
}
println!("values: {vals:?}");
println!("sum: {}", vals.iter().sum::<i32>());
Ok(())
}
We parse the lines into integers, skip empty lines, and report the lines that are not valid numbers.
match line.parse::<i32>() {
Ok(n) => vals.push(n),
Err(e) => eprintln!("line {}: {line:?}: {e}", i + 1),
}
The parse method returns a Result. Invalid lines
are reported to the standard error output and the processing continues.
$ cargo run -q values: [12, 4, -7, 33, 8, 15] sum: 65 line 7: "abc": invalid digit found in string
Reading CSV data
In the next example, we read comma-separated values into a vector of structures.
name,occupation,salary John Doe,gardener,1200 Roger Roe,driver,1500 Lucy Smith,teacher,1900 Paul Novak,programmer,2700
The first line of the file is a header.
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Debug)]
struct User {
name: String,
occupation: String,
salary: u32,
}
fn main() -> io::Result<()> {
let reader = BufReader::new(File::open("users.csv")?);
let mut users = Vec::new();
for line in reader.lines().skip(1) {
let line = line?;
let fields: Vec<&str> = line.split(',').collect();
if let [name, occupation, salary] = fields[..] {
users.push(User {
name: name.to_string(),
occupation: occupation.to_string(),
salary: salary.parse().unwrap_or(0),
});
}
}
for u in &users {
println!("{:<12} {:<12} {:>5}", u.name, u.occupation, u.salary);
}
let total: u32 = users.iter().map(|u| u.salary).sum();
println!("total salary: {total}");
println!("{:?}", users[0]);
Ok(())
}
Each line is split into fields, which are stored in a User
structure.
for line in reader.lines().skip(1) {
The skip(1) call skips the header line.
if let [name, occupation, salary] = fields[..] {
A slice pattern checks that the line has exactly three fields and binds them to variables. Lines with a different number of fields are ignored.
Splitting on commas does not handle quoted fields that contain commas. For
real CSV files, use the csv crate.
$ cargo run -q
John Doe gardener 1200
Roger Roe driver 1500
Lucy Smith teacher 1900
Paul Novak programmer 2700
total salary: 7300
User { name: "John Doe", occupation: "gardener", salary: 1200 }
Counting words
The example counts the occurrences of words in a text file.
use std::collections::BTreeMap;
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let content = fs::read_to_string("thermopylae.txt")?;
let mut freq: BTreeMap<String, usize> = BTreeMap::new();
for word in content.split(|c: char| !c.is_alphanumeric() && c != '-') {
if !word.is_empty() {
*freq.entry(word.to_lowercase()).or_insert(0) += 1;
}
}
let mut common: Vec<(&String, &usize)> = freq.iter().filter(|(_, n)| **n > 1).collect();
common.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
println!("{} distinct words", freq.len());
for (word, n) in common {
println!("{word}: {n}");
}
Ok(())
}
We split the text into words, count them in a map, and print the words that occur more than once.
for word in content.split(|c: char| !c.is_alphanumeric() && c != '-') {
The text is split at every character that is not a letter, a digit, or a hyphen. Consecutive separators produce empty strings, which we skip.
*freq.entry(word.to_lowercase()).or_insert(0) += 1;
The entry API inserts a zero for a new word and returns a
mutable reference to the counter. We use a BTreeMap, which keeps
the words sorted.
common.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
The words are sorted by their count in descending order; words with the same count are sorted alphabetically.
$ cargo run -q 29 distinct words of: 6 the: 4 persian: 2
Handling errors
The io::Error type has a kind method, which returns
an ErrorKind value. We can use it to react to particular
errors.
use std::fs;
use std::io::ErrorKind;
fn main() {
let files = ["words.txt", "missing.txt"];
for name in files {
match fs::read_to_string(name) {
Ok(content) => println!("{name}: {} lines", content.lines().count()),
Err(e) if e.kind() == ErrorKind::NotFound => println!("{name}: no such file"),
Err(e) => println!("{name}: {e}"),
}
}
let err = fs::read_to_string("missing.txt").unwrap_err();
println!("{err}");
println!("{err:?}");
}
We try to read an existing and a missing file.
Err(e) if e.kind() == ErrorKind::NotFound => println!("{name}: no such file"),
Err(e) => println!("{name}: {e}"),
A match guard handles the missing file; other errors, such as
PermissionDenied, are printed.
println!("{err}");
println!("{err:?}");
The display format prints the message from the operating system; the debug format shows the error code and kind. Note that the message does not contain the file name. In larger programs, we add it ourselves.
$ cargo run -q
words.txt: 10 lines
missing.txt: no such file
No such file or directory (os error 2)
Os { code: 2, kind: NotFound, message: "No such file or directory" }
The file name from the command line
The example counts lines, words, and characters of a file whose name is given
on the command line, similar to the wc tool.
use std::env;
use std::fs;
use std::process;
fn main() {
let Some(path) = env::args().nth(1) else {
eprintln!("usage: read-file <path>");
process::exit(1);
};
let content = fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("cannot read {path}: {e}");
process::exit(1);
});
let lines = content.lines().count();
let words = content.split_whitespace().count();
let chars = content.chars().count();
println!("{lines} {words} {chars} {path}");
}
The program takes the path from the first argument and reports errors to the standard error output.
let Some(path) = env::args().nth(1) else {
eprintln!("usage: read-file <path>");
process::exit(1);
};
The first item of env::args is the program name, so the file
name is the item with the index 1. The let else statement exits
the program when the argument is missing.
let content = fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("cannot read {path}: {e}");
process::exit(1);
});
Here we add the file name to the error message. The
process::exit function never returns, so the closure has the
type of the success value.
let chars = content.chars().count();
The chars method counts Unicode characters, while
len returns the number of bytes. For ASCII text, both are the
same.
$ cargo run -q -- words.txt 10 10 56 words.txt $ cargo run -q -- thermopylae.txt 3 38 223 thermopylae.txt $ cargo run -q -- missing.txt cannot read missing.txt: No such file or directory (os error 2) $ echo $? 1 $ cargo run -q usage: read-file <path> $ echo $? 1
Seeking
The Seek trait moves the position in a file. We can read a part
of the file without reading the data before it.
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
fn main() -> io::Result<()> {
let mut file = File::open("thermopylae.txt")?;
let size = file.metadata()?.len();
println!("file size: {size} bytes");
let mut head = [0u8; 21];
file.seek(SeekFrom::Start(4))?;
file.read_exact(&mut head)?;
println!("{}", String::from_utf8_lossy(&head));
let mut tail = [0u8; 19];
file.seek(SeekFrom::End(-20))?;
file.read_exact(&mut tail)?;
println!("{}", String::from_utf8_lossy(&tail));
println!("position: {}", file.stream_position()?);
Ok(())
}
We get the file size from its metadata and read two parts of the file: one from the start and one from the end.
let mut head = [0u8; 21]; file.seek(SeekFrom::Start(4))?; file.read_exact(&mut head)?;
We move to the byte with the offset 4 and read exactly 21 bytes. Unlike
read, the read_exact method fills the whole buffer
or returns an error.
file.seek(SeekFrom::End(-20))?;
A negative offset relative to the end moves before the last twenty bytes. The last byte of the file, the newline, is not read.
println!("position: {}", file.stream_position()?);
The stream_position method returns the current position.
$ cargo run -q file size: 223 bytes Battle of Thermopylae invasion of Greece. position: 222
Source
The Rust Programming Language: Reading a File
In this article we have read files in Rust. See also the Rust writing to files article.
Author
List all Rust tutorials.