BYTEMAGMA

Master Rust Programming

Input and Output in Rust: Reading, Writing, and Handling Files

Introduction

Input and output (I/O) are essential parts of many applications, for reading configuration files, processing user input, logging output, saving results to disk, etc. Performing I/O in Rust involves the std::io and std::fs modules, and also other key Rust features, such as ownership, error handling, and traits such as Read and Write.

In this post, we’ll dive deep into how Rust handles I/O operations, covering basic terminal input and output, file manipulation and binary data processing.

You’ll learn how to read from and write to both standard input/output and files, how to buffer I/O for better performance, and how to gracefully handle I/O errors the Rust way. We’ll also look at how Rust works with paths and metadata.

By the end of this post, you’ll have a good understanding of how to work with I/O in Rust.


The std::io and std::fs Modules

Rust’s standard library provides two key modules for input and output: std::io and std::fs. Understanding the roles of these modules is the foundation for performing effective and idiomatic I/O operations in Rust.

Overview of the std::io Module

The std::io module is the core module for handling input and output in Rust. It provides:

  • Traits like Read, Write, BufRead, and Seek that define how data can be read from or written to different sources and sinks.
  • Types like Stdin, Stdout, Stderr, BufReader, and BufWriter that implement these traits.
  • Utility functions and error types that make it easier to manage I/O operations robustly and efficiently.

The std::io module allows you to interact with streams—whether that stream is standard input/output, a network connection, or a file opened via std::fs.

Overview of the std::fs Module

While std::io defines how I/O works, std::fs focuses on where I/O happens—it’s all about working with the filesystem.

The std::fs module provides functions and types for:

  • Opening and creating files (File::open, File::create)
  • Reading and writing files with convenience functions (fs::read_to_string, fs::write)
  • Managing directories and file paths
  • Querying file metadata, like size, permissions, and timestamps

The File type returned by many std::fs functions implements traits from std::io like Read and Write, allowing it to seamlessly work with I/O functions from that module.

Common Traits in Rust I/O: Read, Write, BufRead, and Seek

These traits, all from std::io, are essential to understanding how data flows in Rust’s I/O system:

  • Read: For types you can read bytes from (e.g., files, stdin, network streams).
  • Write: For types you can write bytes to (e.g., files, stdout).
  • BufRead: For buffered readers that provide additional methods like read_line and lines, improving efficiency and ease of use.
  • Seek: For types that support random access to data via seeking (e.g., jumping to specific byte offsets in a file).

These traits are designed to be composable and generic, making it easy to write reusable code that works with any readable or writable source—not just files.

Composable means that you can combine different types and behaviors in flexible ways to build more complex functionality without rewriting or duplicating code.

For example:

  • You can wrap a File (which implements Read) in a BufReader (which implements BufRead) to get efficient, line-by-line reading.
  • You can layer multiple I/O tools together, like wrapping a TcpStream in a BufWriter, and then use Write methods on it.
  • You can write a function that takes anything that implements Read, and pass in a File, BufReader<File>, or even stdin()—without needing separate code for each.

Composable traits let you reuse components elegantly. This is a key design principle in Rust: build small, focused abstractions that work well together.


Standard Input and Output

In many command-line programs, interacting with the user is essential—whether you’re asking for input, showing progress, or printing results. Rust provides several macros and functions for working with standard input, output, and error streams through the std::io module and built-in macros like print!, println!, eprint!, and eprintln!.

This section covers how to read user input, write to the terminal, and handle error output—with real-world examples you can reuse in CLI tools or small utilities.

Reading User Input from the Terminal

Using stdin()

Rust uses the std::io::stdin() function to provide access to standard input. To actually capture input, you typically combine it with the Read or BufRead traits.

The Read trait in std::io is the primary trait for reading bytes from a source—such as a file, standard input, a network stream, or any custom data stream. It provides the read() method, a low-level method that reads raw bytes and tries to fill buf with bytes from the source.

fn read(&mut self, buf: &mut [u8]) -> Result<usize>

Types like File, Stdin, TcpStream, and &[u8] implement Read.

Use read() when you:

  • want to read a fixed number of bytes
  • are dealing with binary data
  • are doing your own buffering or data interpretation

The BufRead trait is a higher-level trait built on top of Read. It’s for types that buffer input internally and expose convenience methods for reading data efficiently and line-by-line. Buffering means reading or writing data in larger chunks to reduce the number of slow, low-level operations and improve overall I/O performance.

The BufRead trait provides methods like:

fn fill_buf(&mut self) -> Result<&[u8]> 
fn consume(&mut self, amt: usize) 
fn read_line(&mut self, buf: &mut String) -> Result<usize> 
fn lines(&mut self) -> Lines<Self>

Types that implement BufRead must also implement Read, but with extra functionality for efficient buffered reading.

Use BufRead methods when:

  • you want to read text line-by-line
  • performance matters (reduces system calls)
  • reading from slower sources like stdin or files

If you’re working with text (especially line-by-line), use BufRead—it’s safer and more ergonomic (makes your code easier to write, read, and maintain). If you’re working with binary or need full control, go with Read.


Let’s see how to read text from stdin, reading the input line-by-line with stdin().read_line().

Open a shell window (Terminal on Mac/Linux, Command Prompt or PowerShell on Windows). Then navigate to the directory where you store Rust packages for this blog series, and run the following command:

cargo new input_output

Next, change into the newly created input_output directory and open it in VS Code (or your favorite IDE).

Note: Using VS Code is highly recommended for following along with this blog series. Be sure to install the Rust Analyzer extension — it offers powerful features like code completion, inline type hints, and quick fixes.

Also, make sure you’re opening the input_output directory itself in VS Code. If you open a parent folder instead, the Rust Analyzer extension might not work properly — or at all.

Replace the default code in src/main.rs with the following:

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

fn main() {
    print!("Enter your name: ");
    io::stdout().flush().unwrap(); // Ensure prompt appears before input

    let mut name = String::new();
    io::stdin().read_line(&mut name).expect("Failed to read line");

    println!("Hello, {}!", name.trim());
}

Execute cargo run, enter your name in the shell window when prompted, and you should see something like this:

Enter your name: Greg
Hello, Greg!

