
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
, andSeek
that define how data can be read from or written to different sources and sinks. - Types like
Stdin
,Stdout
,Stderr
,BufReader
, andBufWriter
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 likeread_line
andlines
, 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 implementsRead
) in aBufReader
(which implementsBufRead
) to get efficient, line-by-line reading. - You can layer multiple I/O tools together, like wrapping a
TcpStream
in aBufWriter
, and then useWrite
methods on it. - You can write a function that takes anything that implements
Read
, and pass in aFile
,BufReader<File>
, or evenstdin()
—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 toout.txt
2>
redirects stderr toerr.txt
So:
- Regular output (
println!
) goes toout.txt
- Errors (
eprintln!
) go toerr.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) orAsRawHandle
(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 likeRead
,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 theErr
. - If it succeeds,
file
gets theFile
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/Macro | Adds newline? | Easy to use? | Used directly? |
write!() | ❌ No | ✅ Yes | ✅ Common |
writeln!() | ✅ Yes | ✅ Yes | ✅ Common |
write_fmt() | ❌ No | ❌ Low-level | Rare, 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
Feature | BufReader | Manual Chunked / Binary Read |
Best for | Text files, line-by-line reading | Binary 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()
returns0
→ you’ve hit EOFread()
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 returntrue
.
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.
Function | Follows symlinks? | Use case |
metadata(path) | ✅ Yes | Check the target of the path |
symlink_metadata(path) | ❌ No | Check 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
orfcntl
. - 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
, andNaiveDate
. - 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 Type | Description |
---|---|
std::fmt::Error | Error returned when formatting fails (rare outside macros) |
std::num::ParseIntError | Returned when parsing a string into an integer fails |
std::num::ParseFloatError | Returned when parsing a string into a float fails |
std::str::Utf8Error | Happens when byte slices can’t be interpreted as UTF-8 |
std::string::FromUtf8Error | Happens when converting Vec<u8> to String fails due to invalid UTF-8 |
std::char::CharTryFromError | Happens when converting an integer to a char fails |
std::env::VarError | Occurs when reading environment variables fails |
std::path::StripPrefixError | When 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::SystemTimeError | Happens 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
andErrorKind
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!
Leave a Reply