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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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::PathBuf 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
List all Rust tutorials.