Notice these two lines:

print!("Enter your name: ");
io::stdout().flush().unwrap(); // Ensure prompt appears before input

After we prompt the user to enter their name, we need to call flush() because Rust might hold onto your string “Enter your name: “ in a buffer and the user might never see it! So flush() forces Rust to empty the buffer and display your text on the screen. Very important!

Notice how we create a mutable String variable name and pass that to read_line(). We’re basically saying, “read a line from stdin and stick it in this variable I am providing”.

Note the use of expect():

io::stdin().read_line(&mut name).expect("Failed to read line");

read_line() could result in an error, so expect("Your error message here") is a handy way of providing an error message to display on error.

Notice that we call trim() on the String variable name to remove any whitespace the user might have entered before or after entering their name. trim() is a String method, one of many String methods providing handy functionality like this.


Now let’s see an example of reading and parsing numeric input. Comment out our current code in main.rs with a block comment /* code to comment out */ and then put the following code in main.rs.

Note, instead you could create a new file main_two.rs in the src/bin directory (you need to create the bin directory) and put this code there, but then to run that file you need to execute cargo run --bin main_two.rs. If you do this then to run the code from the previous example you need to execute cargo run --bin input_output.

use std::io;

fn main() {
    println!("Enter your age:");

    let mut input = String::new();
    io::stdin().read_line(&mut input).expect("Failed to read input");

    let age: u32 = input.trim().parse().expect("Please enter a valid number");
    println!("In 5 years, you’ll be {}.", age + 5);
}

Execute cargo run (or cargo run --bin main_two) and you should be prompted for your age and see something like this:

Enter your age:
45
In 5 years, you’ll be 50.

Hey, why didn’t I need to use flush() after prompting the user?

This is because in the previous example we prompted the user with the print!() macro, which does not automatically add a newline character after the prompt text, so the prompt text might stay in the buffer, so we need to flush() the buffer.

But in our new example we used println!() which DOES add a newline character after the prompt text. println!() usually triggers a flush because stdout is line-buffered on most platforms.

So be aware of this when prompting the user using print!() or println!().

Note: print!() prompts the user and DOES NOT move the cursor to the next line (no newline character). println!() prompts the user and DOES move the cursor to the next line (newline character). So choose print!() or println!() depending on the behavior you want.

Notice this line:

let age: u32 = input.trim().parse().expect("Please enter a valid number");

Before this line we did the same thing as the first example, created a String variable, and used read_line() to place the user input (their age) into that String variable. But we want to treat their input as a number, and because all stdin input comes in as a String, on the above line we call parse() to convert the String (age) into an integer.


Now let’s look at an example of handling user input in a loop, and also handling errors. Replace or comment out the code in main.rs with the following code (or create a new file src/bin/main_three.rs).

In this code, we immediately start an infinite loop. It will keep running until the user types “quit” to exit, presses Ctrl + C, or until the Sun reaches the end of its life, expands into a red giant, and engulfs the inner planets—including Earth.

use std::io;

fn main() {
    loop {
        println!("Enter a number (or type 'quit' to exit):");

        let mut input = String::new();
        io::stdin().read_line(&mut input).expect("Failed to read");

        let trimmed = input.trim();
        if trimmed.eq_ignore_ascii_case("quit") {
            break;
        }

        match trimmed.parse::<i32>() {
            Ok(num) => println!("You entered: {}", num),
            Err(_) => println!("Invalid number, please try again."),
        }
    }
}

Inside the loop we prompt the user to enter a number or “quit” to exit the loop.

We read their input, trim it, and then check if they entered “quit”. If so, we use the break keyword to exit the loop and function main() ends and the program terminates. If the user entered a number, execution proceeds to the next line in the loop code block.

We use a match construct to check the result of calling parse::<i32>() on the trimmed String. parse::<i32>()is a generic method that we are using to convert the String to an i32.

parse::<i32>()returns a Result<T, E>, and in this case will either be an instance of the Ok(i32) variant of Result, or an instance of the Err(error) variant of Result, with the error being dependent on what you are trying to parse.

Our match arms handle the Ok() case, capturing the actual number the user entered in a temporary variable called num (or whatever name you put in Ok()), and we use that num in our println!().

The other match arm handles the Err(_) case. The underscore in Err(_) tells Rust we don’t care what the error was, we’re just going to print our own general message telling the user they entered an invalid number.

After this match construct, execution goes back to the top of the loop code block and we prompt the user to enter another number, or quit to exit.

This kind of infinite loop allows you to perform actions for the user multiple times, such as parsing files to get word counts, etc. until the user decides they are done and they choose to exit the program.


Writing Output to the Terminal

Rust provides the print! and println! macros to write to standard output. They are ideal for user-friendly messages, prompts, logs, and any general information you want the user to see. We’ve already seen examples of using print!() and println!().

print!() outputs text to stdout (the shell window, the terminal) but does not move the cursor to the next line with a newline character (\n). This is why we need to call io::stdout().flush() to flush the buffer so our text is displayed immediately.

println!() also outputs to stdout but it automatically also outputs a newline character, and thus the cursor moves to the next line, and our text is displayed automatically without the need to call flush().

You choose between print!() and println!() depending on whether you want the cursor to stay on the same line or move to the next line.

Here are a few more examples of using print!() and println!():

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

fn main() {
    print!("What's your favorite color? ");
    io::stdout().flush().unwrap(); // Make sure the prompt shows

    let mut color = String::new();
    io::stdin().read_line(&mut color).unwrap();

    println!("Nice! {} is a great color.", color.trim());
}

fn main() {
    let items = ["apple", "banana", "cherry"];
    println!("You have {} items in your cart:", items.len());

    for item in items {
        println!("- {}", item);
    }
}

fn main() {
    let name = "Greg";
    let language = "Rust";
    println!("Hello, {name}! Ready to write some {language} code?");
}

Using eprint! and eprintln! for stderr

Sometimes, you want to separate regular output from error messages—especially in CLI tools (Command Line Interface tools), where stdout might be piped to a file or another command. That’s where eprint! and eprintln! come in: they write to stderr (standard error) instead of stdout (standard output).

