Guide contents

Lesson 53 of 94

Paths & Directories

Rust has two path types, following the same split as &str and String: Path is a borrowed path, and PathBuf is an owned one you can build up. Directory operations live in std::fs, next to the file operations from the previous lesson.

Task Rust
Build a path Path::new("a/b"), PathBuf::from(...)
Add a component path.join("c")
File name and parent path.file_name() / path.parent()
Path relative to another other.strip_prefix(path)
Exists / is a directory p.exists() / p.is_dir(), or fs::exists(p)
Size / modification time fs::metadata(p)?.len() / .modified()
Create directories fs::create_dir_all(p)
List a directory fs::read_dir(dir)
Walk a tree a recursive function, or the walkdir crate
Delete fs::remove_file(p) / fs::remove_dir(p)
Delete a tree fs::remove_dir_all(p), like rm -rf
Move or rename fs::rename(a, b)
Resolve to a real path fs::canonicalize(path)
Temporary directory env::temp_dir(), or the tempfile crate

Part 1: Paths

1. Path and PathBuf

Path::new views a string as a path without copying it. PathBuf owns its path and can grow with push. join returns a new PathBuf.

Rust
use std::path::{Path, PathBuf};

fn main() {
    let file = Path::new("reports/2026/q3.csv"); // borrowed, like &str
    println!("{}", file.display());

    let mut dir = PathBuf::from("reports"); // owned and growable, like String
    dir.push("2026");
    dir.push("q4.csv");
    println!("{}", dir.display());

    let log = Path::new("/var/log").join("app").join("today.log");
    println!("{}", log.display());

    // Joining an absolute path replaces everything.
    println!("{}", Path::new("/var/log").join("/etc/hosts").display());
}
Output
reports/2026/q3.csv
reports/2026/q4.csv
/var/log/app/today.log
/etc/hosts

join and push use the platform's separator, so the same code builds reports\2026\q4.csv on Windows. The output on this page comes from Linux.

2. Taking a path apart

The accessors return Options, because a path may not have the part you ask for: Makefile has no extension, and / has no parent.

Rust
use std::path::Path;

fn main() {
    let p = Path::new("/home/ana/photos/beach.2026.jpg");
    println!("file_name: {:?}", p.file_name());
    println!("file_stem: {:?}", p.file_stem());
    println!("extension: {:?}", p.extension());
    println!("parent:    {:?}", p.parent());
    println!("absolute:  {}", p.is_absolute());

    let parts: Vec<String> = p.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
    println!("components: {parts:?}");

    println!("Makefile extension: {:?}", Path::new("Makefile").extension());
    println!("parent of /: {:?}", Path::new("/").parent());
    println!("ancestors of a/b/c: {:?}", Path::new("a/b/c").ancestors().collect::<Vec<_>>());
}
Output
file_name: Some("beach.2026.jpg")
file_stem: Some("beach.2026")
extension: Some("jpg")
parent:    Some("/home/ana/photos")
absolute:  true
components: ["/", "home", "ana", "photos", "beach.2026.jpg"]
Makefile extension: None
parent of /: None
ancestors of a/b/c: ["a/b/c", "a/b", "a", ""]

3. OsStr: file names aren't always text

Operating systems allow file names that aren't valid UTF-8: on Linux, a name is any bytes except / and zero. So a path stores an OsStr, not a str, and converting it to text is explicit: to_str() returns None for names that aren't valid UTF-8, to_string_lossy() replaces the bad bytes, and display() is for printing.

Rust
use std::ffi::OsStr;
use std::path::Path;

fn extension_lowercase(path: &Path) -> Option<String> {
    let ext: &OsStr = path.extension()?;
    Some(ext.to_str()?.to_lowercase()) // None if the extension isn't valid UTF-8
}

