Skip to main content
Rust · lesson 26 of 30
Rust beginner Lesson 26 of 30

File I/O in Rust

Read and write files in Rust: fs helpers, buffered I/O, paths, directories, JSON with serde, environment variables, arguments, and stdin.

By SkillByExample · Updated

Reading a File

Reading a file is one of the most common I/O tasks. fs::read_to_string is the simplest option — it opens the file, reads the entire contents into a String, and closes it in one call. It returns a Result because the file might not exist, you might lack permission, or any number of other OS-level failures can occur. Always handle the error rather than unwrapping in production code.

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

fn main() -> Result<(), io::Error> {
    fs::write("hello.txt", "Hello,\nfile I/O!\n")?; // set up a sample file

    // Read entire file into a String — simple but loads the whole file into memory
    let content = fs::read_to_string("hello.txt")?;
    println!("file contents:\n{}", content);

    // Read as raw bytes when the file is not guaranteed to be UTF-8
    let bytes = fs::read("hello.txt")?;
    println!("file size: {} bytes", bytes.len());

    // A missing file is an Err, which ? returns from main
    let missing = fs::read_to_string("nope.txt")?;
    println!("never reached: {}", missing);
    Ok(())
}
$ rustc file_io_reading_a_file.rs && ./file_io_reading_a_file
file contents:
Hello,
file I/O!

file size: 17 bytes
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

The blank line after file I/O! is the file’s own trailing newline followed by println!’s. 17 bytes counts both \n characters. The last read fails, ? returns the io::Error from main, and Rust prints it with Debug formatting and exits with status 1 — the Os { code: 2, kind: NotFound, ... } text is the raw error value, which is why a real CLI usually matches on the error and prints something friendlier.

Writing a File

fs::write is the counterpart to fs::read_to_string — it writes a string or byte slice to a file, creating it if it does not exist and overwriting it if it does. For more control over what happens to an existing file, use OpenOptions.

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

fn main() -> Result<(), io::Error> {
    // Write a string — creates the file, truncates if it already exists
    fs::write("output.txt", "Hello, file!\n")?;

    // Write bytes
    fs::write("data.bin", &[0u8, 1, 2, 3, 4])?;

    // Append to an existing file using OpenOptions
    use std::fs::OpenOptions;
    use std::io::Write;

    let mut file = OpenOptions::new()
        .append(true)   // do not truncate — add to the end
        .create(true)   // create if it does not exist
        .open("output.txt")?;

    writeln!(file, "appended line")?;

    print!("{}", fs::read_to_string("output.txt")?);
    println!("data.bin: {:?}", fs::read("data.bin")?);
    Ok(())
}
$ rustc file_io_writing_a_file.rs && ./file_io_writing_a_file
Hello, file!
appended line
data.bin: [0, 1, 2, 3, 4]

fs::write truncated output.txt to one line, then the append(true) handle added a second without disturbing the first. Run the program twice and the output is the same — the fs::write truncates the file again before the append.

Buffered I/O

Reading a file line by line with a BufReader is far more memory-efficient than loading the whole file into a String. The buffer reads a chunk from the OS at a time (typically 8 KB) and serves lines from that chunk, making it suitable for files of any size. BufWriter provides the same benefit on the write side, batching small writes into fewer, larger OS calls.

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

fn main() -> Result<(), io::Error> {
    std::fs::write("input.txt", "alpha\nbeta\ngamma\n")?; // set up a sample file

    // BufReader — read line by line without loading the whole file
    let file = File::open("input.txt")?;
    let reader = BufReader::new(file);

    for (line_num, line) in reader.lines().enumerate() {
        let line = line?; // each line() call returns Result<String>
        println!("{}: {}", line_num + 1, line);
    }

    // BufWriter — batch small writes to reduce OS syscall overhead
    let out = File::create("output.txt")?;
    let mut writer = BufWriter::new(out);

    for i in 0..1000 {
        writeln!(writer, "line {}", i)?;
    }
    // BufWriter flushes when dropped, but drop can't report errors — flush explicitly
    writer.flush()?;

    let written = std::fs::read_to_string("output.txt")?;
    println!("wrote {} lines, last: {:?}", written.lines().count(), written.lines().last());
    Ok(())
}
$ rustc file_io_buffered_i_o.rs && ./file_io_buffered_i_o
1: alpha
2: beta
3: gamma
wrote 1000 lines, last: Some("line 999")

lines() strips the \n from each line, so line never contains it. The explicit flush() matters: if the final flush fails inside drop, the error is silently discarded — calling it yourself surfaces it through ?.

Working with Paths

Raw string paths are fragile across operating systems — Windows uses \, Unix uses /, and concatenating paths with string operations is error-prone. Path and PathBuf are the correct abstractions. Path is a borrowed path slice (like &str), and PathBuf is an owned, growable path (like String).

use std::path::{Path, PathBuf};