Note that if you don’t redirect stdout or stderr output to file, then by default they both output to the terminal. You redirect stdout to output to a file with > and you redirect stderr to file with 2>.

Example (in bash or most Unix shells):

my_program > out.txt 2> err.txt
  • > redirects stdout to out.txt
  • 2> redirects stderr to err.txt

So:

  • Regular output (println!) goes to out.txt
  • Errors (eprintln!) go to err.txt

Use cases:

  • Displaying errors without disrupting standard output
  • Logging warnings during processing
  • Debug output

Here are a few examples:

fn main() {
    let filename = "missing.txt";
    eprintln!("Error: Could not open file '{}'", filename);
}

fn main() {
    for i in 1..=5 {
        eprintln!("Processing item {}...", i);
        // Simulate processing
    }
    println!("Done processing all items.");
}

fn main() {
    println!("This goes to standard output.");
    eprintln!("This is an error message on stderr.");
}

Bonus Tips

  • Always flush stdout when using print!() without a newline to ensure the output appears immediately.
  • Trim input when using read_line() to avoid newline characters affecting logic or parsing.
  • Prefer eprintln!() for anything diagnostic or error-related—it keeps your stdout clean and script-friendly.

Working with Files

Rust’s standard library offers great tools for interacting with the filesystem. Understanding how to work with files and directories is essential for building a command-line tools, writing logs, reading configuration files, generating reports, and many other tasks.

We’ll start with the basics and then explore more advanced use cases.


Opening and Creating Files

Opening Files

File::open()– opens an existing file in read-only mode. It is a static method implemented directly on the File struct, which is part of the std::fs module.

impl File {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<File>
}

This is an associated function implemented on the File struct, not a method coming from a trait like Read, Write, or Seek. However, once you have a File object (after using File::open(), also known as a file handle), you can use various trait methods on it—because File implements several I/O-related traits:

  • Read (for reading bytes)
  • Write (for writing bytes)
  • Seek (for moving the file cursor)
  • AsRawFd (on Unix) or AsRawHandle (on Windows)

The AsRef<Path> part is a trait bound that means:

“This function can accept any type P that implements the AsRef<Path> trait—i.e., anything that can be borrowed as a Path“.

This function signature uses generics and trait bounds. If you need a refresher on these topics, this ByteMagma post might help: Generics in Rust: Writing Flexible and Reusable Code

So in summary:

  • File::open() is not from a trait, just a plain associated function.
  • But File itself implements I/O traits, which are traits like Read, Write, etc.

Let’s look at an example. Replace the code in main.rs with this code, or comment it out and paste this code:

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

fn main() -> io::Result<()> {
    let file = File::open("example.txt")?;
    Ok(())
}

This code tries to open a file named example.txt. The file should be located in the root package directory input_output, the parent directory of the src directory.

If the file does not exist you’ll get an error:

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

If the file doesn’t exist the program does nothing. It simply opens the file and then the program exits. We’ll see examples of actually making use of the file soon.

Notice the main() function signature and return value:

fn main() -> io::Result<()> {
    let file = File::open("example.txt")?;
    Ok(())
}

Here main() is defined to return a Result<()>. This means we want to return a Result Ok() variant with no data or an Err() variant.

The ? Operator

This line:

let file = File::open("example.txt")?;
  • Tries to open the file.
  • If it fails, it returns early from main() with the Err.
  • If it succeeds, file gets the File object.

Real-world examples when opening files:

  • Reading a user-supplied configuration file.
  • Loading a CSV data file into memory.
  • Opening a markdown file to render HTML.

Creating Files

File::create – creates a file for writing if the file doesn’t exist, or if the file already exists, the file is recreated and its contents are wiped out, removed.

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

fn main() -> io::Result<()> {
    let file = File::create("output.txt")?;
    Ok(())
}

Just like File::open, File::create is a static/associated function implemented directly on the File struct—not from a trait. Here’s the signature from the standard library:

impl File {
    pub fn create<P: AsRef<Path>>(path: P) -> Result<File>
}

These are some of the traits File implements:

  • Read – so you can read bytes from it
  • Write – so you can write to it
  • Seek – so you can move the cursor around
  • Debug, AsRef, and some platform-specific traits like AsRawFd

If you replace the code in main.rs with the provided example code and execute it, file output.txt will be created at the package root (parent of the src directory) if the file does not already exist. Run the code, then open output.txt and add some text. Run the code and the file is still there but is now empty. It was recreated and the previous contents wiped out.

Real-world examples of outputting to file:

  • Saving generated reports or logs.
  • Writing the output of a data scraper.
  • Exporting user-entered text from a GUI app.

OpenOptions for More Control

For fine-grained control over file access, use OpenOptions. It allows combinations like append, truncate, read+write, etc.

use std::fs::OpenOptions;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open("log.txt")?;

    writeln!(file, "New log entry")?;
    Ok(())
}

This code opens file log.txt in append mode if it exists, otherwise it creates the file.

Real-world examples of using OpenOptions:

  • Appending to a log file without deleting existing content.
  • Safely opening a cache file to read/write data.
  • Avoiding accidental truncation of files in production.

Writing to Files

write! and writeln!

These macros work with anything implementing the Write trait, including files.

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

fn main() -> io::Result<()> {
    let mut file = File::create("hello.txt")?;
    writeln!(file, "Hello, world!")?;
    Ok(())
}

The File struct implements Write, so you can use it to write strings, byte slices, or formatted data.

Writing String Data with write_all():

fn main() -> std::io::Result<()> {
    let mut file = File::create("data.txt")?;    file.write_all("Hello".as_bytes())?; // a string as bytes    file.write_all(b"Raw byte data\n")?; // another string as bytes
    Ok(())
}

Writing Formatted Data with write!() or writeln!():

use std::io::Write;

writeln!(file, "Name: {}, Score: {}", name, score)?;
write!(file, "Hello {}", "Greg")?;
file.write_fmt(format_args!("Hello {}", "Greg"))?; // a friendlier version of write!()

write!() and writeln!() are macros that let you write formatted strings into anything that implements the std::io::Write trait (like File, Vec<u8>, etc). write!() doesn’t write a newline \n. writeln!() does write a newline.

write_fmt() is a method defined by the Write trait, and is the underlying method used by these macros.

