How to Use std

:path for Path Manipulation

Use std::path::Path for viewing paths and PathBuf for building or modifying them in a cross-platform way.

Paths are not strings

You're writing a tool that needs to find a config file. In Python, you probably just did os.path.join or f-strings. In Rust, you reach for string concatenation. You type "/home/user" + "/docs". It works on Linux. You push to CI. The Windows runner fails because it expected backslashes, or worse, you accidentally created a path with a double slash that broke a legacy C library. Or you tried to append a filename and got a type error because Rust refuses to treat paths as plain text. Paths look like strings, but they aren't strings. Treating them as strings is a fast track to bugs that only show up on a different operating system.

Rust splits path handling into two types. Path is a borrowed view. It points to path data somewhere else. You can read from it, split it, check it, but you can't change it. PathBuf is the owned buffer. It holds the memory. You can push segments, pop them, and modify the path. This mirrors Rust's string types: &str is to String as Path is to PathBuf. The compiler enforces this split to prevent you from mutating a path while someone else is reading it, and to keep zero-cost abstractions for reading paths without allocating.

Under the hood, Path wraps OsStr. OsStr stands for Operating System String. It's a sequence of bytes that the OS understands. On Linux and macOS, OsStr is just UTF-8 bytes. On Windows, it's UTF-16 code units. This difference is why Rust doesn't let you treat paths as &str directly. If Rust allowed &str, you'd have to convert every path to UTF-8, which fails on Windows for some legacy paths, or requires encoding work. OsStr abstracts this away. You manipulate the path structure; the OS handles the bytes. When you need text, you convert. This is the lossy part. to_string_lossy does the conversion and replaces anything that isn't valid UTF-8 with the replacement character. It never panics. It gives you a string you can print, even if the path is weird.

Treat paths as structured data, not text. The compiler will thank you.

The minimal toolkit

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

fn main() {
    // Path::new takes a borrowed string slice. No allocation happens here.
    // The lifetime of the Path is tied to the string literal.
    let config_path = Path::new("config.toml");

    // PathBuf owns the data. This allocates memory on the heap.
    // Use PathBuf when you need to modify the path or store it.
    let mut base = PathBuf::from("/home/user");

    // push appends a component, handling separators automatically.
    // On Windows, this adds a backslash. On Unix, a forward slash.
    base.push("projects");

    // display() returns a formatter that handles platform-specific escaping.
    // Path does not implement Display, so you must use display() for printing.
    println!("Full path: {}", base.display());

    // file_name() extracts the last component. It returns an Option<&OsStr>.
    // It returns None for paths like "/" or ".".
    if let Some(name) = config_path.file_name() {
        // to_string_lossy() converts OsStr to &str, replacing invalid UTF-8.
        // This is safe for logging and user-facing output.
        println!("File: {}", name.to_string_lossy());
    }
}

How the pieces fit

When you call Path::new, you get a &Path. This is a zero-sized type wrapper around a &OsStr. It doesn't own the bytes. If the string it points to goes out of scope, the Path becomes invalid. The borrow checker prevents this. PathBuf allocates a buffer. push checks if the new component is absolute. If it is, push replaces the entire buffer. This is a safety feature. You don't want base.push("/etc") to result in /home/user/etc. You want /etc. The logic handles platform separators. On Windows, push("docs") adds a backslash. On Unix, it adds a forward slash. You write the code once; the path logic adapts.

Rust uses a mechanism called Deref to make PathBuf act like Path. When you have a PathBuf, you can pass it to a function expecting &Path. The compiler automatically inserts a reference conversion. This is why library functions almost always take &Path in their arguments. If they took &PathBuf, you couldn't pass a path created from a string literal without allocating a PathBuf first. By taking &Path, the function accepts &PathBuf, &Path, and even &str via Path::new. This pattern reduces allocations and makes APIs ergonomic. Follow this pattern in your own code. Accept &Path for read-only path arguments.

Trust the borrow checker on lifetimes. It keeps your views valid.

