File I/O

Reviewed & published by Brayan K

File I/O in Rust is reading and writing files through the std::fs and std::io modules — from one-shot helpers like fs::read_to_string and fs::write to buffered, line-by-line streaming with File and BufReader.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll write and read whole files, stream lines with a BufReader, append with OpenOptions, build portable paths, and propagate errors cleanly with the ? operator and io::Result.

What You'll Learn in This Lesson

1️⃣ Whole-File Read and Write

The two workhorses: fs::write(path, data) creates or truncates a file and writes everything in one call, and fs::read_to_string(path) reads a whole text file into a String. Both return io::Result, so a main() -> io::Result<()> can use the ? operator on them.

use std::fs;
use std::io;

// std::fs::write creates (or truncates) a file and writes all the bytes.
// std::fs::read_to_string reads an entire file into a String.
// Both return io::Result, so main returns it and uses the ? operator.
fn main() -> io::Result<()> {
    // Build a path inside the OS temp directory so this runs anywhere.
    let mut path = std::env::temp_dir();
    path.push("lcf_notes.txt");

    // Write the whole file in one call.
    fs::write(&path, "Line one\nLine two\nLine three\n")?;
    println!("wrote lcf_notes.txt");

    // Read it all back as a String.
    let contents = fs::read_to_string(&path)?;
    print!("--- file contents ---\n{}", contents);
    println!("--- {} bytes ---", contents.len());

    Ok(())
}

We build the path from std::env::temp_dir() and push a filename onto it, which works on every OS. The three lines plus their newlines come to 29 bytes.

2️⃣ Streaming Lines with BufReader

For large files, don't slurp the whole thing — open a File, wrap it in a BufReader for buffering, and iterate .lines(). Each item is an io::Result<String> with the newline already stripped, so you ? it to get the String.

use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};

// For line-by-line reading, wrap a File in a BufReader and iterate .lines().
// Each line is an io::Result<String>, so we use ? to unwrap it.
fn main() -> io::Result<()> {
    let mut path = std::env::temp_dir();
    path.push("lcf_lines.txt");

    // Prepare a file to read (write a few lines first).
    let mut f = File::create(&path)?;
    writeln!(f, "apple")?;
    writeln!(f, "banana")?;
    writeln!(f, "cherry")?;

    // Open it for reading and buffer it.
    let file = File::open(&path)?;
    let reader = BufReader::new(file);

    // .lines() yields one Result<String> per line (newline stripped).
    let mut count = 0;
    for line in reader.lines() {
        let line = line?;
        count += 1;
        println!("{:>2}: {} ({} chars)", count, line, line.len());
    }
    println!("total lines: {}", count);

    Ok(())
}

3️⃣ Appending with OpenOptions

Both fs::write and File::create overwrite an existing file. To add to the end instead, configure the open with OpenOptions::new().append(true).create(true) — perfect for log files that should grow over time.

use std::fs::{self, OpenOptions};
use std::io::{self, Write};

// OpenOptions with .append(true) opens a file for adding to the end instead of
// overwriting it. .create(true) makes the file if it doesn't exist yet.
fn main() -> io::Result<()> {
    let mut path = std::env::temp_dir();
    path.push("lcf_log.txt");

    // Start fresh so the example is reproducible.
    fs::write(&path, "")?;

    // Append three log lines, each in its own open/close.
    for i in 1..=3 {
        let mut file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)?;
        writeln!(file, "event {}", i)?;
    }

    // Read everything back to prove the lines accumulated.
    let log = fs::read_to_string(&path)?;
    print!("{}", log);

    Ok(())
}

Each loop iteration opens the file in append mode and writes one line, so the three events accumulate rather than replacing each other. Swap .append(true) for .write(true).truncate(true) and you'd be back to overwriting.

Your turn. Fill in the blanks marked ____, then run it.

// 🎯 YOUR TURN — fill in the two std::fs calls to write then read a file.
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    let mut path = std::env::temp_dir();
    path.push("lcf_yt.txt");

    fs::____(&path, "hello from rust")?;     // 👉 write the whole file
    let text = fs::____(&path)?;             // 👉 read it back as a String
    println!("read back: {}", text);
    println!("length: {}", text.len());

    Ok(())
}

// ✅ Expected output:
//    read back: hello from rust
//    length: 15

Mini-Challenge: Average Numbers From a File

Write numbers to a file, then read them back line by line, parse, and average them. Run it with cargo run and check the output.

// 🎯 MINI-CHALLENGE: write numbers to a file, then read and average them.
//
// 1. Build a path with std::env::temp_dir() and push "lcf_numbers.txt".
// 2. File::create it and writeln! the numbers 10, 25, 3, 47, 15 — one per line.
// 3. File::open it, wrap in a BufReader, and loop over .lines(). For each line,
//    trim() and parse() it to i32; add to a running sum and count.
// 4. Print the count, the sum, and the average (sum / count).
//    Return io::Result<()> from main and use ? on the I/O calls.
//
// ✅ Example output:
//    count: 5
//    sum: 100
//    average: 20

