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 Path methods return OsStr/OsString, not str/String, and why converting a path to a String is fallible: path.to_str() returns Option<&str> (None if 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 as Path/PathBuf for actual filesystem operations rather than round-tripping through String.


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 wantUse
A borrowed path parameter&Path (or impl AsRef<Path> to accept &str too)
An owned/buildable pathPathBuf (push, join, set_extension)
Join path components portablypath.join(part) — never format!("{}/{}", ...)
The filename / stem / extension / parentfile_name / file_stem / extension / parent
Read a whole text/binary filefs::read_to_string / fs::read
Write a whole filefs::write
Stream a large fileFile + BufReader/BufWriter
Make a directory treefs::create_dir_all
List a directoryfs::read_dir (yields Result entries)
Human-readable path outputpath.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) and PathBuf (owned, like String) model filesystem paths; accept &Path or impl AsRef<Path> in functions, use PathBuf to build or store.
  • Build paths with join/push, never string concatenation with a hardcoded /, join uses the platform's separator and handles absolute/trailing-slash edge cases correctly.
  • Inspect paths with file_name/file_stem/extension/parent/components (all return Option), rather than splitting strings, which isn't portable.
  • Paths aren't guaranteed UTF-8, so their methods return OsStr and .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::write for whole files, create_dir_all/read_dir/metadata for directories, and File + buffered readers/writers only for large or streamed data.