When the file needs to exist
You're building a tool that saves a configuration file. You compute the settings, call a function to write them, and the program exits. You check the directory. The file is empty. Or worse, the file exists but contains garbage from a previous run because you forgot to truncate it. Or you try to write a String and the compiler rejects you with a type error.
File I/O in Rust is explicit. You choose how the file opens, how data moves, and when it hits the disk. This control prevents data loss, but it means you have to make the right choices. Rust gives you a spectrum of tools, from a one-liner that handles everything to a buffered stream that lets you write megabytes without choking on system calls.
The spectrum of writing
Think of std::fs::write as a courier service. You hand it the data and the address, and it handles the whole trip. You don't see the truck. You just get a receipt. If the address is wrong, the receipt says so.
Think of opening a File as renting a desk in the post office. You sit there, write line by line. You can keep the desk open to add more later. You have more control, but you also have to manage the desk. If you leave without locking up, your work might vanish.
Rust also provides BufWriter, which is like a scratchpad on that desk. Instead of running to the mail slot for every sentence, you write a paragraph on the scratchpad and run once. This saves time when you have a lot to write.
The one-shot write
For most simple cases, you don't need a desk. You just need the courier. std::fs::write opens the file, writes the data, flushes the buffer, and closes the file. It returns a Result, so you can handle errors immediately.
use std::fs;
use std::io;
/// Writes a configuration string to a file using the convenience function.
fn write_config() -> io::Result<()> {
// fs::write opens, writes, and closes the file in one atomic operation.
// It truncates the file if it exists, so old data is replaced.
fs::write("config.txt", "key=value\n")?;
// The ? operator propagates errors. If the write fails, this function returns early.
Ok(())
}
fn main() -> io::Result<()> {
write_config()?;
Ok(())
}
fs::write expects a byte slice. If you have a string literal, Rust coerces it automatically. If you have a String or &str variable, you need to convert it. Passing a String directly triggers E0277 (the trait bound for AsRef<[u8]> is not satisfied). Call .as_bytes() on the string to fix this.
Use fs::write when you can dump the whole payload at once. It's the path of least resistance.
Writing in chunks with buffering
When you're generating a log file or a CSV report, you rarely have all the data at once. You produce lines over time. Opening and closing the file for every line is slow. System calls are expensive because they switch context between your program and the kernel. You want to batch writes.
Open a File and wrap it in a BufWriter. The BufWriter holds data in memory and writes it to the file in larger chunks. This reduces the number of system calls and boosts performance.
use std::fs::File;
use std::io::{BufWriter, Write};
/// Writes multiple log lines to a file using a buffered writer.
fn write_logs() -> std::io::Result<()> {
// File::create opens the file for writing.
// It truncates the file if it already exists, wiping previous content.
let file = File::create("app.log")?;
// BufWriter buffers data in memory to reduce system calls.
// This is crucial for performance when writing many small chunks.
let mut writer = BufWriter::new(file);
// write_all ensures the entire slice is written.
// The write() method might write only part of the slice, which is rare but possible.
writer.write_all(b"Starting application...\n")?;
writer.write_all(b"Processing data...\n")?;
writer.write_all(b"Done.\n")?;
// Flushing forces the buffer to disk.
// The BufWriter also flushes when dropped, but explicit flush is safer for logs.
writer.flush()?;
Ok(())
}
fn main() -> std::io::Result<()> {
write_logs()?;
Ok(())
}
The Write trait provides both write and write_all. The write method returns Result<usize>, indicating how many bytes were written. It might write fewer bytes than you asked for. write_all loops internally until the entire slice is written. The community convention is to use write_all unless you have a specific reason to handle partial writes. Partial writes are a headache you don't need.
Appending and custom options
File::create always truncates the file. If you want to add data to an existing file without destroying it, you need OpenOptions. This struct uses the builder pattern to compose file behavior. You can set flags for appending, creating, truncating, and more.
use std::fs::OpenOptions;
use std::io::{BufWriter, Write};
/// Appends a log entry to an existing file.
fn append_log() -> std::io::Result<()> {
// OpenOptions lets you configure how the file is opened.
// append(true) moves the cursor to the end before writing.
// create(true) creates the file if it doesn't exist.
let file = OpenOptions::new()
.append(true)
.create(true)
.open("app.log")?;
let mut writer = BufWriter::new(file);
writer.write_all(b"New log entry\n")?;
writer.flush()?;
Ok(())
}
fn main() -> std::io::Result<()> {
append_log()?;
Ok(())
}
OpenOptions is flexible. You can combine append(true) with truncate(true) to clear the file and then append, though that's usually redundant. You can use create_new(true) to fail if the file already exists, which is useful for preventing accidental overwrites.
Use OpenOptions to compose your file behavior. It's the builder pattern for file access.
Pitfalls and errors
File I/O has traps. Knowing them saves debugging time.
Buffering hides data. If your program crashes, data in a BufWriter is lost. The buffer only flushes when full or when you call flush(). For critical logs, call flush() explicitly after important writes. Don't rely on the drop flush if the process might panic.
Bytes versus strings. write_all takes &[u8]. Strings are &str. If you pass a String, the compiler rejects you with E0277. Use .as_bytes() to convert. If you have a Vec<u8>, use .as_slice() or just pass &vec.
Permissions matter. If the directory is read-only, the write fails with a PermissionDenied error. Check the path and permissions before blaming the code. A PermissionDenied error is the OS telling you to move along.
Truncation is default. File::create and fs::write wipe the file. If you want to keep data, use OpenOptions with append(true). Accidental truncation is a common bug when switching from append mode to create mode.
Error propagation. File operations return Result. If you ignore the result, you get a warning. If you use ?, the error propagates. In main, returning io::Result<()> is idiomatic. It prints the error to stderr and exits with code 1. This is a community convention that keeps error handling clean.
Decision matrix
Pick the tool that matches your write pattern. Don't buffer a single string. Don't open a new file for every byte.
Use std::fs::write when you have a complete payload and want the simplest code. It handles opening, writing, and closing automatically. It truncates the file, so use it only when replacement is the goal.
Use File::create with write_all when you need to write multiple chunks without buffering overhead, or when you need to keep the file handle open for other operations. This is rare; buffering is usually better for performance.
Use BufWriter when you are writing many small pieces of data, like log lines or CSV rows. The buffer batches writes to reduce system calls and boost performance. Always wrap the BufWriter in a scope or call flush() to ensure data persists.
Use OpenOptions when you need to append to a file, create it only if it doesn't exist, or set specific permissions. File::create always truncates, which destroys existing data. OpenOptions gives you fine-grained control over file creation and access modes.
Use write_all instead of write for almost all cases. write_all guarantees the full slice is written. write requires you to track how many bytes were written and retry the rest, which adds complexity for little gain.