Filesystem and Paths
Touching the filesystem seems simple until a hardcoded "data/" + name breaks on Windows, or a path with non-UTF-8 bytes refuses to convert to a String. Rust models paths with dedicated types, Path and PathBuf, precisely because a filesystem path is not just a string: it has platform-specific separators, can contain bytes that aren't valid UTF-8, and has structure (components, extension, parent) worth manipulating properly. This tutorial covers the Path/PathBuf pair (the borrowed/owned split you already know from &str/String), the std::fs operations for reading and writing, and the gotchas that bite when you treat paths as plain strings.
Path vs PathBuf: Borrowed and Owned
The path types mirror the string types exactly (Strings: Choosing the Right Type): Path is the borrowed view (like &str), and PathBuf is the owned, growable version (like String). You accept &Path in parameters and build/store PathBuf.
use std::path::{Path, PathBuf};
// borrowed view — accept this in functions
let p: &Path = Path::new("config/settings.toml");
// owned, buildable path — for storing or constructing
let mut buf: PathBuf = PathBuf::from("config");
buf.push("settings.toml"); // join a component: "config/settings.toml"
buf.set_extension("json"); // now "config/settings.json"
The same "accept the borrowed type" rule applies: a function should take &Path (or, even better, impl AsRef<Path> from Deref, AsRef, and Borrow, which also accepts &str, String, and PathBuf). PathBuf is for when you build up a path or need to own one; &Path is for reading it.
Build Paths with join/push, Not String Concatenation
The most common filesystem mistake is assembling paths by concatenating strings with a hardcoded /. That hardcodes the Unix separator and breaks on Windows (which uses \), and it mishandles edge cases like trailing slashes. Path::join (and PathBuf::push) insert the correct separator for the platform:
// WRONG: hardcoded separator, breaks on Windows, fragile with trailing slashes
let path = format!("{dir}/{name}");
// RIGHT: join uses the platform's separator and handles edges correctly
let path = Path::new(dir).join(name); // "dir/name" on Unix, "dir\name" on Windows
join also does the sensible thing when the second part is absolute (it replaces rather than nonsensically appends) and when either side has stray separators. Building paths through join/push rather than string formatting is the portability baseline, it's the same reason you use Path at all instead of String.
Inspecting Paths: Components, Extension, Parent
Path exposes structure that string slicing can't get right across platforms. The common accessors return Option because the part may not exist:
let p = Path::new("/home/ada/report.pdf");
p.file_name(); // Some("report.pdf")
p.file_stem(); // Some("report") — name without extension
p.extension(); // Some("pdf") — no leading dot
p.parent(); // Some("/home/ada") — the containing directory
p.is_absolute(); // true
// iterate the pieces portably:
for component in p.components() {
// Root, Normal("home"), Normal("ada"), Normal("report.pdf"), ...
}
These return Option (and OsStr, see the gotcha below) rather than &str because a path may have no extension, no parent, or non-UTF-8 content. Use extension() to branch on file type, file_stem() to rename while preserving directory and extension, and components() to walk a path portably instead of splitting on a separator character yourself.
Gotcha: paths are not guaranteed to be valid UTF-8, on Linux a filename can be arbitrary bytes, and on Windows it's UTF-16 that may not round-trip. That's why
Pathmethods returnOsStr/OsString, notstr/String, and why converting a path to aStringis fallible:path.to_str()returnsOption<&str>(Noneif not UTF-8). Don't reach for.to_str().unwrap()on paths from the outside world, it panics on legitimate filenames. Use.display()for lossy, human-readable output (println!("{}", path.display())) and keep paths asPath/PathBuffor actual filesystem operations rather than round-tripping throughString.
Reading and Writing Files
std::fs provides both whole-file convenience functions and, for large data, the buffered streaming from The Read and Write Traits. For small-to-medium files, the one-shot helpers are the idiomatic choice:
use std::fs;
// read/write an entire file in one call — returns io::Result
let contents: String = fs::read_to_string("config.toml")?; // whole file as String
let bytes: Vec<u8> = fs::read("image.png")?; // whole file as bytes
fs::write("output.txt", "hello")?; // create/truncate + write
// directory and metadata operations
fs::create_dir_all("a/b/c")?; // make the whole chain, ok if it exists
fs::remove_file("temp.txt")?;
let meta = fs::metadata("file.txt")?; // size, permissions, timestamps
println!("{} bytes", meta.len());
// iterate a directory
for entry in fs::read_dir(".")? {
let entry = entry?; // each entry is itself a Result
println!("{}", entry.path().display());
}
fs::read_to_string is the go-to for "load this config/text file"; fs::write for "dump this out." Reach for File + BufReader/BufWriter only when the file is large enough that you don't want it all in memory at once, or when you're streaming. Note read_dir yields Result items (each entry can fail independently), so you'll typically ? inside the loop.
Filesystem Cheat Sheet
| You want | Use |
|---|---|
| A borrowed path parameter | &Path (or impl AsRef<Path> to accept &str too) |
| An owned/buildable path | PathBuf (push, join, set_extension) |
| Join path components portably | path.join(part) — never format!("{}/{}", ...) |
| The filename / stem / extension / parent | file_name / file_stem / extension / parent |
| Read a whole text/binary file | fs::read_to_string / fs::read |
| Write a whole file | fs::write |
| Stream a large file | File + BufReader/BufWriter |
| Make a directory tree | fs::create_dir_all |
| List a directory | fs::read_dir (yields Result entries) |
| Human-readable path output | path.display() (never .to_str().unwrap()) |
The throughline: treat paths as Path/PathBuf, not strings. Build them with join/push for portability, inspect them with the structural accessors, and remember they can hold non-UTF-8 data, so display with .display() and convert to String only through the fallible .to_str(). The std::fs one-shot helpers cover most reading and writing; drop to buffered streams only for large files.
Key Takeaways
Path(borrowed, like&str) andPathBuf(owned, likeString) model filesystem paths; accept&Pathorimpl AsRef<Path>in functions, usePathBufto build or store.- Build paths with
join/push, never string concatenation with a hardcoded/,joinuses the platform's separator and handles absolute/trailing-slash edge cases correctly. - Inspect paths with
file_name/file_stem/extension/parent/components(all returnOption), rather than splitting strings, which isn't portable. - Paths aren't guaranteed UTF-8, so their methods return
OsStrand.to_str()is fallible; use.display()for output and avoid.to_str().unwrap(), which panics on valid filenames. - Use
fs::read_to_string/fs::read/fs::writefor whole files,create_dir_all/read_dir/metadatafor directories, andFile+ buffered readers/writers only for large or streamed data.