When you need data on disk
You are building a tool that generates a configuration file. You have the content ready in memory. You need to get it onto the disk so the user can run the next step. In many languages, you open a file, write the string, and hope you remembered to close the handle. In Rust, the compiler forces you to think about the lifecycle, but it also gives you shortcuts that make the simple case trivial. You don't need a ceremony to write a file. You just need to pick the right tool for the job.
Rust treats file I/O as a conversation with the operating system. Those conversations are expensive. The OS has to switch contexts, check permissions, manage buffers, and talk to hardware. Rust wraps these operations in types that manage the file descriptor for you. When the type goes out of scope, the file closes automatically. This pattern, known as RAII, means you rarely leak file handles. You get safety without manual cleanup calls.
The one-shot helper
For the vast majority of cases where you have a complete string or byte slice and want to dump it to a file, std::fs::write is the answer. It creates the file if it doesn't exist, overwrites it if it does, writes the data, and closes the file. All in one call.
use std::fs;
/// Writes a simple configuration string to a file.
/// Returns a Result to handle potential I/O errors.
fn write_config() -> std::io::Result<()> {
// fs::write takes a path and data.
// It returns a Result because disk operations can fail.
// The ? operator propagates the error to the caller.
fs::write("config.toml", "key = \"value\"\n")?;
Ok(())
}
fn main() -> std::io::Result<()> {
write_config()?;
Ok(())
}
The ? operator is the key here. fs::write returns a std::io::Result<()>. If the write succeeds, it returns Ok(()). If it fails, it returns Err(e). The ? checks the result. On success, it unwraps the value. On error, it returns the error immediately from the current function. This turns verbose error handling into a single character.
fs::write accepts anything that implements AsRef<Path> for the first argument and AsRef<[u8]> for the second. This means you can pass &str, String, &[u8], or Vec<u8> directly. The compiler handles the conversions. This flexibility is a standard convention in the Rust standard library. Functions that accept AsRef reduce boilerplate for callers.
fs::write is your go-to for simple dumps. If you need more control, you are holding the wrong tool.
How it actually works
When fs::write runs, Rust performs a sequence of steps under the hood. First, it converts the path argument to an OS-specific path representation. Then it calls the operating system to open the file with flags that request write access and truncate the existing content. Truncation means the file is wiped clean before writing. If the file doesn't exist, the OS creates it.
Once the file is open, Rust writes the bytes to the OS buffer. The OS may not write immediately to the physical disk. It batches writes for performance. Rust then closes the file descriptor. The close operation often triggers a flush, pushing buffered data to the storage device, but the exact timing depends on the OS and file system.
If any step fails, fs::write returns an error. Common errors include permission denied, disk full, or path not found. The compiler ensures you handle this result. If you try to ignore the Result, you get a warning. If you try to assign the Result to a variable expecting (), you get E0308 (mismatched types). The compiler rejects code that silently drops I/O results. This prevents data loss from unhandled errors.
Rust treats files as bytes. There is no distinction between text and binary files at the I/O level. If you write a &str, Rust converts the UTF-8 string to bytes. If you write &[u8], Rust writes the raw bytes. You control the encoding. This design keeps the standard library simple and efficient. You bring the encoding logic if you need it.
Don't ignore the Result. A silent failure on disk is a data loss waiting to happen.
When one shot isn't enough
Sometimes you need to append to a file, write in chunks, or keep the file open for multiple operations. fs::write truncates the file every time. It doesn't support appending. It doesn't support partial writes. For these cases, you need std::fs::File and std::fs::OpenOptions.
OpenOptions lets you configure how the file is opened. You can choose to create the file, append to it, truncate it, or require that the file exists. You build the options, then call open to get a File handle. The File handle implements the Write trait, which gives you methods like write and write_all.
use std::fs::OpenOptions;
use std::io::{Write, BufWriter};
/// Appends a log message to a file using buffering.
/// Uses OpenOptions to configure append mode.
fn append_log(message: &str) -> std::io::Result<()> {
// OpenOptions allows fine-grained control over file opening.
// create(true) creates the file if it doesn't exist.
// append(true) seeks to the end before writing.
let file = OpenOptions::new()
.create(true)
.append(true)
.open("app.log")?;
// BufWriter wraps the File and buffers writes in memory.
// This reduces the number of expensive OS calls.
let mut writer = BufWriter::new(file);
// write_all ensures the entire buffer is written.
// write might write only part of the data.
writer.write_all(message.as_bytes())?;
// BufWriter flushes automatically when dropped.
// Explicit flush is useful if you need durability before continuing.
writer.flush()?;
Ok(())
}
fn main() -> std::io::Result<()> {
append_log("Server started.\n")?;
append_log("Processing request.\n")?;
Ok(())
}
The BufWriter is crucial here. Writing to a file involves a system call for every write operation. System calls are slow. If you write one byte at a time, you pay the overhead for every byte. BufWriter accumulates data in a memory buffer. It only calls the OS when the buffer is full or when you explicitly flush. This can improve performance by orders of magnitude for small writes.
write_all is the method you want. The write method might write fewer bytes than you requested. It returns the number of bytes written. You would need a loop to ensure everything gets written. write_all handles that loop for you. It keeps calling write until the entire buffer is written or an error occurs.
Always use write_all. Partial writes are a bug waiting to happen.
The cost of disk
Disk I/O is orders of magnitude slower than memory access. Accessing RAM takes nanoseconds. Accessing a disk takes milliseconds. That is a million times slower. When you write to a file, the data travels through multiple layers: your program's memory, the OS buffer, the file system cache, and finally the storage device. Each layer adds latency.
Buffering bridges this gap. By batching writes, you amortize the cost of system calls and disk seeks. BufWriter uses a default buffer size of 8 kilobytes. This is usually a good starting point. For specific workloads, you can tune the buffer size with BufWriter::with_capacity.
Buffering is a trade-off. You gain speed, but you lose immediate durability. If your program crashes while data is in the buffer, that data is lost. The OS might also crash or lose power. If you need to guarantee that data is on disk, you must flush and potentially sync. std::fs::File has a sync_all method that forces the OS to flush data to the storage device. This is slow but safe.
Buffering is a trade-off. You gain speed, you lose immediate durability. Know the difference.
Paths and types
Rust distinguishes between path views and owned paths. Path is a view into a path string. It doesn't own the data. PathBuf owns the data. You can construct a PathBuf and then get a &Path from it. This mirrors the String and &str relationship.
Most I/O functions accept AsRef<Path>. This means you can pass &str, String, &Path, or PathBuf. The compiler inserts the conversion automatically. This is convenient for simple cases. When you need to manipulate paths, you use PathBuf.
use std::path::PathBuf;
use std::fs;
fn main() -> std::io::Result<()> {
// PathBuf owns the path data.
let mut path = PathBuf::from("logs");
// join adds a component to the path.
// It handles separators correctly for the OS.
path.push("app.log");
// fs::write accepts AsRef<Path>, so PathBuf works directly.
fs::write(&path, "Log data")?;
Ok(())
}
PathBuf::from creates a path from a string. push adds a component. join returns a new PathBuf. These methods handle OS-specific separators. On Windows, they use backslashes. On Unix, they use forward slashes. You don't need to worry about the details. The standard library handles the portability.
If you pass a &str to a function expecting PathBuf, you get E0308 (mismatched types). The compiler tells you the types don't match. You need to convert the string to a PathBuf or pass a reference. This error is common when learning Rust. The fix is usually adding & or calling .into().
Convention aside: Use PathBuf when you need to build or store paths. Use &Path when you are passing a path to a function. This keeps ownership clear and avoids unnecessary allocations.
Pitfalls and errors
File I/O is the wild west. The compiler can't check permissions, disk space, or file existence. Your error handling is the only safety net.
fs::write truncates the file. If you call it twice, the second call wipes the first content. If you meant to append, you lost data. Use OpenOptions with append(true) to avoid this.
BufWriter holds data in memory. If you drop the writer without flushing, the data is lost. The drop implementation flushes the buffer, but if the flush fails, the error is ignored. This is a design choice to avoid panics in drop. If you need to catch flush errors, call flush explicitly and handle the result.
Permissions can block writes. If the directory is read-only or the file is protected, you get an error. The error kind is ErrorKind::PermissionDenied. You can check the kind to provide a helpful message.
use std::io::{Error, ErrorKind};
fn handle_write_error(e: Error) {
match e.kind() {
ErrorKind::PermissionDenied => eprintln!("Access denied. Check file permissions."),
ErrorKind::NotFound => eprintln!("Path not found. Check the directory."),
_ => eprintln!("I/O error: {}", e),
}
}
E0277 (trait bound not satisfied) appears if you try to write a type that doesn't implement AsRef<[u8]>. For example, trying to write an integer directly. You need to convert the integer to a string or bytes first.
Disk I/O is the wild west. The compiler can't check permissions or disk space. Your error handling is the only safety net.
Decision matrix
Use fs::write when you have a complete string or byte slice and want to create or overwrite a file in a single step. Use fs::write for configuration dumps, simple exports, and one-off writes. It is concise and handles the lifecycle for you.
Use File with OpenOptions when you need to append to a file, keep the file open for multiple writes, or configure creation behavior. Use File when you need fine-grained control over the file handle. It gives you the Write trait and methods like sync_all.
Use BufWriter when you are writing many small chunks of data and want to reduce system call overhead. Use BufWriter for log files, CSV generation, and streaming data. It buffers writes in memory and flushes periodically.
Reach for fs::write for simple outputs. Reach for BufWriter for performance-critical writes. Reach for OpenOptions when you need to append or control file creation.
Counter-intuitive but true: the more you buffer, the harder it is to guarantee durability. Flush often if data loss is unacceptable.