Function/MacroAdds newline?Easy to use?Used directly?
write!()❌ No✅ Yes ✅ Common
writeln!()✅ Yes✅ Yes ✅ Common
write_fmt()❌ No❌ Low-levelRare, low-level use

Real-world examples:

  • Writing logs in JSON format for later ingestion.
  • Saving downloaded content to disk.
  • Writing a list of email addresses to a file.

Reading from Files

fs::read_to_string – quick and convenient for reading an entire file into a String.

use std::fs;

fn main() -> std::io::Result<()> {
    let content = fs::read_to_string("example.txt")?;
    println!("{}", content);
    Ok(())
}

You’ll get an error if the file does not exist.

Real-world examples:

  • Loading templates for an HTML rendering engine.
  • Reading a simple user profile from disk.
  • Importing stopwords for a text analysis app.

Reading with File and the Read Trait

Use File and Read when you want more control, like reading chunks or processing binary data.

Reading in Chunks (Text or Binary Files)

use std::fs::File;
use std::io::{self, Read};

fn main() -> io::Result<()> {
    let mut file = File::open("big_file.txt")?;
    let mut buffer = [0u8; 512];

    loop {
        let bytes_read = file.read(&mut buffer)?;
        if bytes_read == 0 {
            break; // EOF
        }

        // Example: print the chunk as text
        print!("{}", String::from_utf8_lossy(&buffer[..bytes_read]));
    }

    Ok(())
}

Here we open a large file, create a buffer that can hold 512 bytes, and then loop through the file, reading from the file 512 bytes at a time. read() returns the number of bytes read. When there is no data left (bytes_read == 0), we break out of the loop.

String::from_utf8_lossy(...) converts a &[u8] (byte slice) into a human-readable String, even if some of the bytes are not valid UTF-8. Instead of panicking or throwing an error, it replaces invalid sequences with (the Unicode replacement character). Using from_utf8_lossy lets you still print or display the data without crashing.

Reading with File and the Read trait is useful when streaming logs, files over a network, etc, and to handle very large files that can’t fit in memory.


Processing Binary Data

use std::fs::File;
use std::io::{self, Read};

fn main() -> io::Result<()> {
    let mut file = File::open("data.bin")?;
    let mut buffer = [0u8; 4]; // read 4 bytes at a time

    loop {
        let bytes_read = file.read(&mut buffer)?;
        if bytes_read == 0 {
            break; // EOF
        }
        if bytes_read < 4 {
            eprintln!("Unexpected end of file");
            break;
        }

        // Convert 4 bytes to a u32 (assuming little-endian format)
        let number = u32::from_le_bytes(buffer);
        println!("Number: {}", number);
    }

    Ok(())
}

Here we open a file and read data 4 bytes at a time. We expect to be able to read the data in these 4 byte pieces, so if we end up with less than 4 bytes we break. We then convert the 4 bytes to a u32, assuming our use case requires that.

What does little-endian mean?

Endianness is about byte order—how multi-byte numbers are stored in memory or in a file.

  • Little-endian: Least significant byte comes first (low address).
  • Big-endian: Most significant byte comes first.

For example, the number 0x12345678 stored in little-endian byte order would be:

[0x78, 0x56, 0x34, 0x12]

This is not significant to our discussion about reading from files, just a bit of additional context.

Processing binary data like this is perfect for parsing binary formats (WAV files, images, network packets, etc.), as it gives you full control over byte-level processing.


Chunked reading is about speed and size. Binary processing is about structure and meaning.

Think of chunked reading like scooping soup out of a pot—you’re just moving it in manageable portions.
Binary processing is like carefully picking out ingredients and measuring them—you need exact pieces to make sense of the recipe.


Reading with BufReader for Line-by-Line Reading

BufReader wraps a File and reads large chunks internally, making it efficient even for line-by-line parsing from slow sources like stdin or disk files.

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

fn main() -> io::Result<()> {
    let file = File::open("lines.txt")?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        println!("{}", line?);
    }

    Ok(())
}

We open a file, create a BufReader from the file handle, then loop over the file line by line using reader.lines(), which returns an iterator over the file lines.

Key use cases:

  • Large text files, like logs or CSVs
  • When you want to read line-by-line
  • It handles buffering for you, so you’re not reading one byte at a time
FeatureBufReaderManual Chunked / Binary Read
Best for Text files, line-by-line readingBinary files, structured data
Handles buffering✅ Yes❌ No (you manage the buffer)
Reads full lines?✅ Yes❌ No
Gives full control over bytes?❌ Not directly✅ Yes
Simple to use?✅ Very⚠️ More complex, but powerful

Use BufReader when you care about lines and want buffered performance. Use manual chunking when you care about bytes and want full control.

Real-world examples:

  • Reading a log file line by line.
  • Parsing a large CSV one line at a time.
  • Processing newline-delimited JSON data.

Handling EOF and Partial Reads

In many of the examples above, we used the return value (number of bytes read) to check how many bytes were read, and then used break to exit the loop when it’s 0. This is how read() signals end-of-file (EOF).

But read() might return fewer bytes than the buffer size, even before the end of the file is reached. That’s called a partial read, and it’s normal—especially when reading from files, streams, or sockets. That’s why looping is the standard pattern when using Read.

  • read() returns 0 → you’ve hit EOF
  • read() might return less than the buffer size → partial read
  • Use a loop to handle both

We’ve learned a lot about working with files in Rust. Now let’s turn our attention to working with directories, another common part of I/O.


Working with Directories

Creating Directories

Use fs::create_dir to create a single directory, and create_dir_all to create all nested directories in a path.

use std::fs;

fn main() -> std::io::Result<()> {
    fs::create_dir("data")?;
    fs::create_dir_all("data/reports/2025")?;
    Ok(())
}

create_dir() creates a single directory and returns an error if the directory already exists.
create_dir_all() creates a full directory path (all nested directories) and quietly does nothing for directories that are already present—making it the safer choice when you’re not sure what exists yet.

Real-world examples:

  • Preparing a folder structure before saving files.
  • Creating backup or export directories on the fly.
  • Ensuring a cache directory exists at startup.

Removing Directories