Real-world usage

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

/// Finds the first existing config file in a list of candidates.
/// Returns None if no file exists.
fn find_config(candidates: &[&str]) -> Option<PathBuf> {
    for candidate in candidates {
        // Construct an owned PathBuf for each candidate.
        let path = PathBuf::from(candidate);
        
        // Check if the file exists on the filesystem.
        // This performs a system call, so avoid calling it in tight loops.
        if path.exists() {
            return Some(path);
        }
    }
    None
}

fn main() {
    let locations = ["./config.toml", "../config.toml", "/etc/myapp/config.toml"];
    
    if let Some(config) = find_config(&locations) {
        println!("Found config at: {}", config.display());
        
        // Extract directory and filename separately.
        // parent() returns None for paths with no parent, like "file.txt".
        if let Some(parent) = config.parent() {
            println!("In directory: {}", parent.display());
        }
        
        // Get extension without the dot.
        // extension() returns None if there is no extension.
        if let Some(ext) = config.extension() {
            println!("Extension: {}", ext.to_string_lossy());
        }
    }
}

Community convention dictates using .display() for any path output. Path implements Debug, but not Display. If you try println!("{}", path), the compiler rejects it with E0277 (trait bound not satisfied). You must use path.display(). This returns a temporary formatter that handles platform-specific escaping and ensures the output is safe for terminals. Another convention: use PathBuf::from for construction. PathBuf::new creates an empty buffer, which is rarely what you want. from leverages the From trait to accept &str, String, and OsString. It's the idiomatic entry point.

Use to_string_lossy for display. Panic on to_str only if you control the input.

Pitfalls and compiler errors

The absolute path trap catches everyone. path_buf.push("/absolute") wipes path_buf. The result is /absolute. This is a safety feature. You don't want base.push("/etc") to result in /home/user/etc. You want /etc. The logic handles platform separators. On Windows, push("docs") adds a backslash. On Unix, it adds a forward slash. You write the code once; the path logic adapts.

Type mismatches are common when you're new to the split. You have a &Path and need a PathBuf. You can't just assign it. You need .to_path_buf() or .into(). If you try to pass &Path to a function expecting PathBuf, you get E0308 (mismatched types). The compiler is telling you that a borrowed view cannot become an owned buffer without copying the data.

Method errors happen when you forget which type owns the data. You try path.push("foo") on a &Path. Compiler says E0599 (no method named push found for reference &Path). Path is immutable. You need a PathBuf. The error message usually suggests methods available on Path, like join, which returns a new PathBuf instead of mutating.

String concatenation is a silent killer. format!("{}/{}", dir, file) works but breaks on Windows separators and doesn't handle absolute path replacement. It also allocates a String which is wasteful if you just need a path. PathBuf::join reuses the buffer when possible and handles the logic correctly. Never concatenate paths with strings.

Check for absolute paths before you push. A single slash can wipe your buffer.

Decision matrix

Use Path when you only need to read or inspect a path and don't own the data. Use Path for function arguments to accept both &Path and &PathBuf via deref coercion.

Use PathBuf when you need to construct a path, modify it, or store it in a struct. Use PathBuf when the path data must outlive the current scope.

Use join when you want to create a new path without mutating the original. Use join to chain path components in a single expression.

Use push when you are building a path step-by-step and want to mutate an existing PathBuf. Use push inside loops where allocation reuse matters.

Use to_string_lossy when you need a &str for logging, printing, or passing to a library that requires UTF-8. Use to_string_lossy to avoid panicking on valid filesystem paths that contain non-UTF-8 bytes.

Use to_str when you must guarantee the path is valid UTF-8 and you want to handle the failure case explicitly. Use to_str only if your application logic breaks on non-UTF-8 paths.

Use OsString when you need an owned path component that might not be UTF-8. Use OsString for low-level interoperability or when parsing raw OS data.

Prefer &Path in your API signatures. It makes your code flexible and cheap.

Where to go next