fn main() -> std::io::Result<()> {
    // your code here
    Ok(())
}

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — std::fs

CallDoes
fs::write(p, data)write a whole file (truncate)
fs::read_to_string(p)read a whole file to String
File::open(p)open for reading
BufReader::new(f).lines()iterate lines
OpenOptions...append(true)open to append

🎉 Lesson Complete!

Practice quiz

Which call reads an entire text file into a String in one shot?

  • fs::read_bytes
  • File::open
  • fs::read_to_string(path)
  • fs::lines

Answer: fs::read_to_string(path). std::fs::read_to_string(path) loads a whole text file into a String in a single call.

What does fs::write(path, contents) do if the file already exists?

  • Creates or truncates it, then writes all the bytes
  • Appends to it
  • Fails with an error
  • Renames it

Answer: Creates or truncates it, then writes all the bytes. fs::write creates the file if needed and truncates an existing one before writing everything at once.

What type do most std::fs operations return?

  • Option<T>
  • Vec<u8>
  • bool
  • io::Result

Answer: io::Result. They return io::Result (Ok or Err), so you handle errors with ? or match.

To read a file line by line efficiently, what should you wrap a File in?

  • a Vec
  • a BufReader
  • a HashMap
  • a String

Answer: a BufReader. Wrap the File in a BufReader and iterate .lines(); buffering reads in chunks instead of one syscall per byte.

Each item yielded by reader.lines() is what?

  • An io::Result<String> with the newline stripped
  • A plain String
  • A char
  • A Vec<u8>

Answer: An io::Result<String> with the newline stripped. .lines() yields one io::Result<String> per line, with the trailing newline already removed.

Which trait must be in scope for .lines() to work on a BufReader?

  • std::io::Write
  • std::fmt::Display
  • std::io::BufRead
  • std::fs::File

Answer: std::io::BufRead. .lines() comes from the BufRead trait; forgetting to import it causes a 'no method named lines' error.

Which trait must be in scope to use writeln! on a File?

  • std::io::Read
  • std::io::Write
  • std::io::BufRead
  • std::fmt::Debug

Answer: std::io::Write. writeln! needs the std::io::Write trait in scope.

How do you append to a file instead of overwriting it?

  • fs::write with a flag
  • File::create
  • fs::read_to_string
  • OpenOptions::new().append(true).create(true).open(path)

Answer: OpenOptions::new().append(true).create(true).open(path). OpenOptions with .append(true) opens for adding at the end; .create(true) makes the file if missing.

Why use std::env::temp_dir() and .push() to build a path?

  • It is required syntax
  • It builds a portable path that works on every OS
  • It is faster than strings
  • It encrypts the file

Answer: It builds a portable path that works on every OS. Building a PathBuf with temp_dir() and .push() avoids hard-coded slashes, so the code runs on any OS.

What signature lets main use ? on file operations and end with Ok(())?

  • fn main()
  • fn main() -> bool
  • fn main() -> io::Result<()>
  • fn main() -> String

Answer: fn main() -> io::Result<()>. fn main() -> io::Result<()> gives a Result return type so ? works, ending the success path with Ok(()).

Continue this course

Frequently asked questions

What is the simplest way to read or write a whole file in Rust?

For reading, std::fs::read_to_string(path) loads an entire text file into a String in one call, and std::fs::read(path) does the same for raw bytes into a Vec<u8>. For writing, std::fs::write(path, contents) creates the file if needed, truncates it if it exists, and writes all the bytes at once. These three functions cover the most common cases and each returns an io::Result, so you handle errors with ? or match. Reach for File and buffered readers/writers only when you need streaming or line-by-line control.

How do I read a file line by line?

Open the file with File::open, wrap it in a BufReader for efficiency, and iterate over reader.lines(). Each item is an io::Result<String> with the trailing newline already stripped, so you call ? (or handle the Result) to get the String. Buffering matters: a BufReader reads in large chunks instead of one syscall per byte, which makes line iteration fast. This pattern is the idiomatic way to process large text files without loading the whole thing into memory at once.

What does the ? operator do in file code?

Most file operations return io::Result<T>, which is either Ok(value) or Err(error). The ? operator unwraps the Ok value and, on an Err, immediately returns that error from the current function. It only works in a function whose return type is a compatible Result, which is why these examples write fn main() -> io::Result<()> and end with Ok(()). Using ? keeps file code linear and readable instead of nesting match expressions around every call, while still propagating every error correctly.

How do I append to a file instead of overwriting it?

Use OpenOptions to choose how the file is opened. Calling OpenOptions::new().append(true).create(true).open(path) opens the file for writing at the end, creating it first if it does not exist, so each write adds to what is already there rather than replacing it. By contrast, File::create and fs::write both truncate an existing file to empty before writing. Append mode is exactly what you want for log files and any growing record.