Use fs::remove_dir for empty directories and remove_dir_all for recursive deletion (delete a directory and its contents, and all sub-directories as well).

use std::fs;

fn main() -> std::io::Result<()> {
    fs::remove_dir("data")?;
    fs::remove_dir_all("temp_data")?;
    Ok(())
}

fs::remove_dir() is meant for removing an empty directory, so it is a safe default to avoid accidentally deleting important files or subfolders.

remove_dir_all() deletes the directory and all of its contents permanently (files and sub-directories), with no warning or confirmation. Use it with caution, especially in scripts or automation.

Real-world examples:

  • Cleaning up temporary workspaces.
  • Resetting app state on startup.
  • Deleting outdated report directories.

Checking if a Path is a File, Directory, Symbolic Link, etc.

Use metadata or symlink_metadata to get information on the target.

metadata and symlink_metadata are used for checking what a path points to—but they do more than just that.

What is metadata()?

let meta = fs::metadata("some_path")?;
  • It gets metadata about the target that the path points to.
  • If "some_path" is a symlink, it follows the link and returns info about the target.
  • So if "some_path" is a symlink to a file, meta.is_file() will return true.

What is symlink_metadata()?

let meta = fs::symlink_metadata("some_path")?;
  • It gets metadata about the path itself, without following symlinks.
  • This is how you check whether a path is a symlink, not just where it points.
FunctionFollows symlinks?Use case
metadata(path)✅ YesCheck the target of the path
symlink_metadata(path)❌ NoCheck if the path is a symlink

Once you have metadata (using either of these functions), you can call:

meta.is_file(); // true if it's a file (not a directory or symlink)
meta.is_dir(); // true if it's a directory
meta.file_type().is_symlink(); // true if it's a symlink (only works with symlink_metadata)

symlink_metadata() allows you to get additional information about the target:

meta.len();
meta.permissions();
meta.modified()?; // etc.

Reading Directory Contents

fs::read_dir returns an iterator of entries. It lists the immediate contents of a directory—files, subdirectories, and symlinks. It does not look inside subdirectories. If you want a recursive listing, you’ll need to walk the directory tree manually or use a crate like walkdir.

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

fn main() -> io::Result<()> {
    for entry in fs::read_dir("logs")? {
        let entry = entry?;
        let path = entry.path();
        let metadata = entry.metadata()?; // Follows symlinks

        if metadata.is_dir() {
            println!("Directory: {}", path.display());
        } else if metadata.is_file() {
            println!("File: {}", path.display());
        } else {
            println!("Other: {}", path.display());
        }
    }

    Ok(())
}

Recursively Iterating Directories

For deep directory traversal (including subdirectories), you have two common options:

Manual recursion

This approach uses standard library tools and gives you full control.

use std::fs;
use std::path::Path;

fn visit_dirs(dir: &Path) -> std::io::Result<()> {
    if dir.is_dir() {
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                visit_dirs(&path)?;
            } else {
                println!("File: {}", path.display());
            }
        }
    }
    Ok(())
}

fn main() -> std::io::Result<()> {
    visit_dirs(Path::new("my_project"))?;
    Ok(())
}

Using the walkdir crate (simpler and more powerful)

For most real-world projects, walkdir is easier and more flexible. It handles recursion, symlinks, sorting, depth limits, and error handling for you.

use walkdir::WalkDir;

fn main() {
    for entry in WalkDir::new("my_project") {
        let entry = entry.unwrap();
        if entry.file_type().is_file() {
            println!("File: {}", entry.path().display());
        }
    }
}

Manual recursion gives you fine control. The walkdir crate gives you power and convenience out of the box.

Real-world examples:

  • Scanning a project for .rs files.
  • Searching for media files (images, videos) in nested folders.
  • Backing up all files in a workspace.

File Locking in Rust

It can be a problem when multiple processes or threads attempt to read from or write to the same file at the same time. Without coordination, simultaneous writes might overlap or interleave, leading to data corruption or inconsistent state.

File locking provides a synchronization mechanism to prevent these issues. It ensures exclusive access to a file when performing write operations or allows shared access for multiple readers while blocking writers.

This is crucial when:

  • Multiple processes are appending to the same log file
  • A background service writes to a configuration file
  • Concurrent routines update a simple database file

Surprisingly, Rust’s standard library does not include built-in file locking. While it excels at safety and concurrency, file locking touches platform-specific OS features, and Rust chooses to leave that to external crates.

External crates in Rust are community-maintained libraries you can add to your project to extend its functionality. You include them by declaring them in your Cargo.toml file, and they’re fetched from crates.io, Rust’s package registry.

External crates let you avoid reinventing the wheel and tap into powerful, well-tested code written by others.

Note that file locking mechanisms differ between platforms:

  • Unix systems (Linux/macOS) often rely on flock or fcntl.
  • Windows uses the WinAPI’s file locking facilities.

Thankfully, some crates abstract over these differences to offer cross-platform support.


Using the fs2 Crate

To work with file locks in a cross-platform way, we can use the fs2 crate. It extends the standard std::fs::File type with lock-related methods and supports both exclusive and shared locks on Unix and Windows.

Let’s create a new Rust package to use as we look at file locking. Execute cargo new file_locking, then CD into the file_locking directory and open it in VS Code.

Now add this to the Cargo.toml file:

[dependencies]
chrono = "0.4"
fs2 = "0.4"

chrono is a popular Rust crate for working with dates and times. It provides:

  • Date and time types, like Utc, Local, DateTime, and NaiveDate.
  • Formatting and parsing, so you can turn timestamps into readable strings (and vice versa).
  • Time zone handling, including UTC and local time.
  • Arithmetic, like adding or subtracting durations from timestamps.

Understanding Exclusive vs Shared Locks

When managing access to shared resources, locks help coordinate which processes can read or write data. There are two common types of locks:

  • Exclusive Lock – lock_exclusive(): Used by writers to ensure they have sole access to a resource. Only one process can hold this lock at a time—no other readers or writers are allowed.
  • Shared Lock – lock_shared(): Used by readers to safely access a resource. Multiple processes can hold shared locks simultaneously, but only if no exclusive lock is active.

Mini Example: Locking a Log File Before Writing