fn main() {
    for name in ["IMG_0042.JPG", "notes.Md", "archive.tar.gz", "README"] {
        println!("{name:<16} {:?}", extension_lowercase(Path::new(name)));
    }

    let p = Path::new("reports/q3.csv");
    let text: Option<&str> = p.to_str();
    println!("{text:?} | {} | {}", p.to_string_lossy(), p.display());
}
Output
IMG_0042.JPG     Some("jpg")
notes.Md         Some("md")
archive.tar.gz   Some("gz")
README           None
Some("reports/q3.csv") | reports/q3.csv | reports/q3.csv

Rust makes you decide what to do with unusual names.

4. Changing parts of a path

with_extension and with_file_name return modified copies. strip_prefix removes a leading directory, which is how you turn a path under one directory into the matching path under another. starts_with compares whole components, not characters.

Rust
use std::path::{Path, PathBuf};

fn main() {
    let src = Path::new("site/content/guide/intro.md");
    println!("{}", src.with_extension("html").display());
    println!("{}", src.with_file_name("index.md").display());

    // Map site/content/... to public/... with a new extension.
    let relative = src.strip_prefix("site/content").unwrap();
    let out: PathBuf = Path::new("public").join(relative).with_extension("html");
    println!("{}", out.display());

    println!("starts_with site: {}, starts_with sit: {}", src.starts_with("site"), src.starts_with("sit"));

    let mut backup = src.to_path_buf();
    backup.set_extension("md.bak");
    println!("{}", backup.display());
}
Output
site/content/guide/intro.html
site/content/guide/index.md
public/guide/intro.html
starts_with site: true, starts_with sit: false
site/content/guide/intro.md.bak

5. Accepting any path-like argument

Functions that take a path usually accept impl AsRef<Path>, which is what every function in std::fs does. Callers can then pass a &str, a String, a &Path, or a &PathBuf (Conversion Traits covers AsRef).

Rust
use std::path::{Path, PathBuf};

fn describe(path: impl AsRef<Path>) -> String {
    let path = path.as_ref();
    format!("{} ({} components)", path.display(), path.components().count())
}

fn main() {
    let owned = PathBuf::from("/tmp/cache/data.bin");
    println!("{}", describe("config/app.toml")); // &str
    println!("{}", describe(String::from("logs/app.log"))); // String
    println!("{}", describe(&owned)); // &PathBuf
    println!("{}", describe(owned.parent().unwrap())); // &Path
}
Output
config/app.toml (2 components)
logs/app.log (2 components)
/tmp/cache/data.bin (4 components)
/tmp/cache (3 components)

Part 2: Asking the filesystem

6. Does it exist, and what is it?

exists, is_file, and is_dir answer quick questions. fs::metadata returns the details: size, permissions, timestamps.

Rust
use std::fs;
use std::io;
use std::path::Path;
use std::time::SystemTime;

fn main() -> io::Result<()> {
    fs::create_dir_all("data")?;
    fs::write("data/users.json", "[]")?;

    for name in ["data", "data/users.json", "data/missing.txt"] {
        let p = Path::new(name);
        println!("{name:<18} exists={:<5} file={:<5} dir={}", p.exists(), p.is_file(), p.is_dir());
    }

    let meta = fs::metadata("data/users.json")?;
    let age = SystemTime::now().duration_since(meta.modified()?).unwrap_or_default();
    println!("size {} bytes, read-only: {}, modified in the last minute: {}", meta.len(), meta.permissions().readonly(), age.as_secs() < 60);
    Ok(())
}
Output
data               exists=true  file=false dir=true
data/users.json    exists=true  file=true  dir=false
data/missing.txt   exists=false file=false dir=false
size 2 bytes, read-only: false, modified in the last minute: true

Path::exists returns false both when the path is missing and when it can't be checked, for example without permission. When that difference matters, fs::exists(path) returns an io::Result<bool> instead.

7. Creating directories

create_dir creates one directory and fails if its parent is missing or it already exists. create_dir_all creates every missing parent and succeeds if the directory is already there, like mkdir -p.

