File I/O in Rust
Read and write files, work with paths, handle directories, and use buffered I/O in Rust.
Reading a File
Reading a file is one of the most common I/O tasks. fs::read_to_string is the simplest option — it opens the file, reads the entire contents into a String, and closes it in one call. It returns a Result because the file might not exist, you might lack permission, or any number of other OS-level failures can occur. Always handle the error rather than unwrapping in production code.
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
// Read entire file into a String — simple but loads the whole file into memory
let content = fs::read_to_string("hello.txt")?;
println!("file contents:\n{}", content);
// Read as raw bytes when the file is not guaranteed to be UTF-8
let bytes = fs::read("hello.txt")?;
println!("file size: {} bytes", bytes.len());
Ok(())
}
Writing a File
fs::write is the counterpart to fs::read_to_string — it writes a string or byte slice to a file, creating it if it does not exist and overwriting it if it does. For more control over what happens to an existing file, use OpenOptions.
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
// Write a string — creates the file, truncates if it already exists
fs::write("output.txt", "Hello, file!\n")?;
// Write bytes
fs::write("data.bin", &[0u8, 1, 2, 3, 4])?;
// Append to an existing file using OpenOptions
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.append(true) // do not truncate — add to the end
.create(true) // create if it does not exist
.open("output.txt")?;
writeln!(file, "appended line")?;
Ok(())
}
Buffered I/O
Reading a file line by line with a BufReader is far more memory-efficient than loading the whole file into a String. The buffer reads a chunk from the OS at a time (typically 8 KB) and serves lines from that chunk, making it suitable for files of any size. BufWriter provides the same benefit on the write side, batching small writes into fewer, larger OS calls.
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
fn main() -> Result<(), io::Error> {
// BufReader — read line by line without loading the whole file
let file = File::open("input.txt")?;
let reader = BufReader::new(file);
for (line_num, line) in reader.lines().enumerate() {
let line = line?; // each line() call returns Result<String>
println!("{}: {}", line_num + 1, line);
}
// BufWriter — batch small writes to reduce OS syscall overhead
let out = File::create("output.txt")?;
let mut writer = BufWriter::new(out);
for i in 0..1000 {
writeln!(writer, "line {}", i)?;
}
// BufWriter flushes automatically when dropped, but explicit flush is good practice
writer.flush()?;
Ok(())
}
Working with Paths
Raw string paths are fragile across operating systems — Windows uses \, Unix uses /, and concatenating paths with string operations is error-prone. Path and PathBuf are the correct abstractions. Path is a borrowed path slice (like &str), and PathBuf is an owned, growable path (like String).
use std::path::{Path, PathBuf};
fn main() {
// PathBuf — owned, mutable path
let mut path = PathBuf::from("/home/user");
path.push("documents"); // joins with the OS separator
path.push("report.txt");
println!("{}", path.display()); // /home/user/documents/report.txt
// Path queries
println!("{:?}", path.file_name()); // Some("report.txt")
println!("{:?}", path.extension()); // Some("txt")
println!("{:?}", path.parent()); // Some("/home/user/documents")
println!("{:?}", path.file_stem()); // Some("report")
// Existence checks
let p = Path::new("Cargo.toml");
println!("exists: {}", p.exists());
println!("is file: {}", p.is_file());
println!("is dir: {}", p.is_dir());
// Build paths from components — OS-agnostic
let config = Path::new("config").join("app").join("settings.toml");
println!("{}", config.display()); // config/app/settings.toml (or config\app\settings.toml on Windows)
}
Reading and Writing Structured Data
Most real programs need to read and write structured data, not raw text. Combining serde with a format crate like serde_json or toml lets you serialize and deserialize Rust structs directly. The derive macros generate all the conversion code — you just describe the shape of your data.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
struct Config {
host: String,
port: u16,
debug: bool,
tags: Vec<String>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Serialize to JSON and write to file
let config = Config {
host: "localhost".to_string(),
port: 8080,
debug: true,
tags: vec!["web".to_string(), "api".to_string()],
};
let json = serde_json::to_string_pretty(&config)?;
fs::write("config.json", &json)?;
println!("wrote config:\n{}", json);
// Read file and deserialize back into a struct
let content = fs::read_to_string("config.json")?;
let loaded: Config = serde_json::from_str(&content)?;
println!("loaded: {:?}", loaded);
Ok(())
}
Directory Operations
Working with directories — listing contents, creating, removing, and walking recursively — uses the std::fs module. For recursive directory walking in production code, the walkdir crate is significantly more ergonomic than rolling your own recursion.
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
// Create directories — create_dir_all creates intermediate directories too
fs::create_dir_all("output/reports/2024")?;
// List directory contents — returns an iterator of DirEntry
for entry in fs::read_dir("src")? {
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_file() {
println!("file: {} ({} bytes)", path.display(), metadata.len());
} else if metadata.is_dir() {
println!("dir: {}", path.display());
}
}
// Remove a file
// fs::remove_file("output.txt")?;
// Remove an empty directory
// fs::remove_dir("output/reports/2024")?;
// Remove a directory and all its contents
// fs::remove_dir_all("output")?;
Ok(())
}
Environment Variables and Temporary Files
Environment variables are the standard way to configure applications without hardcoding paths or secrets. The tempfile crate creates temporary files and directories that are automatically deleted when they go out of scope — essential for test fixtures and intermediate processing.
use std::env;
use std::io::{self, Write};
fn main() -> Result<(), io::Error> {
// Read environment variables
match env::var("HOME") {
Ok(home) => println!("home: {}", home),
Err(_) => println!("HOME not set"),
}
// Iterate all environment variables
for (key, value) in env::vars() {
if key.starts_with("RUST") {
println!("{}={}", key, value);
}
}
// Command-line arguments
let args: Vec<String> = env::args().collect();
println!("program: {}", args[0]);
if args.len() > 1 {
println!("first arg: {}", args[1]);
}
Ok(())
}
Copying, Moving, and Renaming Files
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
// Copy a file — returns the number of bytes copied
let bytes = fs::copy("source.txt", "destination.txt")?;
println!("copied {} bytes", bytes);
// Rename or move a file within the same filesystem
fs::rename("old_name.txt", "new_name.txt")?;
// Get file metadata
let meta = fs::metadata("Cargo.toml")?;
println!("size: {} bytes", meta.len());
println!("readonly: {}", meta.permissions().readonly());
println!("modified: {:?}", meta.modified()?);
Ok(())
}
Reading Stdin
Reading from standard input is essential for CLI tools. stdin().lock() provides a buffered reader over stdin, and .lines() gives you an iterator that yields each line as the user types it.
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
// Read line by line until EOF (Ctrl+D on Unix, Ctrl+Z on Windows)
for line in stdin.lock().lines() {
match line {
Ok(l) => println!("you typed: {}", l),
Err(e) => {
eprintln!("error reading stdin: {}", e);
break;
}
}
}
}