Let’s say we have a log file that multiple processes might write to. We want to lock it exclusively before appending, so log entries don’t overlap. Replace the contents of main.rs with the following:

use std::fs::OpenOptions;
use std::io::Write;
use fs2::FileExt;use chrono::Utc;
fn main() -> std::io::Result<()> {
    let log_path = "app.log";

    // Open (or create) the log file in append mode
    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open(log_path)?;

    // Lock the file exclusively before writing
    file.lock_exclusive()?; // blocks until the lock is available

    writeln!(file, "[{}] Application started", Utc::now())?;

    // File lock is automatically released when file is dropped
    Ok(())
}

Our code opens the file app.log, creating it if it doesn’t already exist, and opens it in append mode so new content is added to the end. We then acquire an exclusive lock, which blocks other processes from writing to the file until we’re done with it.

Next, we write a message to the file, including a timestamp representing the current UTC date and time. If you open the file after running cargo run, you’ll see something like this (your date and time will vary):

[2025-04-11 02:16:41.624633 UTC] Application started

If you execute the program multiple times you’ll see multiple log entries.

Note: The lock is released automatically when file goes out of scope, thanks to Rust’s ownership and the Drop trait.

In this example, we acquire an exclusive lock on the file. This means that while our code is holding the lock, other processes that also try to lock the file (either for reading or writing) will be blocked until we’re done.

However, file locking in Rust (and most systems) is advisory, not enforced by the OS. So if other code tries to read the file without first acquiring a lock, it can still do so — though it risks reading partial or incomplete data if we’re in the middle of writing.

In cooperative systems where all readers and writers use locking, this exclusive lock ensures that only one process writes at a time, and no readers access the file during the write.


Shared Lock Example: Reading a Config File

Shared locks allow multiple readers to access a resource at the same time, but they block writers from making changes while the lock is held. This is useful when different parts of a program need to read something like a configuration file without risking interference from concurrent writes.

Comment out the current code in main.rs /* CODE TO COMMENT OUT */ and add this code:

use std::fs::File;
use std::io::{BufRead, BufReader};
use fs2::FileExt;

fn main() -> std::io::Result<()> {
    let file = File::open("config.ini")?;
    file.lock_shared()?; // Acquire a shared lock

    let reader = BufReader::new(&file);
    for line in reader.lines() {
        println!("Config Line: {}", line?);
    }

    // Lock is automatically released here
    Ok(())
}

Now add a file file_locking/config.ini and add this content:

[database]
host=localhost
port=5432
username=admin
password=secret

[logging]
level=info
file=app.log

[features]
enable_cache=true
max_connections=100

Execute cargo run and you should see output like this:

Config Line: [database]
Config Line: host=localhost
Config Line: port=5432
Config Line: username=admin
Config Line: password=secret
Config Line: 
Config Line: [logging]
Config Line: level=info
Config Line: file=app.log
Config Line: 
Config Line: [features]
Config Line: enable_cache=true
Config Line: max_connections=100

Our code opens a file, acquires a shared lock, creates a BufReader for the file, and iterates over the file lines, printing a message that includes the line from the file.


Error Handling with Locks

If you cannot acquire a lock, for example the file is locked by another process, then your call to fs2‘s lock_exclusive() or lock_shared() will block until it becomes available.

If you prefer non-blocking behavior, fs2 doesn’t directly support it—but you can simulate it by attempting to acquire the lock in a separate thread or using timeouts.

use std::time::{Duration, Instant};
use std::fs::OpenOptions;
use std::io::Write;
use fs2::FileExt;

fn try_lock_with_timeout(file: &std::fs::File, timeout: Duration) -> std::io::Result<()> {
    let start = Instant::now();

    loop {
        match file.try_lock_exclusive() {
            Ok(_) => return Ok(()),
            Err(e) if start.elapsed() < timeout => {
                std::thread::sleep(Duration::from_millis(50));
            }
            Err(e) => return Err(e),
        }
    }
}

fn main() -> std::io::Result<()> {
    let log_path = "app.log";

    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open(log_path)?;

    // Try to acquire exclusive lock with a 5-second timeout
    match try_lock_with_timeout(&file, Duration::from_secs(5)) {
        Ok(()) => {
            writeln!(file, "[{}] Application started with timeout lock", chrono::Utc::now())?;
        }
        Err(e) => {
            eprintln!("Failed to acquire lock within timeout: {}", e);
        }
    }

    Ok(())
}

This code includes a utility function try_lock_with_timeout() to attempt to get a lock on a file. If the attempt is successful then will return a Result enum Ok() variant with no data. Returning Ok() is enough to signal that a lock on the file was acquired. The function continues looping if the timeout hasn’t expired; otherwise, it returns an error.

If the elapsed time the function is keeping track of is less than the timeout duration provided to the function, then the function sleeps for 50 milliseconds. If we still get an error after the timeout then we just return the error as the lock could not be acquired.

In main() we call this utility function try_lock_with_timeout(), passing a file handle and a 5 second timeout duration, and we print a message with the result of either getting a lock or failing to get a lock.

The key point is that main() calls this utility function once. The function doesn’t return a value until:

  • a lock is acquired
  • the timeout duration is reached and a lock could not be acquired
  • an error occurred trying to acquire a lock

This gives you control to implement backoff or timeout behavior manually.


Releasing a Lock

You don’t need to do anything special to release a lock. Once the File handle goes out of scope and is dropped, Rust automatically releases the lock. If you want to release it earlier, you can do so explicitly:

use fs2::FileExt;
use std::fs::File;

fn main() -> std::io::Result<()> {
    let file = File::create("temp.txt")?;
    file.lock_exclusive()?; // Lock acquired

    // Do stuff...
    
    file.unlock()?; // Explicit unlock
    Ok(())
}

Calling file.unlock() explicitly releases the lock if that is a requirement for you.


Cross-Platform Considerations

The fs2 crate handles cross-platform compatibility under the hood.

  • Unix: Uses flock for advisory locks
  • Windows: Uses LockFileEx via the Windows API

Because of this, your code will just work on both platforms without conditional compilation.


Summary

