ZetCode

Rust directories and paths

last modified November 13, 2026

In this article we work with file system paths and directories in Rust.

The std::path module provides two types for paths: Path is a borrowed path slice, like str, and PathBuf is an owned, growable path, like String. They do not use String internally, because file names on Unix can be arbitrary bytes and on Windows arbitrary UTF-16 units. They are built on OsStr and OsString instead.

Path manipulation is purely textual and does not touch the disk. Operations on the file system itself, such as creating or listing directories, live in the std::fs module. The examples were tested with Rust 1.97 and the 2024 edition on Linux. They are run as Cargo projects with cargo run -q; the project directory contains a small docs directory with sample files.

Parts of a path

A Path has methods that return its individual parts.

main.rs
use std::path::Path;

fn main() {
    let path = Path::new("/home/user/docs/reports/summary.tar.gz");

    println!("path: {}", path.display());
    println!("file name: {:?}", path.file_name());
    println!("stem: {:?}", path.file_stem());
    println!("prefix: {:?}", path.file_prefix());
    println!("extension: {:?}", path.extension());
    println!("parent: {:?}", path.parent());
    println!("absolute: {}", path.is_absolute());

    for c in path.components() {
        print!("{c:?} ");
    }
    println!();

    for a in path.ancestors().take(3) {
        println!("{}", a.display());
    }

    let rel = Path::new("src/main.rs");
    println!("{} {}", rel.is_relative(), rel.starts_with("src"));
    println!("{:?}", Path::new("/").parent());
    println!("{:?}", Path::new(".bashrc").extension());
}

We take apart the path of a compressed archive.

let path = Path::new("/home/user/docs/reports/summary.tar.gz");

Path::new wraps a string slice without copying it. The file need not exist.

println!("stem: {:?}", path.file_stem());
println!("prefix: {:?}", path.file_prefix());
println!("extension: {:?}", path.extension());

The extension is the part after the last dot, and the stem is everything before it. The file_prefix method returns the part before the first dot instead. All three return an Option<&OsStr>.

