ZetCode

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.

words.txt
sky
blue
falcon
forest
rock
water
storm
sun
cloud
river

The words.txt file contains ten words, each on a separate line.

thermopylae.txt
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

numbers.txt
12
4
-7
33

8
abc
15

The numbers.txt file contains integers, one per line.

main.rs
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.

users.csv
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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.

main.rs
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 std::fs module

The BufRead trait

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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.