Skip to lesson

learningrust.org / intermediate / 21-file-io · lesson 21 of 26

TL;DR

Learn how to read from and write to files in Rust using std::fs and std::io traits

Key concepts

  • Rust file IO
  • Rust read write files
  • Rust std fs
  • Rust file handling
  • Rust buffered IO

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.

What You'll Learn

By the end of this lesson you will write a program that reads a file of records line by line, skips the blank ones, trims each field, and reports how many rows survived. The part that catches people is the order of those steps: deciding whether to keep a line before you trim it gives a different answer than trimming first, and both versions compile, run, and print a plausible number.

This is the capability the capstone's taskwork stands on — it reads its task file from disk with the same two calls you meet here, and every count it prints depends on getting these steps in the right order. You arrive able to model a row as a struct and return a Result when parsing fails (Error Handling), and knowing that an index into text is only safe if the text gave it to you (Strings and Text). What is new is that the rows now come from outside the program, which means they can be missing, unreadable, or malformed in ways nothing in your code chose.

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());
}
Continue learning

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};

// Takes impl Read rather than &File: the function needs one capability, not one
// concrete type, and asking for the least is what lets a Cursor stand in below.
fn count_lines(reader: impl io::Read) -> io::Result<usize> {
    let buffered = BufReader::new(reader);
    let mut count = 0;

    for line in buffered.lines() {
        // Bound and discarded rather than ignored with a bare ?: the ? is what
        // propagates a mid-read failure, and dropping the String here is the
        // point — counting lines has no reason to keep any of them alive.
        let _line = line?;
        count += 1;
    }

    Ok(count)
}

// Returns a Vec of pairs rather than the HashMap it built, because the caller
// wants an ORDER and a map has none. Sorting is done here, once, instead of
// leaving every caller to rediscover that a map cannot be ranked.
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() {
            // Lowercased AND stripped of punctuation before counting, so that
            // The, the and the, are one word. Normalise before you key a map:
            // the same before-or-after decision the debug block below turns on.
            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.

Before you meet that bug, build the loop correctly once. Here is the frame the four lines sit inside — note that they are the body of the for, so raw is the loop variable and out is the accumulator waiting outside it:

use std::io::{BufRead, BufReader, Cursor};

fn main() -> std::io::Result<()> {
    let text = "alpha\n\n   \nbeta\n";
    let reader = BufReader::new(Cursor::new(text));
    let mut out: Vec<String> = Vec::new();

    for raw in reader.lines() {
        // the four shuffled lines go here
    }

    println!("kept {:?}", out);
    Ok(())
}

Arrange the code

These four lines are the body of a loop over BufReader::lines(). They take one raw line, strip the whitespace around it, drop it if nothing is left, and keep what survives. The pieces are shuffled. The loop they belong to runs over a four-line config in which one line is empty and one holds nothing but spaces, so exactly two lines carry a setting. Put them in the order that reports those 2 usable lines — then answer what the order is really testing: which line introduces the name the next one consumes, and which single swap still compiles but changes the answer?

  1. if trimmed.is_empty() { continue; }
  2. out.push(trimmed.to_string());
  3. let trimmed = line.trim();
  4. let line = raw?;
Continue learning

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"

Continue learning

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.

Reading an io::Error

Converting an error is not the same as understanding one, and an io::Error is a different animal from a compiler diagnostic. Ask for a file that is not there:

use std::fs;

fn main() {
    match fs::read_to_string("/nope/missing.txt") {
        Ok(s) => println!("read {} bytes", s.len()),
        Err(e) => {
            println!("Display: {}", e);
            println!("Debug:   {:?}", e);
            println!("kind:    {:?}", e.kind());
        }
    }
}

That prints three views of one error:

Display: No such file or directory (os error 2)
Debug:   Os { code: 2, kind: NotFound, message: "No such file or directory" }
kind:    NotFound

Each view has a job, and choosing the wrong one is how unhelpful error messages get written. The Display form is the one to show a user, and notice what it does not contain: the path. read_to_string knows the path, and throws it away — so if your program prints only this, your user learns that something was missing and not what. Adding the path back is your job, not the standard library's, and it is the single most common improvement to a Rust CLI's error output.

The Debug form is the one to log, because it carries the raw OS error code, and os error 2 is stable across machines in a way the message text is not — a French-locale system may print a translated message for the same code.

The kind() is the one to branch on. It returns an ErrorKind enum — NotFound, PermissionDenied, AlreadyExists and others — and it is the only part of the three you should ever match against. Matching on the message string works until the day the operating system, the locale, or the Rust version changes the wording; matching on kind() does not. That distinction matters here more than in most places, because the three failure causes named at the top of this lesson are three different ErrorKind values, and a program that treats "the file is missing" the same as "you are not allowed to read it" gives its user no way forward.

One thing an io::Error will never give you is a --> line, a highlighted span, or a help: suggestion. Those belong to compile-time diagnostics. A runtime error tells you what went wrong and nothing at all about where in your program it happened — which is why the useful move is to attach the context yourself at the point of the call, while you still know which path you asked for.

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?

Continue learning

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

Continue learning

Once it passes, try two variations and predict each before running:

  1. 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, then split_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 the split_once guard and the bug is live again.
  2. Drop the TOTAL line. In write_summary, delete the final writeln! for the total. Decide which check fails and what the message shows before running. The second assert_eq! fails and prints both strings in full, so you can see the two outputs diverge only at the end — a concrete demonstration of why assert_eq! on the whole written buffer beats asserting on its length.

The Same Checks, Written as Tests

The build task's checks live inside fn main as assert_eq! calls, and that is deliberate rather than lazy. As Testing in Rust explained, the Run button on these pages compiles and runs the file with a single command and no test mode, so anything behind #[cfg(test)] is stripped before compilation and never runs here. Assertions that must actually execute have to be in main.

In a Cargo project you would write them the other way round. Here is read_report and write_summary with a real test module beside them:

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

fn read_report(reader: impl io::Read) -> io::Result<Vec<(String, u32)>> {
    let mut rows = Vec::new();
    for line in BufReader::new(reader).lines() {
        let line = line?;
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        if let Some((name, count)) = trimmed.split_once(':') {
            if let Ok(n) = count.trim().parse::<u32>() {
                rows.push((name.trim().to_string(), n));
            }
        }
    }
    Ok(rows)
}

fn write_summary(writer: &mut impl Write, rows: &[(String, u32)]) -> io::Result<()> {
    for (name, count) in rows {
        writeln!(writer, "{} = {}", name, count)?;
    }
    let total: u32 = rows.iter().map(|(_, c)| c).sum();
    writeln!(writer, "TOTAL = {}", total)?;
    Ok(())
}

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.len(), 3);
    let mut sink = Cursor::new(Vec::new());
    write_summary(&mut sink, &rows).expect("write failed");
    println!("All checks passed.");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn skips_comments_blanks_and_unparseable_lines() {
        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),
            ]
        );
    }

    #[test]
    fn a_whitespace_only_line_is_not_a_row() {
        let rows = read_report(Cursor::new("   \n")).expect("read failed");
        assert!(rows.is_empty());
    }

    #[test]
    fn summary_ends_with_a_total_line() {
        let rows = vec![("alpha".to_string(), 12u32), ("beta".to_string(), 7)];
        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\nTOTAL = 19\n");
    }
}