Rust
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("build"); // start fresh

    match fs::create_dir("build/debug/assets") {
        Ok(()) => println!("created"),
        Err(e) => println!("create_dir: {:?}", e.kind()), // the parents don't exist
    }

    fs::create_dir_all("build/debug/assets")?;
    fs::create_dir_all("build/debug/assets")?; // no error the second time
    println!("build/debug/assets is a dir: {}", fs::metadata("build/debug/assets")?.is_dir());

    match fs::create_dir("build") {
        Ok(()) => println!("created"),
        Err(e) => println!("create_dir again: {:?}", e.kind()),
    }
    Ok(())
}
Output
create_dir: NotFound
build/debug/assets is a dir: true
create_dir again: AlreadyExists

8. Listing a directory

fs::read_dir returns an iterator of entries. Each entry can fail on its own, hence the entry?. The order is whatever the filesystem returns, which is not sorted, so sort when order matters.

Rust
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("inbox");
    fs::create_dir_all("inbox/archive")?;
    for (name, text) in [("b.txt", "bee"), ("a.txt", "a"), ("notes.md", "# notes")] {
        fs::write(format!("inbox/{name}"), text)?;
    }

    let mut lines = Vec::new();
    for entry in fs::read_dir("inbox")? {
        let entry = entry?;
        let name = entry.file_name().to_string_lossy().into_owned();
        if entry.file_type()?.is_dir() {
            lines.push(format!("dir  {name}/"));
        } else {
            lines.push(format!("file {name:<9} {} B", entry.metadata()?.len()));
        }
    }
    lines.sort();
    for line in &lines {
        println!("{line}");
    }
    Ok(())
}
Output
dir  archive/
file a.txt     1 B
file b.txt     3 B
file notes.md  7 B

9. Finding files by extension

entry.path() gives the full path of each entry. Filtering by extension compares an &OsStr with a &str, which works directly.

Rust
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

fn files_with_extension(dir: &Path, ext: &str) -> io::Result<Vec<PathBuf>> {
    let mut found = Vec::new();
    for entry in fs::read_dir(dir)? {
        let path = entry?.path();
        if path.is_file() && path.extension().is_some_and(|e| e == ext) {
            found.push(path);
        }
    }
    found.sort();
    Ok(found)
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("docs");
    fs::create_dir_all("docs/drafts.md")?; // a directory with a misleading name
    for name in ["setup.md", "api.md", "notes.txt", "logo.png"] {
        fs::write(Path::new("docs").join(name), "")?;
    }

    for path in files_with_extension(Path::new("docs"), "md")? {
        println!("{}", path.display());
    }
    Ok(())
}
Output
docs/api.md
docs/setup.md

10. Walking a directory tree

A recursive function over read_dir visits a whole tree in a few lines. entry.file_type() doesn't follow symbolic links, so a link that points back up the tree can't cause an endless loop.

Rust
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

fn walk(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        if entry.file_type()?.is_dir() {
            walk(&entry.path(), files)?;
        } else {
            files.push(entry.path());
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("project");
    for file in ["project/Cargo.toml", "project/src/main.rs", "project/src/net/client.rs", "project/src/net/server.rs", "project/tests/api.rs"] {
        let path = Path::new(file);
        fs::create_dir_all(path.parent().unwrap())?;
        fs::write(path, "// code\n")?;
    }

    let root = Path::new("project");
    let mut files = Vec::new();
    walk(root, &mut files)?;
    files.sort();
    for f in &files {
        println!("{}", f.strip_prefix(root).unwrap().display());
    }
    Ok(())
}
Output
Cargo.toml
src/main.rs
src/net/client.rs
src/net/server.rs
tests/api.rs

For large trees, the walkdir crate adds depth limits, sorting, and error handling per entry; the ignore crate also skips files listed in .gitignore.

11. Total size of a directory

The same recursion, summing file sizes, is du -s:

Rust
use std::fs;
use std::io;
use std::path::Path;

fn dir_size(dir: &Path) -> io::Result<u64> {
    let mut total = 0;
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let meta = entry.metadata()?;
        total += if meta.is_dir() { dir_size(&entry.path())? } else { meta.len() };
    }
    Ok(total)
}

fn human(bytes: u64) -> String {
    match bytes {
        0..1_024 => format!("{bytes} B"),
        1_024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1_024.0),
        _ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
    }
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("media");
    fs::create_dir_all("media/photos/2026")?;
    fs::write("media/readme.txt", "hello")?;
    fs::write("media/photos/cat.jpg", vec![0u8; 300_000])?;
    fs::write("media/photos/2026/dog.jpg", vec![0u8; 1_500_000])?;

    for dir in ["media", "media/photos/2026"] {
        println!("{dir:<18} {}", human(dir_size(Path::new(dir))?));
    }
    Ok(())
}
Output
media              1.7 MB
media/photos/2026  1.4 MB

Part 3: Changing the filesystem

12. Removing files and directories

remove_file deletes a file and remove_dir an empty directory. remove_dir_all deletes a directory and everything inside it, like rm -rf, so double-check the path you give it.

Rust
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    fs::create_dir_all("work/cache")?;
    fs::write("work/cache/blob.bin", [0u8; 64])?;

    match fs::remove_dir("work") {
        Ok(()) => println!("removed"),
        Err(e) => println!("remove_dir: {:?}", e.kind()),
    }

    fs::remove_dir_all("work")?;
    println!("work exists afterwards: {}", fs::exists("work")?);
    Ok(())
}
Output
remove_dir: DirectoryNotEmpty
work exists afterwards: false

13. Moving files, even across filesystems

fs::rename moves a file instantly within one filesystem, but can't move it to a different disk or partition. A robust move_file falls back to copying and deleting in that case.

Rust
use std::fs;
use std::io::{self, ErrorKind};
use std::path::Path;

fn move_file(from: &Path, to: &Path) -> io::Result<()> {
    if let Some(parent) = to.parent() {
        fs::create_dir_all(parent)?;
    }
    match fs::rename(from, to) {
        Err(e) if e.kind() == ErrorKind::CrossesDevices => {
            fs::copy(from, to)?;
            fs::remove_file(from)
        }
        other => other,
    }
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("uploads");
    fs::create_dir_all("uploads/incoming")?;
    fs::write("uploads/incoming/scan.pdf", "%PDF")?;

    move_file(Path::new("uploads/incoming/scan.pdf"), Path::new("uploads/2026/09/scan.pdf"))?;
    println!("old exists: {}", fs::exists("uploads/incoming/scan.pdf")?);
    println!("new contents: {}", fs::read_to_string("uploads/2026/09/scan.pdf")?);
    Ok(())
}
Output
old exists: false
new contents: %PDF

14. Current directory, absolute and canonical paths

Relative paths are resolved against the current directory, env::current_dir(). path::absolute makes a path absolute without touching the filesystem. fs::canonicalize goes further: it resolves ., .., and symbolic links, and fails if the path doesn't exist.

Rust
use std::env;
use std::fs;
use std::io;
use std::path::{self, Path};

fn main() -> io::Result<()> {
    let cwd = fs::canonicalize(env::current_dir()?)?;
    println!("current dir is absolute: {}", cwd.is_absolute());

    fs::create_dir_all("a/b")?;
    fs::write("a/b/file.txt", "x")?;

    let messy = Path::new("a/b/../b/./file.txt");
    let clean = fs::canonicalize(messy)?;
    println!("canonical == cwd/a/b/file.txt: {}", clean == cwd.join("a/b/file.txt"));

    let abs = path::absolute("not/there.txt")?;
    println!("absolute works for missing files: {}", abs.is_absolute());
    println!("canonicalize on a missing file: {:?}", fs::canonicalize("not/there.txt").map_err(|e| e.kind()));
    Ok(())
}
Output
current dir is absolute: true
canonical == cwd/a/b/file.txt: true
absolute works for missing files: true
canonicalize on a missing file: Err(NotFound)

15. Temporary directories

env::temp_dir() is the system's temporary directory (/tmp on Linux). Give your own directory inside it a unique name, for example with the process id, and remove it when you're done. The tempfile crate automates both, deleting the directory when its handle goes out of scope.

Rust
use std::env;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::process;

fn scratch_dir(label: &str) -> io::Result<PathBuf> {
    let dir = env::temp_dir().join(format!("{label}-{}", process::id()));
    fs::create_dir_all(&dir)?;
    Ok(dir)
}

fn main() -> io::Result<()> {
    let dir = scratch_dir("thumbnailer")?;
    fs::write(dir.join("frame-001.raw"), [0u8; 1024])?;
    fs::write(dir.join("frame-002.raw"), [0u8; 1024])?;
    println!("{} files in the scratch dir", fs::read_dir(&dir)?.count());
    println!("inside the system temp dir: {}", dir.starts_with(env::temp_dir()));

    fs::remove_dir_all(&dir)?;
    println!("cleaned up: {}", !dir.exists());
    Ok(())
}
Output
2 files in the scratch dir
inside the system temp dir: true
cleaned up: true

16. Paths across platforms

Windows uses \ between components and drive letters like C:; Linux and macOS use /. Build paths with join rather than format!("{dir}/{name}"), and they come out right everywhere.

Rust
use std::path::{Path, MAIN_SEPARATOR};

fn main() {
    println!("separator on this platform: {MAIN_SEPARATOR:?}");
    let built = Path::new("logs").join("2026").join("app.log");
    println!("joined: {}", built.display());

    // On Linux and macOS, a backslash is an ordinary character in a file name.
    let windows_style = Path::new(r"logs\2026\app.log");
    println!("components of {}: {}", windows_style.display(), windows_style.components().count());
}
Output
separator on this platform: '/'
joined: logs/2026/app.log
components of logs\2026\app.log: 1

On Windows the same program prints '\\', logs\2026\app.log, and 3 components. Windows also accepts / as a separator, so Path::new("logs/2026/app.log") works on both.

Part 4: Realistic examples

17. Realistic example: a find command

Search a tree with a predicate closure that sees each file's path and metadata, like find . -name '*.log' -size +1k.

Rust
use std::fs::{self, Metadata};
use std::io;
use std::path::{Path, PathBuf};

fn find(root: &Path, matches: &impl Fn(&Path, &Metadata) -> bool, found: &mut Vec<PathBuf>) -> io::Result<()> {
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        let meta = entry.metadata()?;
        if meta.is_dir() {
            find(&path, matches, found)?;
        } else if matches(&path, &meta) {
            found.push(path);
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("var");
    fs::create_dir_all("var/log/nginx")?;
    fs::write("var/log/app.log", vec![b'x'; 5_000])?;
    fs::write("var/log/tiny.log", "ok")?;
    fs::write("var/log/nginx/access.log", vec![b'x'; 20_000])?;
    fs::write("var/log/nginx/access.log.gz", vec![b'x'; 9_000])?;

    let big_logs = |path: &Path, meta: &Metadata| path.extension().is_some_and(|e| e == "log") && meta.len() > 1_024;
    let mut found = Vec::new();
    find(Path::new("var"), &big_logs, &mut found)?;
    found.sort();
    for path in &found {
        println!("{:>6} {}", fs::metadata(path)?.len(), path.display());
    }
    Ok(())
}
Output
  5000 var/log/app.log
 20000 var/log/nginx/access.log

18. Realistic example: organizing a downloads folder

Move each file into a subfolder chosen by its extension, creating the folders as needed.

Rust
use std::fs;
use std::io;
use std::path::Path;

fn category(path: &Path) -> &'static str {
    let ext = path.extension().and_then(|e| e.to_str()).map(str::to_lowercase);
    match ext.as_deref() {
        Some("jpg" | "jpeg" | "png" | "gif") => "images",
        Some("pdf" | "docx" | "txt" | "md") => "documents",
        Some("zip" | "gz" | "tar") => "archives",
        _ => "other",
    }
}