File locking is essential for safe concurrent file access. Though Rust’s standard library doesn’t provide it out of the box, the fs2 crate fills the gap nicely:

  • Cross-platform support (Windows + Unix)
  • Easy-to-use API
  • Automatic lock release with RAII

Whether you’re managing logs, config files, or local file-based queues, proper file locking keeps your data safe and your operations predictable.


Working with Temporary Files

Sometimes, your program needs a place to stash data temporarily: intermediate output, test artifacts, or cached results. Temporary files are easy to use, are automatically cleaned up, and are safe.

The community-powered tempfile crate helps you work with temporary files in Rust. A bit later we’ll see how we can include the tempfile crate as a dependency of your program in the Cargo.toml file.


Why Use Temporary Files?

Temporary files are useful in many scenarios:

  • Safe scratch space for intermediate computations.
  • Short-term caching, like storing a web response or a resized image.
  • Command-line tools that need to generate output for another tool to consume.
  • Testing file-based code without polluting your file system.

These files aren’t meant to persist. We want the OS to delete them when we’re done. That’s where temporary file handling really shines—especially with Rust’s safety guarantees.


The Standard Library: No Direct Temp File Support

The Rust standard library doesn’t offer built-in support for temporary files. You can always create a temporary file in using Rust’s I/O capabilities manually, but that opens you up to name collisions, race conditions, and cleanup headaches.

Let’s see how we can do this more effectively with the tempfile crate.


Introducing the tempfile Crate

tempfile is a community crate that handles temporary files the right way:

  • Automatically deleted when they go out of scope
  • Unique, non-colliding names
  • Support for unnamed and named temporary files
  • Simple Read/Write usage just like any standard file

You can learn more about it on crates.io, the registry for community built Rust crates.

To get started, lets create a new Rust package we’ll use to discuss working with temporary files.

In the directory where you store Rust packages for this blog, execute cargo new temporary_files. Then CD into the temporary_files directory and also open it in VS Code.

Now add tempfile to Cargo.toml for the new package:

[dependencies]
tempfile = "3"

Creating Temporary Files

You’ve got two main choices depending on your use case:

  • unnamed temp files
  • named temp files with paths

tempfile::tempfile() – Unnamed Temp Files

Use this when you just need a scratch file but don’t care about the file name.

Replace the code in main.rs with this content:

use tempfile::tempfile;
use std::io::{Write, Seek, SeekFrom, Read};

fn main() -> std::io::Result<()> {
    let mut file = tempfile()?; // Automatically deleted on drop

    writeln!(file, "Temporary data here!")?;

    file.seek(SeekFrom::Start(0))?;

    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    println!("File contents: {}", contents);

    Ok(())
}

Here we call tempfile() to get a file handle to a temporary file. We write to it, move the cursor back to the beginning of the file, then we read the file contents into a String and print it out.

Note that when we write to a file, the cursor moves to the end of the content we wrote to file. If we don’t seek back to the beginning of the file then we would read nothing, because the cursor is at the end of the file.

Notice how we’re not deleting the temporary file, Rust does it when file goes out of scope.

Note that even if you put code for a delay in your code you still won’t be able to see the temporary file during the delay. The file has no name and no path, it is managed by the operating system and is invisible!


tempfile::NamedTempFile – Named Temp Files with Paths

Use this when you need to pass the file to another process, log its location, or access it from the file system.

use tempfile::NamedTempFile;
use std::{io::Write, thread, time::Duration};

fn main() -> std::io::Result<()> {
    let mut named_file = NamedTempFile::new()?;

    writeln!(named_file, "Rust loves safe temp files!")?;

    // Print the full absolute path to the temp file
    println!("Temp file created at: {}", named_file.path().display());

    // Sleep to allow time for manual inspection
    println!("Sleeping for 5 seconds so you can inspect the file...");
    thread::sleep(Duration::from_secs(5));

    // File gets deleted automatically when named_file is dropped
    Ok(())
}

In this code we use NamedTempFile to get a named temporary file handle, write some text to the file, and we print the path where the operating system stored the file. Then we sleep for 5 seconds in case you want to examine the file.

This code example is very similar to the previous one with the unnamed file, and we’re not really making use of the “named file”. But we could use it, for example with the following line that outputs the contents of the file using the “cat” command (Linux/Mac).

Command::new("cat").arg(file.path()).output()?;

You can access the file path with .path() and use it in shell commands, logging, etc.

Notice that you can perform the usual file operation on unnamed and named temporary files, writing to them, reading from them, etc. But they will automatically be dropped when the file handle goes out of scope.


Optional: Customizing Location or Prefix

You can use Builder so your temp file lives somewhere else or have a specific prefix and/or suffix.

use tempfile::Builder;

fn main() -> std::io::Result<()> {
    let custom_file = Builder::new()
        .prefix("rusty_temp_")
        .suffix(".log")
        .tempfile()?; // Unnamed temporary file

    println!("Custom temp file created");

    Ok(())
}

For named files with a custom path, use .tempfile_in("/my/tmp") to specify the custom path.


Mini Example: Writing Intermediate CLI Output to a Temp File

Let’s say you’re building a CLI that runs an expensive computation and then passes the result to another command-line utility, but you don’t want to persist the file.

use tempfile::NamedTempFile;
use std::io::Write;
use std::process::Command;

fn main() -> std::io::Result<()> {
    let mut tmp = NamedTempFile::new()?;

    writeln!(tmp, "Hello from your CLI tool!")?;

    let path = tmp.path();

    let output = Command::new("cat")
        .arg(path)
        .output()
        .expect("failed to execute process");

    println!("Cat output:\n{}", String::from_utf8_lossy(&output.stdout));

    Ok(())
}

Here we create a named temp file, write to it, get the path to the temp file, and pass the path to the “cat” command (Mac, Linux) to output the contents of the file.

Once your program exits, the file is gone, and you didn’t have to worry about cleanup.


Temporary files are essential for many Rust applications, from testing and caching to CLI tool development. With the tempfile crate, you get a safe, idiomatic way to create, use, and clean up temporary files without the pain of manual file handling. Next time your app needs a scratch pad, reach for tempfile—and let Rust take care of the mess.


Error Handling in I/O

