The filesystem is not a vector
You're building a tool that processes log files. You pull a log from a remote server, save a backup copy for safety, then move the processed result into an archive directory. In a language like Python, you'd import shutil and call copy or move. In Rust, you reach for std::fs. The API is small, but the filesystem is a chaotic environment. Disks fill up, permissions block access, and operations that look like simple moves can trigger slow background copies if the source and destination live on different drives. Rust gives you precise control, but you have to handle the edge cases the OS throws at you.
Copying: reading and writing bytes
std::fs::copy duplicates a file. It opens the source, reads every byte, opens the destination, and writes those bytes. You end up with two independent files. Changes to one do not affect the other. The function returns the number of bytes copied, which is useful for progress bars or verifying that the transfer completed fully.
use std::fs;
fn main() -> std::io::Result<()> {
// Copy creates a duplicate file. Returns the byte count transferred.
let bytes = fs::copy("input.txt", "output.txt")?;
println!("Copied {bytes} bytes");
Ok(())
}
The function overwrites the destination if it already exists. It does not ask for confirmation. On Unix systems, fs::copy preserves the source file's permissions and ownership where possible. On Windows, it copies file attributes. If you need strict control over permissions, create the file manually with std::fs::File::create and write the data yourself.
Copying is a full read-write cycle. Measure twice, copy once.
Moving: the atomic rename
std::fs::rename moves a file. On the same filesystem, this operation is instant. The OS does not move data. It updates a directory entry to point to the new location. It's like moving a sticky note from one wall to another. The note itself doesn't travel.
use std::fs;
fn main() -> std::io::Result<()> {
// Rename moves the file. Instant on the same filesystem.
fs::rename("output.txt", "archive.txt")?;
Ok(())
}
Rename is atomic. If the process crashes or loses power during the call, the file is either in the old location or the new location. There is no half-moved state. This makes rename the gold standard for safe updates. Many database engines and config managers write to a temporary file, then rename it over the active file to guarantee consistency.
Rename is atomic. If it succeeds, the file is there. If it fails, nothing changed.
The cross-device trap
The instant rename trick only works when source and destination are on the same filesystem. If you try to rename a file from your internal SSD to a USB drive, or from a local mount to a network share, the OS returns an error. The filesystem is a forest of disjoint islands, not a single tree. The rename syscall cannot cross boundaries.
Rust exposes this via std::io::ErrorKind::CrossDevice. You must detect this error and fall back to a manual copy-and-delete sequence.
use std::fs;
use std::io;
use std::path::Path;
fn robust_move(src: &Path, dst: &Path) -> io::Result<()> {
// Attempt the fast, atomic rename first.
match fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::CrossDevice => {
// Different filesystems. Copy data then remove source.
fs::copy(src, dst)?;
fs::remove_file(src)
}
Err(e) => Err(e),
}
}
This pattern is common enough that many developers wrap it in a helper. The fallback copies the data and then deletes the original. If the copy succeeds but the delete fails, you end up with duplicates. That's why the function chains the results with ?. If the delete fails, the error propagates and the caller knows something went wrong.
The filesystem is a forest, not a tree. Always handle cross-device errors.
Hard links: the instant duplicate
Sometimes you want a copy that is instant, but fs::copy is too slow, and you can't use fs::rename because you need to keep the original. Enter hard links. A hard link creates a new directory entry that points to the same data blocks as the original file. No data is copied. The operation is instant. Both paths refer to the exact same content.
use std::fs;
fn main() -> std::io::Result<()> {
// Creates a hard link. Instant. Shares data blocks with original.
fs::hard_link("original.txt", "link.txt")?;
Ok(())
}
Hard links are powerful for storage efficiency. Backup tools use them to save space when files haven't changed. The catch is that hard links share data. If you open link.txt and write to it, you are modifying the same data as original.txt. Both files change. Hard links also cannot cross filesystem boundaries, just like rename. They only work on the same device.
Hard links share data. Edit one, you edit all.
Pitfalls and compiler errors
File operations fail at runtime. The compiler cannot predict whether a file exists or whether you have permission to write. You must handle std::io::Result errors. If you try to assign the result of fs::copy directly to a variable without handling the error, the compiler rejects you with E0308 (mismatched types) because copy returns a Result<u64, Error>, not a u64.
use std::fs;
fn main() {
// E0308: mismatched types.
// copy returns Result<u64, Error>, not u64.
let bytes = fs::copy("a.txt", "b.txt");
}
You must use ?, match, or unwrap to extract the value. Using ? requires the function to return a Result.
Another common trap involves moving PathBuf values. fs::copy takes arguments that implement AsRef<Path>. If you pass a PathBuf by value, Rust moves it. You cannot use the variable afterward.
use std::fs;
use std::path::PathBuf;
fn main() -> std::io::Result<()> {
let path = PathBuf::from("file.txt");
// This moves `path` into the function.
fs::copy(path, "out.txt")?;
// E0382: use of moved value.
// `path` was consumed by the previous call.
println!("Path is {:?}", path);
Ok(())
}
Borrow the path with &path if you need to reuse it. The compiler error E0382 (use of moved value) tells you exactly where the ownership shifted.
Symlinks also behave differently. fs::copy follows symlinks and copies the target content. fs::rename renames the symlink itself, not the target. If you need to copy a symlink as a symlink, use std::os::unix::fs::symlink or platform-specific APIs.
Check existence before overwriting. The compiler won't stop you from deleting your database.
Decision matrix
Use fs::copy when you need a duplicate file and the original must remain untouched. Use fs::copy when you need to transfer data across different filesystems or network mounts. Use fs::copy when you need the byte count for progress tracking or verification. Use fs::rename when you want to move a file and both paths are on the same filesystem. Use fs::rename when atomicity matters, such as updating a config file or swapping a database journal. Use fs::hard_link when you need an instant duplicate on the same filesystem and you will never modify the file in place. Use a copy-then-delete fallback when you need to move across devices and rename fails with CrossDevice. Use std::fs::remove_file after a manual copy if you implemented a move and the copy succeeded.
Don't assume rename is free. It might be a copy in disguise.