fn main() {
    // PathBuf — owned, mutable path
    let mut path = PathBuf::from("/home/user");
    path.push("documents");        // joins with the OS separator
    path.push("report.txt");

    println!("{}", path.display());

    // Path queries
    println!("{:?}", path.file_name());
    println!("{:?}", path.extension());
    println!("{:?}", path.parent());
    println!("{:?}", path.file_stem());

    // Existence checks hit the real filesystem
    let p = Path::new("/etc/hostname");
    println!("exists: {}", p.exists());
    println!("is file: {}", p.is_file());
    println!("is dir: {}", p.is_dir());

    // Build paths from components — OS-agnostic
    let config = Path::new("config").join("app").join("settings.toml");
    println!("{}", config.display());
}
$ rustc file_io_working_with_paths.rs && ./file_io_working_with_paths
/home/user/documents/report.txt
Some("report.txt")
Some("txt")
Some("/home/user/documents")
Some("report")
exists: true
is file: true
is dir: false
config/app/settings.toml

The output above is from Linux. file_name, extension, parent and file_stem are pure string operations — they never touch the disk — while exists/is_file/is_dir do. On Windows the same program prints /home/user\documents\report.txt and config\app\settings.toml: push and join insert the native separator, which is exactly why you use them instead of format!("{}/{}", ...).

Reading and Writing Structured Data

Most real programs need to read and write structured data, not raw text. Combining serde with a format crate like serde_json or toml lets you serialize and deserialize Rust structs directly. The derive macros generate all the conversion code — you just describe the shape of your data.

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
use std::fs;

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
    tags: Vec<String>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialize to JSON and write to file
    let config = Config {
        host: "localhost".to_string(),
        port: 8080,
        debug: true,
        tags: vec!["web".to_string(), "api".to_string()],
    };

    let json = serde_json::to_string_pretty(&config)?;
    fs::write("config.json", &json)?;
    println!("wrote config:\n{}", json);

    // Read file and deserialize back into a struct
    let content = fs::read_to_string("config.json")?;
    let loaded: Config = serde_json::from_str(&content)?;
    println!("loaded: {:?}", loaded);

    Ok(())
}
$ cargo run -q
wrote config:
{
  "host": "localhost",
  "port": 8080,
  "debug": true,
  "tags": [
    "web",
    "api"
  ]
}
loaded: Config { host: "localhost", port: 8080, debug: true, tags: ["web", "api"] }

The JSON keys are the struct’s field names, generated by #[derive(Serialize)]; from_str needs the target type annotated (let loaded: Config) because the same JSON could deserialize into many types.

Directory Operations

Working with directories — listing contents, creating, removing, and walking recursively — uses the std::fs module. For recursive directory walking in production code, the walkdir crate is significantly more ergonomic than rolling your own recursion.

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

fn main() -> Result<(), io::Error> {
    // Create directories — create_dir_all creates intermediate directories too
    fs::create_dir_all("output/reports/2024")?;
    fs::write("output/summary.txt", "ok\n")?;

    // List directory contents — read_dir order is not guaranteed, so sort it
    let mut entries: Vec<_> = fs::read_dir("output")?.collect::<Result<_, _>>()?;
    entries.sort_by_key(|e| e.path());
    for entry in entries {
        let path = entry.path();
        let metadata = entry.metadata()?;

        if metadata.is_file() {
            println!("file: {} ({} bytes)", path.display(), metadata.len());
        } else if metadata.is_dir() {
            println!("dir:  {}", path.display());
        }
    }

    // remove_dir only removes an EMPTY directory
    match fs::remove_dir("output") {
        Ok(()) => println!("removed"),
        Err(e) => println!("remove_dir failed: {}", e),
    }

    // remove_dir_all removes a directory and everything in it
    fs::remove_dir_all("output")?;
    println!("output exists afterwards: {}", std::path::Path::new("output").exists());
    Ok(())
}
$ rustc file_io_directory_operations.rs && ./file_io_directory_operations
dir:  output/reports
file: output/summary.txt (3 bytes)
remove_dir failed: Directory not empty (os error 39)
output exists afterwards: false

read_dir lists only the immediate children — reports shows up but 2024 inside it doesn’t. collect::<Result<_, _>>() turns an iterator of Result<DirEntry> into a single Result<Vec<DirEntry>>, failing on the first bad entry.

Environment Variables and Temporary Files

Environment variables are the standard way to configure applications without hardcoding paths or secrets. env::temp_dir() gives the OS’s scratch directory; for files that must be cleaned up automatically, the tempfile crate deletes them when they go out of scope — essential for test fixtures.

use std::env;

fn main() {
    // Read environment variables — Err if unset (or not valid Unicode)
    match env::var("APP_MODE") {
        Ok(mode) => println!("mode: {}", mode),
        Err(e) => println!("APP_MODE: {}", e),
    }

    // The OS temp directory
    println!("temp dir: {}", env::temp_dir().display());

    // Command-line arguments — args[0] is the program itself
    let args: Vec<String> = env::args().collect();
    println!("program: {}", args[0]);
    for (i, arg) in args.iter().enumerate().skip(1) {
        println!("arg {}: {}", i, arg);
    }
}
$ rustc env_args.rs && APP_MODE=staging ./env_args first "two words"
mode: staging
temp dir: /tmp
program: ./env_args
arg 1: first
arg 2: two words
$ ./env_args
APP_MODE: environment variable not found
temp dir: /tmp
program: ./env_args