I/O operations can fail for many reasons: files might not exist, permissions may be restricted, or the disk could be full. Rust makes you deal with these possibilities up front using the Result<T, std::io::Error> type. This ensures your program doesn’t silently ignore critical problems.

Let’s explore how Rust handles I/O errors using Result, pattern matching, the ErrorKind enum, and the handy ? operator.


Propagating I/O Errors using Result<T, std::io::Error>

Rust’s I/O functions typically return Result<T, std::io::Error>, which means you must either handle the error or propagate it. When writing functions that perform I/O, returning a Result allows the caller to decide how to handle errors.

Note that we’re using the std::io::Error. Rust provides many other domain specific errors:

Error TypeDescription
std::fmt::ErrorError returned when formatting fails (rare outside macros)
std::num::ParseIntErrorReturned when parsing a string into an integer fails
std::num::ParseFloatErrorReturned when parsing a string into a float fails
std::str::Utf8ErrorHappens when byte slices can’t be interpreted as UTF-8
std::string::FromUtf8ErrorHappens when converting Vec<u8> to String fails due to invalid UTF-8
std::char::CharTryFromErrorHappens when converting an integer to a char fails
std::env::VarErrorOccurs when reading environment variables fails
std::path::StripPrefixErrorWhen trying to remove a prefix from a Path that isn’t a prefix
std::sync::PoisonError<T>Happens when another thread panicked while holding a lock
std::time::SystemTimeErrorHappens when subtracting a future SystemTime from a past one

Propagate Errors When Reading a File

When writing a function that does file I/O, it’s idiomatic to return a Result<T, io::Error> and propagate any errors up the call stack, so the caller can decide how to deal with errors.

Create a new Rust package by executing cargo new input_output_errors. Then CD into the input_output_errors directory and also open that directory in VS Code.

Replace the code in main.rs with the following:

use std::fs::File;
use std::io::{self, Read};

fn read_file_contents(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // propagate open error
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // propagate read error
    Ok(contents)
}

fn main() {
    match read_file_contents("example.txt") {
        Ok(text) => println!("File contents:\n{}", text),
        Err(e) => eprintln!("Failed to read file: {}", e),
    }
}

This code has a utility function read_file_contents() to read the contents of a file. The function signature indicates it returns a Result<String, io::Error>. If there are no errors the file contents will be returned. If an error occurs, the function returns early with the error.

You can execute cargo run when file example.txt does not exist and you should see an error like this:

Failed to read file: No such file or directory (os error 2)

Then you can create file example.txt directly in the input_output_errors directory and add some text content, execute cargo run and you should see your file contents printed:

File contents:
This is some sample text.

Notice how the errors are propagated out of the function back into the main() function, where the errors can be handled with a match expression handling the Ok() and Err() cases.


Matching on I/O Errors

You can match on std::io::Error to handle specific error scenarios differently—for example, creating a file if it doesn’t exist. In the following code our utility function tries to open a file. If the open operation is successful we return the file handle.

On error, we first check if the error is of type ErrorKind::NotFound, and if so we create the file. If the error is any other type of error, such as a permissions error, we simply return the error. Back in main we have another match that prints a message depending on whether or not we were able to open the file or if we got an error.

use std::fs::File;
use std::io::{self, ErrorKind};

fn open_or_create_file(path: &str) -> Result<File, io::Error> {
    match File::open(path) {
        Ok(file) => Ok(file),
        Err(ref e) if e.kind() == ErrorKind::NotFound => {
            println!("File not found, creating a new one.");
            File::create(path)
        }
        Err(e) => Err(e),
    }
}

fn main() {
    match open_or_create_file("maybe_exists.txt") {
        Ok(_) => println!("File is ready."),
        Err(e) => eprintln!("Failed to open or create file: {}", e),
    }
}

Common Error Types (ErrorKind)

Rust’s std::io::ErrorKind enum lets you categorize I/O errors so you can respond appropriately. This is especially useful for building user-friendly or fault-tolerant systems.

use std::fs::File;
use std::io::{self, ErrorKind};

fn load_config(path: &str) -> Result<File, io::Error> {
    match File::open(path) {
        Ok(file) => Ok(file),
        Err(e) => match e.kind() {
            ErrorKind::NotFound => {
                println!("Configuration file not found.");
                Err(e)
            }
            ErrorKind::PermissionDenied => {
                println!("Access denied to config file.");
                Err(e)
            }
            _ => {
                println!("Other error: {:?}", e);
                Err(e)
            }
        },
    }
}

fn main() {
    match load_config("config.toml") {
        Ok(_) => println!("Configuration loaded."),
        Err(e) => eprintln!("Error loading config: {}", e),
    }
}

This code has match arms to check for file not found and permission errors and prints custom messages. It also has an arm using the “catch all” _ (underscore) that handles all other errors.


Using the ? Operator for Concise Error Propagation

The ? operator is one of Rust’s most elegant features. It’s a shortcut that automatically returns errors from your function early if an operation fails. It saves you from writing repetitive match or if let logic to handle each possible failure manually.

So instead of cluttering your code with match blocks or explicit error returns, ? lets you express the same logic in one line. But the function current executing must return a Result, because the ? operator needs somewhere to send the error if one occurs.

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

fn print_lines(path: &str) -> Result<(), io::Error> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        println!("{}", line?);
    }

    Ok(())
}

fn main() {
    if let Err(e) = print_lines("notes.txt") {
        eprintln!("Failed to print lines: {}", e);
    }
}

Why Use It?

  • It’s cleaner than nested match statements
  • It’s easier to read and maintain
  • It’s idiomatic Rust—almost every seasoned Rust dev uses it heavily

Rust’s I/O error handling gives you fine-grained control and safety:

  • Use Result<T, io::Error> to propagate I/O failures cleanly.
  • Use match and ErrorKind to react differently to file-not-found vs permission issues.
  • Use the ? operator to avoid unnecessary boilerplate.

This design ensures that your program doesn’t ignore real-world failures—and it makes your code more robust and maintainable.


We’ve covered a lot in this post on I/O in Rust. Rust provides powerful features to handle your I/O needs, for processing files, binary data and more. And error handling ensures your code is robust and safe.

Thank you so much for visiting, and for including ByteMagma in your journey toward Rust mastery!

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *