#182 Jun 4, 2026

182. Path::with_extension — Swap a File Extension Without Slicing Strings

You have report.txt and want report.md. Reaching for replace(".txt", ".md") or a rfind('.')? Stop — Path::with_extension returns a fresh PathBuf with the extension swapped, and it gets every edge case right.

The string-slicing trap

The naïve fix looks reasonable until you read it carefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn change_ext_bad(name: &str, ext: &str) -> String {
    match name.rfind('.') {
        Some(i) => format!("{}.{}", &name[..i], ext),
        None    => format!("{}.{}", name, ext),
    }
}

assert_eq!(change_ext_bad("report.txt", "md"), "report.md");
// But...
assert_eq!(change_ext_bad("./.bashrc", "bak"), "./.bak"); // ate the dotfile name

That second case is the bug: ./.bashrc has no extension — the leading dot is part of the name. Manual rfind('.') doesn’t know that.

The fix: Path::with_extension

1
2
3
4
5
6
use std::path::{Path, PathBuf};

let p = Path::new("reports/q1.txt");
let renamed: PathBuf = p.with_extension("md");

assert_eq!(renamed, PathBuf::from("reports/q1.md"));

It returns a new PathBuf — original path untouched — and stays in OsStr land the whole way through, so non-UTF-8 paths survive intact.

Dotfiles are handled the way you’d want:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new(".bashrc").with_extension("bak"),
           PathBuf::from(".bashrc.bak"));

No extension to start? It adds one instead of failing:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("Makefile").with_extension("bak"),
           PathBuf::from("Makefile.bak"));

Pass "" to strip the extension

The same method, with an empty string, removes the extension entirely — no separate without_extension API needed:

1
2
3
4
use std::path::{Path, PathBuf};

let src = Path::new("build/main.o");
assert_eq!(src.with_extension(""), PathBuf::from("build/main"));

Common pattern in build scripts: derive an output path from an input path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::path::{Path, PathBuf};

fn object_for(src: &Path) -> PathBuf {
    src.with_extension("o")
}

assert_eq!(object_for(Path::new("src/main.rs")),
           PathBuf::from("src/main.o"));
assert_eq!(object_for(Path::new("src/lib.rs")),
           PathBuf::from("src/lib.o"));

Only the last extension changes

with_extension replaces from the last dot — same rule as file_stem. For archive.tar.gz, that means only .gz gets swapped:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("archive.tar.gz").with_extension("zst"),
           PathBuf::from("archive.tar.zst"));

That’s almost always what you want for compression tools. If you need to strip the whole .tar.gz and start over, call with_extension("") twice — or reach for file_prefix (see bite 116).

set_extension if you already own the PathBuf

The mutating sibling lives on PathBuf and avoids the allocation when you already own the path:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("notes/draft.md");
p.set_extension("html");
assert_eq!(p, PathBuf::from("notes/draft.html"));

Returns booltrue if the extension was set, false if the path had no file name to attach one to. Most callers ignore it.

Reach for with_extension (or set_extension) any time you’d otherwise write a rfind('.') or a replace(".old", ".new"). It’s been stable since Rust 1.0 — there’s no excuse left.

← Previous 181. Option::get_or_insert_with — Lazy Default That Returns &mut Next → 183. std::mem::take — Move Out of &mut self Without the Clone