Press Run and you get All checks passed. — from main, not from the three tests. Under cargo test the same file reports three passing tests and never calls main at all. Both batteries assert the same facts; they differ only in which command can reach them.

Notice what the test module makes possible that the main battery does not: each test names one property in its own function name, so a failure report says which promise broke without your having to read the assertion. a_whitespace_only_line_is_not_a_row is the trim-ordering bug from earlier in this lesson, pinned as a permanent regression test.

Transfer

Every function in this lesson takes impl Read or &mut impl Write rather than a File, and the examples pass a Cursor over an in-memory string. A real program would pass a File opened from disk instead, with no change to the function. Which statement names what genuinely transfers between the two sources, rather than a surface resemblance?

Continue learning

Capstone milestone

Milestone — file input. The capstone's taskwork reads its task file from disk and turns each line into a record, skipping the ones it cannot use. That is the loop you built here, and the ordering trap you met here is the one that decides whether its counts are right. Confirm you can read a file into memory, walk it line by line, and normalise each line before deciding whether to keep it.

  • Read a whole file with fs::read_to_string and handled the io::Result rather than unwrapping it blindly
  • Walked a source line by line and skipped the blank and comment lines
  • Trimmed each line BEFORE testing whether to keep it, and can say why the other order gives a different answer
  • Kept the lines that parsed and the failures that did not in step, so a bad line costs one row rather than the whole file
  • Matched on the Result from reading the file, so an unreadable path prints which path failed instead of panicking
Continue learning

Key Takeaways

  • fs::read_to_string and fs::write cover the most common file operations in a single call
  • Accept impl Read / impl Write in function signatures to write testable, reusable I/O code
  • Wrap readers and writers with BufReader / BufWriter when processing data line-by-line or in small chunks
  • Use OpenOptions when 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 Read and impl Write traits instead of std::fs::File directly. This makes unit testing trivial — swap the real file for a Cursor<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.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.