"two words" arrives as a single argument because the shell did the splitting, not Rust. args[0] is whatever path the shell used to launch the program — here ./env_args.

Copying, Moving, and Renaming Files

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

fn main() -> Result<(), io::Error> {
    fs::write("source.txt", "twelve bytes")?; // set up a sample file

    // Copy a file — returns the number of bytes copied
    let bytes = fs::copy("source.txt", "destination.txt")?;
    println!("copied {} bytes", bytes);

    // Rename or move a file within the same filesystem
    fs::rename("destination.txt", "renamed.txt")?;
    println!("destination.txt exists: {}", fs::metadata("destination.txt").is_ok());

    // Get file metadata
    let meta = fs::metadata("renamed.txt")?;
    println!("size: {} bytes", meta.len());
    println!("readonly: {}", meta.permissions().readonly());
    println!("modified within the last minute: {}", meta.modified()?.elapsed().map(|d| d.as_secs() < 60).unwrap_or(false));

    Ok(())
}
$ rustc file_io_copying_moving_and_renaming_files.rs && ./file_io_copying_moving_and_renaming_files
copied 12 bytes
destination.txt exists: false
size: 12 bytes
readonly: false
modified within the last minute: true

rename replaces the destination if it already exists, which is why running the program twice doesn’t fail. modified() returns a SystemTime; printing it raw with {:?} gives an opaque timestamp, so this checks how long ago it was instead.

Reading Stdin

Reading from standard input is essential for CLI tools. stdin().lock() provides a buffered reader over stdin, and .lines() gives you an iterator that yields each line as the user types it.

use std::io::{self, BufRead};

fn main() {
    let stdin = io::stdin();

    // Read line by line until EOF (Ctrl+D on Unix, Ctrl+Z on Windows)
    for line in stdin.lock().lines() {
        match line {
            Ok(l) => println!("you typed: {}", l),
            Err(e) => {
                eprintln!("error reading stdin: {}", e);
                break;
            }
        }
    }
}
$ rustc read_stdin.rs && printf 'first\nsecond\n' | ./read_stdin
you typed: first
you typed: second

Piping input in makes the program see EOF after the last line, so the loop ends on its own. Run it interactively and it waits for input until you send EOF.

Practice

  1. Write the four bytes [0x63, 0x61, 0x66, 0xE9] (“café” in Latin-1) to a file, then read it back with fs::read_to_string, with fs::read, and with String::from_utf8_lossy.

    Solution
    read_to_string failed: stream did not contain valid UTF-8
    bytes: [99, 97, 102, 233]
    lossy: caf�

    read_to_string insists on UTF-8 and errors; read hands you the raw bytes; from_utf8_lossy replaces the invalid byte with U+FFFD.

  2. Replace the ? on the missing-file read with a match that prints no settings.ini — using defaults only for a not found error, and a generic message for anything else.

    Solution
    use std::io::ErrorKind;
    
    match fs::read_to_string("settings.ini") {
        Ok(s) => println!("{} bytes of settings", s.len()),
        Err(e) if e.kind() == ErrorKind::NotFound => println!("no settings.ini — using defaults"),
        Err(e) => println!("could not read settings.ini: {}", e),
    }

    e.kind() is portable across operating systems, unlike the message text or the raw os error number.

  3. read_dir only lists one level. Write a recursive walk(dir, depth) that prints the whole output/ tree, indented, in sorted order.

    Solution
    fn walk(dir: &Path, depth: usize) -> io::Result<()> {
        let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
        entries.sort_by_key(|e| e.path());
        for entry in entries {
            let path = entry.path();
            println!("{}{}", "  ".repeat(depth), path.file_name().unwrap().to_string_lossy());
            if path.is_dir() {
                walk(&path, depth + 1)?;
            }
        }
        Ok(())
    }

    With output/reports/2024/q1.txt and output/summary.txt it prints reports, 2024, q1.txt, summary.txt.

  4. Using BufReader::lines, count the lines and words in a file containing "alpha beta\n\ngamma delta epsilon\n".

    Solution

    Loop over reader.lines(), add 1 per line and line.split_whitespace().count() per line’s words: 3 lines, 5 words. The empty middle line counts as a line with zero words; the final \n does not create a fourth, empty line.

Frequently Asked Questions

What is the difference between fs::read_to_string and BufReader?
fs::read_to_string reads the entire file into a String at once — simple but uses memory proportional to file size. BufReader wraps a File and reads in chunks, which is more memory-efficient for large files or when you want to process line by line.
How do I handle file paths across operating systems?
Use std::path::Path and PathBuf instead of raw strings. They handle path separator differences (/ vs \) and provide methods for joining, extension manipulation, and querying path components.
What is the difference between File::create and File::open?
File::open opens an existing file for reading. File::create creates a new file for writing, truncating it if it already exists. For more control (append, read+write), use OpenOptions.