fn organize(dir: &Path) -> io::Result<usize> {
    let mut moved = 0;
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue; // leave folders alone, including the ones we create
        }
        let path = entry.path();
        let target_dir = dir.join(category(&path));
        fs::create_dir_all(&target_dir)?;
        fs::rename(&path, target_dir.join(entry.file_name()))?;
        moved += 1;
    }
    Ok(moved)
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("downloads");
    fs::create_dir_all("downloads")?;
    for name in ["Invoice.PDF", "cat.jpg", "backup.tar", "notes.md", "setup.exe", "logo.png"] {
        fs::write(Path::new("downloads").join(name), "")?;
    }

    println!("moved {} files", organize(Path::new("downloads"))?);
    let mut result = Vec::new();
    for dir in fs::read_dir("downloads")? {
        let dir = dir?;
        let mut names: Vec<String> = fs::read_dir(dir.path())?.map(|e| Ok(e?.file_name().to_string_lossy().into_owned())).collect::<io::Result<_>>()?;
        names.sort();
        result.push(format!("{}/: {}", dir.file_name().to_string_lossy(), names.join(", ")));
    }
    result.sort();
    println!("{}", result.join("\n"));
    Ok(())
}
Output
moved 6 files
archives/: backup.tar
documents/: Invoice.PDF, notes.md
images/: cat.jpg, logo.png
other/: setup.exe

The collect::<io::Result<_>>() turns an iterator of Results into a Result of a collection, stopping at the first error, as shown in Error Handling Patterns.

19. Realistic example: printing a directory tree

The tree command, with directories first and names sorted, drawn with box characters.

Rust
use std::fs;
use std::io;
use std::path::Path;

fn tree(dir: &Path, prefix: &str, out: &mut Vec<String>) -> io::Result<()> {
    let mut entries: Vec<(bool, String)> = Vec::new();
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        entries.push((!entry.file_type()?.is_dir(), entry.file_name().to_string_lossy().into_owned()));
    }
    entries.sort(); // directories (false) before files (true), then by name

    for (i, (is_file, name)) in entries.iter().enumerate() {
        let last = i == entries.len() - 1;
        out.push(format!("{prefix}{}{name}{}", if last { "└── " } else { "├── " }, if *is_file { "" } else { "/" }));
        if !is_file {
            let child_prefix = format!("{prefix}{}", if last { "    " } else { "│   " });
            tree(&dir.join(name), &child_prefix, out)?;
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("app");
    for file in ["app/Cargo.toml", "app/README.md", "app/src/main.rs", "app/src/db/mod.rs", "app/src/db/models.rs", "app/tests/smoke.rs"] {
        fs::create_dir_all(Path::new(file).parent().unwrap())?;
        fs::write(file, "")?;
    }

    let mut lines = vec![String::from("app/")];
    tree(Path::new("app"), "", &mut lines)?;
    println!("{}", lines.join("\n"));
    Ok(())
}
Output
app/
├── src/
│   ├── db/
│   │   ├── mod.rs
│   │   └── models.rs
│   └── main.rs
├── tests/
│   └── smoke.rs
├── Cargo.toml
└── README.md

Summary

  • Path (borrowed) and PathBuf (owned) are to paths what &str and String are to text. Build paths with join and push, never with string formatting.
  • file_name, file_stem, extension, and parent return Options; with_extension, with_file_name, and strip_prefix derive new paths.
  • File names are OsStr, not str: use display() to print, to_str() or to_string_lossy() to get text.
  • Accept impl AsRef<Path> to take any path-like argument.
  • fs::metadata, exists, is_file, and is_dir inspect; create_dir_all, read_dir, rename, remove_file, remove_dir, and remove_dir_all change things.
  • read_dir returns entries in no particular order: sort them. Walk trees with a short recursive function, or the walkdir crate.
  • fs::canonicalize resolves .. and links for existing paths; path::absolute works without touching the disk.

Next: Standard Input, Output & Error.