for c in path.components() {

The components method splits the path into the root and the individual names. The ancestors method yields the path and then each of its parents.

println!("{:?}", Path::new("/").parent());
println!("{:?}", Path::new(".bashrc").extension());

The root has no parent. A leading dot does not start an extension, so a hidden file such as .bashrc has none.

$ cargo run -q
path: /home/user/docs/reports/summary.tar.gz
file name: Some("summary.tar.gz")
stem: Some("summary.tar")
prefix: Some("summary")
extension: Some("gz")
parent: Some("/home/user/docs/reports")
absolute: true
RootDir Normal("home") Normal("user") Normal("docs") Normal("reports") Normal("summary.tar.gz") 
/home/user/docs/reports/summary.tar.gz
/home/user/docs/reports
/home/user/docs
true true
None
None

Building paths with PathBuf

A PathBuf is built by joining components. Path separators are added automatically.

main.rs
use std::path::{Path, PathBuf};

fn main() {
    let base = Path::new("/var/www");
    let page = base.join("site").join("index.html");
    println!("{}", page.display());

    let mut p = PathBuf::from("/var/log");
    p.push("nginx");
    p.push("access.log");
    println!("{}", p.display());

    p.set_extension("log.1");
    println!("{}", p.display());

    p.pop();
    println!("{}", p.display());

    p.push("/etc/hosts");
    println!("{}", p.display());

    let img = Path::new("photos/cat.jpeg");
    println!("{}", img.with_extension("png").display());
    println!("{}", img.with_file_name("dog.jpeg").display());

    let parts = ["home", "user", "music", "song.mp3"];
    let joined: PathBuf = parts.iter().collect();
    println!("{}", joined.display());
}

We build and modify several paths.

let page = base.join("site").join("index.html");

The join method returns a new PathBuf.

let mut p = PathBuf::from("/var/log");
p.push("nginx");
p.push("access.log");

The push method extends a PathBuf in place, and pop removes the last component.

p.push("/etc/hosts");

Pushing or joining an absolute path replaces the whole path. This is a common source of bugs, and a security issue when the pushed part comes from user input.

let joined: PathBuf = parts.iter().collect();

A PathBuf can be collected from an iterator of components.

$ cargo run -q
/var/www/site/index.html
/var/log/nginx/access.log
/var/log/nginx/access.log.1
/var/log/nginx
/etc/hosts
photos/cat.png
photos/dog.jpeg
home/user/music/song.mp3

Paths and strings

Since a path is not necessarily valid UTF-8, converting it to a Rust string can fail.

main.rs
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

fn print_path(p: impl AsRef<Path>) {
    println!("{}", p.as_ref().display());
}

fn main() {
    let p = Path::new("data/report.csv");

    let s: Option<&str> = p.to_str();
    println!("{s:?}");

    let lossy = p.to_string_lossy();
    println!("{lossy}");

    let ext: Option<&OsStr> = p.extension();
    let is_csv = ext.and_then(OsStr::to_str) == Some("csv");
    println!("csv: {is_csv}");

    let owned: PathBuf = PathBuf::from(String::from("tmp/out.txt"));
    let back: String = owned.into_os_string().into_string().unwrap_or_default();
    println!("{back}");

    print_path("plain &str");
    print_path(String::from("a String"));
    print_path(PathBuf::from("a PathBuf"));
    print_path(Path::new("a &Path"));
}

We convert between paths, OsStr, and strings.

let s: Option<&str> = p.to_str();
let lossy = p.to_string_lossy();

The to_str method returns None for paths that are not valid UTF-8. The to_string_lossy method replaces invalid sequences with the U+FFFD replacement character. For printing, the display method does the same lossy conversion.

let is_csv = ext.and_then(OsStr::to_str) == Some("csv");

Path parts are OsStr values; we convert the extension to &str before comparing it.

fn print_path(p: impl AsRef<Path>) {

Functions that take paths should accept impl AsRef<Path>. Then callers can pass &str, String, Path, or PathBuf. All std::fs functions are declared this way.

$ cargo run -q
Some("data/report.csv")
data/report.csv
csv: true
tmp/out.txt
plain &str
a String
a PathBuf
a &Path

The current directory

Relative paths are resolved against the current working directory of the process.

main.rs
use std::env;
use std::fs;
use std::path::{self, Path};

fn main() -> std::io::Result<()> {
    let cwd = env::current_dir()?;
    println!("cwd: {}", cwd.display());

    let rel = Path::new("docs/../docs/./readme.txt");
    println!("absolute: {}", path::absolute(rel)?.display());
    println!("canonical: {}", fs::canonicalize(rel)?.display());

    match fs::canonicalize("missing.txt") {
        Ok(p) => println!("{}", p.display()),
        Err(e) => println!("canonicalize error: {e}"),
    }

    env::set_current_dir("docs/reports")?;
    println!("cwd: {}", env::current_dir()?.display());
    println!("{}", Path::new("summary.md").exists());

    Ok(())
}

We print the current directory, make paths absolute, and change the directory.

println!("absolute: {}", path::absolute(rel)?.display());
println!("canonical: {}", fs::canonicalize(rel)?.display());

The path::absolute function prepends the current directory without touching the disk; the .. and . components stay. The fs::canonicalize function asks the operating system: it resolves .., ., and symbolic links, and fails if the path does not exist.

env::set_current_dir("docs/reports")?;

Changing the current directory affects the whole process, including all threads. It is usually better to build full paths with join.

$ cargo run -q
cwd: /home/user/projects/dirs
absolute: /home/user/projects/dirs/docs/../docs/readme.txt
canonical: /home/user/projects/dirs/docs/readme.txt
canonicalize error: No such file or directory (os error 2)
cwd: /home/user/projects/dirs/docs/reports
true

Creating directories

The fs::create_dir function creates a single directory, and fs::create_dir_all creates it together with all missing parents.

main.rs
use std::fs;
use std::io::ErrorKind;

fn main() -> std::io::Result<()> {
    fs::create_dir("build")?;
    println!("created build");

    match fs::create_dir("build") {
        Ok(()) => println!("created again"),
        Err(e) if e.kind() == ErrorKind::AlreadyExists => println!("build already exists"),
        Err(e) => return Err(e),
    }

    if let Err(e) = fs::create_dir("out/debug/assets") {
        println!("create_dir: {e}");
    }

    fs::create_dir_all("out/debug/assets")?;
    fs::create_dir_all("out/debug/assets")?;
    println!("created out/debug/assets");

    fs::write("out/debug/assets/app.css", "body { margin: 0 }\n")?;
    println!("{}", fs::read_to_string("out/debug/assets/app.css")?);

    Ok(())
}

We create directories and handle the errors.

Err(e) if e.kind() == ErrorKind::AlreadyExists => println!("build already exists"),

Creating an existing directory with create_dir fails. The kind method of io::Error tells the reason.

if let Err(e) = fs::create_dir("out/debug/assets") {

The create_dir function fails when the parent does not exist.

fs::create_dir_all("out/debug/assets")?;
fs::create_dir_all("out/debug/assets")?;

The create_dir_all function creates the whole chain and succeeds when the directory already exists, so it can be called repeatedly.

$ cargo run -q
created build
build already exists
create_dir: No such file or directory (os error 2)
created out/debug/assets
body { margin: 0 }

Checking paths and metadata

A path can be checked for existence and type, and its metadata can be read.

main.rs
use std::fs;
use std::path::Path;

fn main() -> std::io::Result<()> {
    for name in ["docs", "docs/readme.txt", "docs/nothing.txt"] {
        let p = Path::new(name);
        println!(
            "{name:<18} exists={:<5} dir={:<5} file={}",
            p.exists(),
            p.is_dir(),
            p.is_file()
        );
    }

    println!("{:?}", Path::new("docs/nothing.txt").try_exists());

    let meta = fs::metadata("docs/img/photo.jpg")?;
    println!("size: {} bytes", meta.len());
    println!("read-only: {}", meta.permissions().readonly());
    println!("is file: {}", meta.is_file());

    let dir = fs::metadata("docs")?;
    println!("docs is a dir: {}", dir.file_type().is_dir());

    Ok(())
}

We test three paths and read file metadata.

p.exists(),
p.is_dir(),
p.is_file()

These methods query the file system. They return false on any error, including missing permissions.

println!("{:?}", Path::new("docs/nothing.txt").try_exists());

The try_exists method distinguishes a missing file, Ok(false), from a failed check, Err.

let meta = fs::metadata("docs/img/photo.jpg")?;

The Metadata type holds the size, type, permissions, and timestamps. fs::metadata follows symbolic links; fs::symlink_metadata describes the link itself.

Checking whether a file exists before opening it is racy: another process can delete it in between. When the goal is to open a file, open it and handle the error.

$ cargo run -q
docs               exists=true  dir=true  file=false
docs/readme.txt    exists=true  dir=false file=true
docs/nothing.txt   exists=false dir=false file=false
Ok(false)
size: 15360 bytes
read-only: false
is file: true
docs is a dir: true

Listing a directory

The fs::read_dir function returns an iterator over the entries of a directory.

main.rs
use std::fs;

fn main() -> std::io::Result<()> {
    for entry in fs::read_dir("docs")? {
        let entry = entry?;
        let kind = if entry.file_type()?.is_dir() { "dir" } else { "file" };
        println!("{:<5} {:?}", kind, entry.file_name());
    }

    Ok(())
}

We list the docs directory.

for entry in fs::read_dir("docs")? {
    let entry = entry?;

Opening the directory can fail, and so can reading each entry; that is why there are two ? operators. The entries . and .. are not included.

let kind = if entry.file_type()?.is_dir() { "dir" } else { "file" };

A DirEntry provides the name, the full path, and the file type. On most platforms file_type needs no extra system call.

The entries come in the order the file system returns them, which is not alphabetical and may differ between systems.

$ cargo run -q
file  "readme.txt"
dir   "reports"
file  "notes.md"
dir   "img"

Sorted listing with sizes

To get a predictable order, we collect the entries and sort them.

main.rs
use std::fs;
use std::path::PathBuf;

fn main() -> std::io::Result<()> {
    let mut paths: Vec<PathBuf> = fs::read_dir("docs")?
        .map(|res| res.map(|e| e.path()))
        .collect::<Result<_, _>>()?;

    paths.sort();

    for p in &paths {
        let meta = p.metadata()?;
        if meta.is_dir() {
            println!("{:>8}  {}/", "-", p.display());
        } else {
            println!("{:>8}  {}", meta.len(), p.display());
        }
    }

    let dirs = paths.iter().filter(|p| p.is_dir()).count();
    println!("{} entries, {dirs} directories", paths.len());

    Ok(())
}

The program prints a listing similar to ls -l.

let mut paths: Vec<PathBuf> = fs::read_dir("docs")?
    .map(|res| res.map(|e| e.path()))
    .collect::<Result<_, _>>()?;

Each entry is mapped to its path. Collecting an iterator of Result values into Result<Vec<_>, _> stops at the first error.

let meta = p.metadata()?;

The metadata method of Path is a shortcut for fs::metadata.

$ cargo run -q
       -  docs/img/
      26  docs/notes.md
      22  docs/readme.txt
       -  docs/reports/
4 entries, 2 directories

Walking a directory tree

The standard library has no recursive directory walker, but a recursive function is easy to write. The walkdir crate offers a complete solution.

main.rs
use std::fs;
use std::io;
use std::path::Path;

fn tree(dir: &Path, prefix: &str) -> io::Result<()> {
    let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
    entries.sort_by_key(|e| e.file_name());

    for (i, entry) in entries.iter().enumerate() {
        let last = i == entries.len() - 1;
        let branch = if last { "└── " } else { "├── " };
        println!("{prefix}{branch}{}", entry.file_name().to_string_lossy());

        if entry.file_type()?.is_dir() {
            let next = format!("{prefix}{}", if last { "    " } else { "│   " });
            tree(&entry.path(), &next)?;
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    println!("docs");
    tree(Path::new("docs"), "")
}

The program prints the directory tree like the tree command.

let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());

We collect and sort the entries of each directory, so we know which one is last.

if entry.file_type()?.is_dir() {
    let next = format!("{prefix}{}", if last { "    " } else { "│   " });
    tree(&entry.path(), &next)?;
}

For each subdirectory, the function calls itself with a longer prefix. file_type does not follow symbolic links, so a link to a parent directory does not cause endless recursion.

$ cargo run -q
docs
├── img
│   ├── logo.png
│   └── photo.jpg
├── notes.md
├── readme.txt
└── reports
    ├── 2025
    │   ├── q1.csv
    │   └── q2.csv
    ├── 2026
    │   └── q1.csv
    └── summary.md

Finding files and computing sizes

Recursive walks are the basis of many tools. We search for files by extension and sum the sizes of directories.

main.rs
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

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

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 main() -> io::Result<()> {
    let mut csv = Vec::new();
    find(Path::new("docs"), "csv", &mut csv)?;
    csv.sort();

    println!("{:<26} rows", "file");
    for p in &csv {
        let rows = fs::read_to_string(p)?.lines().count() - 1;
        println!("{:<26} {rows}", p.display());
    }

    println!("docs: {} bytes", dir_size(Path::new("docs"))?);
    println!("docs/img: {} bytes", dir_size(Path::new("docs/img"))?);

    Ok(())
}

The program finds CSV files and counts their data rows.

} else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) {

The eq_ignore_ascii_case method of OsStr compares the extension without converting it to a string; it also matches .CSV.

total += if meta.is_dir() { dir_size(&entry.path())? } else { meta.len() };

The dir_size function adds up the file sizes in a directory and all its subdirectories. This is the apparent size; the space used on disk is usually larger, as reported by du.

$ cargo run -q
file                       rows
docs/reports/2025/q1.csv   3
docs/reports/2025/q2.csv   3
docs/reports/2026/q1.csv   1
docs: 17577 bytes
docs/img: 17408 bytes

Relative paths and path traversal

The strip_prefix method computes a path relative to a base directory.

main.rs
use std::path::Path;

fn main() {
    let root = Path::new("/srv/site");
    let files = [
        "/srv/site/index.html",
        "/srv/site/blog/2026/rust.html",
        "/etc/passwd",
    ];

    for f in files {
        match Path::new(f).strip_prefix(root) {
            Ok(rel) => println!("{rel:?} -> https://example.com/{}", rel.display()),
            Err(_) => println!("{f} is outside {}", root.display()),
        }
    }

    let user_input = "../../etc/passwd";
    let target = root.join(user_input);
    println!("{}", target.display());
    println!("starts with root: {}", target.starts_with(root));

    let safe = Path::new(user_input)
        .components()
        .all(|c| matches!(c, std::path::Component::Normal(_)));
    println!("safe: {safe}");
}

We turn file paths into URLs and check paths from untrusted input.

match Path::new(f).strip_prefix(root) {

The method returns an error when the path does not start with the prefix. Comparison is done by components, not characters, so /srv/sitemap does not start with /srv/site.

let target = root.join(user_input);
println!("starts with root: {}", target.starts_with(root));

Joining ../../etc/passwd gives a path that textually starts with the root but points outside it. The starts_with check alone does not protect against path traversal.

let safe = Path::new(user_input)
    .components()
    .all(|c| matches!(c, std::path::Component::Normal(_)));

A safer check accepts only plain names, rejecting .., the root, and prefixes. Another option is to canonicalize the joined path and compare it with the canonicalized root.

$ cargo run -q
"index.html" -> https://example.com/index.html
"blog/2026/rust.html" -> https://example.com/blog/2026/rust.html
/etc/passwd is outside /srv/site
/srv/site/../../etc/passwd
starts with root: true
safe: false

Removing files and directories

The std::fs module has separate functions for removing files, empty directories, and whole trees.

main.rs
use std::fs;

fn main() -> std::io::Result<()> {
    fs::create_dir_all("trash/a/b")?;
    fs::write("trash/a/b/one.txt", "1")?;
    fs::write("trash/two.txt", "2")?;

    fs::remove_file("trash/two.txt")?;
    println!("removed trash/two.txt");

    if let Err(e) = fs::remove_file("trash/two.txt") {
        println!("remove_file: {} ({:?})", e, e.kind());
    }

    if let Err(e) = fs::remove_dir("trash") {
        println!("remove_dir: {} ({:?})", e, e.kind());
    }

    if let Err(e) = fs::remove_file("trash/a") {
        println!("remove_file on a dir: {} ({:?})", e, e.kind());
    }

    fs::remove_dir_all("trash")?;
    println!("removed trash: {}", !fs::exists("trash")?);

    Ok(())
}

We build a small tree and remove it in several ways.

if let Err(e) = fs::remove_dir("trash") {
    println!("remove_dir: {} ({:?})", e, e.kind());
}

The remove_dir function removes only empty directories. The error kind is DirectoryNotEmpty. Likewise, remove_file does not remove directories.

fs::remove_dir_all("trash")?;
println!("removed trash: {}", !fs::exists("trash")?);

The remove_dir_all function deletes a directory with all its contents, like rm -r, so it must be used with care. The fs::exists function is the same as try_exists.

$ cargo run -q
removed trash/two.txt
remove_file: No such file or directory (os error 2) (NotFound)
remove_dir: Directory not empty (os error 39) (DirectoryNotEmpty)
remove_file on a dir: Is a directory (os error 21) (IsADirectory)
removed trash: true

Copying and moving

The fs::copy function copies a file, and fs::rename moves or renames files and directories.

main.rs
use std::fs;
use std::io;
use std::path::Path;

fn copy_dir(src: &Path, dst: &Path) -> io::Result<u64> {
    fs::create_dir_all(dst)?;
    let mut bytes = 0;
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let target = dst.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            bytes += copy_dir(&entry.path(), &target)?;
        } else {
            bytes += fs::copy(entry.path(), &target)?;
        }
    }
    Ok(bytes)
}

fn main() -> io::Result<()> {
    let n = fs::copy("docs/notes.md", "docs/notes.bak")?;
    println!("copied {n} bytes");

    fs::rename("docs/notes.bak", "docs/reports/notes.md")?;
    println!("moved: {}", Path::new("docs/reports/notes.md").exists());

    let total = copy_dir(Path::new("docs/reports"), Path::new("backup/reports"))?;
    println!("backup: {total} bytes");

    fs::rename("backup", "archive")?;
    println!("archive exists: {}", fs::exists("archive/reports/2025/q2.csv")?);

    fs::remove_file("docs/reports/notes.md")?;
    fs::remove_dir_all("archive")?;
    Ok(())
}

We copy a file, move it, and back up a whole directory.

let n = fs::copy("docs/notes.md", "docs/notes.bak")?;

The function copies the contents and the permission bits, and returns the number of bytes copied.

fs::rename("docs/notes.bak", "docs/reports/notes.md")?;

Renaming within one file system is atomic and fast, since no data is copied. Moving between file systems fails; the file must be copied and the original removed.

fn copy_dir(src: &Path, dst: &Path) -> io::Result<u64> {

There is no function to copy a directory, so we write a recursive one. It recreates the directory structure and copies each file.

$ cargo run -q
copied 26 bytes
moved: true
backup: 147 bytes
archive exists: true

Temporary and home directories

The std::env module tells where temporary files belong and where the user's home directory is.

main.rs
use std::env;
use std::fs;
use std::process;

fn main() -> std::io::Result<()> {
    let tmp = env::temp_dir();
    println!("temp dir: {}", tmp.display());

    let work = tmp.join(format!("myapp-{}", process::id()));
    fs::create_dir(&work)?;

    let file = work.join("data.txt");
    fs::write(&file, "temporary data\n")?;
    println!("wrote {} bytes", fs::metadata(&file)?.len());

    fs::remove_dir_all(&work)?;
    println!("cleaned up: {}", !work.exists());

    if let Some(home) = env::home_dir() {
        println!("home: {}", home.display());
    }

    Ok(())
}

We create a working directory for temporary data and clean it up.

let work = tmp.join(format!("myapp-{}", process::id()));

The temp_dir function returns the value of the TMPDIR variable, or /tmp on Unix. The process ID makes the name unique among running instances. The tempfile crate creates temporary files and directories with random names and removes them automatically.

if let Some(home) = env::home_dir() {

The home_dir function reads the HOME variable on Unix and the user profile directory on Windows.

$ cargo run -q
temp dir: /tmp
wrote 15 bytes
cleaned up: true
home: /home/user

Source

std::path::Path documentation

std::path::PathBuf documentation

std::fs module documentation

In this article we have worked with paths and directories in Rust. Reading and writing file contents is covered in Rust read file and Rust write file.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.