C++ std::filesystem::path | Cross-platform paths in C++17
Key takeaways
std::filesystem::path looks like a thin wrapper around a string, but it stores a platform-native representation, normalizes lazily rather than eagerly, and compares lexically rather than semantically. This post covers the encoding gotchas, the lexical-vs-actual distinction between lexically_normal() and canonical(), and the equality traps that bite when the same file is reachable through two different path spellings.
Introduction
std::filesystem::path, added in C++17, is the type every other filesystem function in the standard library takes and returns. It looks unremarkable — construct it from a string literal, join pieces with /, ask for the filename() — and for the common case that’s all you need. But path is not a std::string wearing a costume. It stores a platform-native representation internally, its normalization rules are stranger than they look, and its comparison operators do something most people don’t expect the first time it costs them a bug. This post focuses specifically on the path type itself: encoding, normalization, and comparison. General directory traversal and file I/O are covered in the companion posts on C++ filesystem and file I/O — here the object under the microscope is the path, not what you do with it once you have a handle to a file.
Why path stores a platform-native string, not always UTF-8
This is the single most important implementation detail to internalize, because it explains almost every encoding bug you’ll hit with path.
On POSIX systems, path::value_type is char, and the native format is whatever byte sequence the OS filesystem API expects — typically UTF-8 on modern Linux, but the standard makes no such guarantee; POSIX paths are technically opaque byte strings. On Windows, path::value_type is wchar_t, and the native format is UTF-16, because that’s what the Win32 wide-character APIs (CreateFileW and friends) actually consume. std::filesystem::path was designed to wrap whichever native representation the platform’s OS calls need, with zero encoding conversion cost when you’re already working in that representation.
#include <filesystem>
namespace fs = std::filesystem;
// On POSIX: path::string_type is std::string (narrow, native encoding)
// On Windows: path::string_type is std::wstring (UTF-16)
fs::path p1 = "report.txt"; // fine everywhere: pure ASCII
fs::path p2 = u8"보고서.txt"; // UTF-8 literal — needs care on Windows
Here’s where it gets sharp. When you construct a path from a std::string (narrow, char-based) on Windows, the standard library has to convert that byte sequence into UTF-16 to store it as the native wstring representation. Which encoding does it assume the input std::string is in? Historically, implementations used the current Windows ANSI code page, not UTF-8 — because that’s what char-based Win32 APIs like CreateFileA assume. If your std::string actually holds UTF-8 (which is the sane, portable choice almost everyone makes today), and the process’s code page isn’t UTF-8, non-ASCII characters get silently mis-transcoded. The file gets created, but with a garbled name, or the lookup for an existing file fails even though the path “looks right” when you print it.
The safe pattern on Windows when you have UTF-8 text is to convert explicitly to UTF-16 before handing it to path, or to build the path from a wchar_t/u8string overload rather than an ambiguous narrow string:
#include <filesystem>
#include <string>
// Best: construct from u8string explicitly (C++20 gives fs::path a
// dedicated constructor for this that guarantees UTF-8 interpretation)
std::u8string utf8_name = u8"보고서.txt";
fs::path p = utf8_name; // C++20: interpreted as UTF-8, converted correctly
// Round-tripping back out: path::u8string() gives you UTF-8 bytes back,
// regardless of the native representation underneath
std::u8string back = p.u8string();
Before C++20 added the u8string overloads, the common workaround was MultiByteToWideChar/WideCharToMultiByte calls at the boundary, or a small helper using std::filesystem::path’s wide-string constructor directly with manually converted UTF-16. If you’re stuck on C++17 and cross-platform UTF-8 file names on Windows, budget time for this — it is not optional plumbing, it is the actual correctness boundary.
Normalization surprises: / isn’t string concatenation
The / operator and append() are not += on a string. They implement a specific joining rule, and the difference matters the moment either operand has a leading or trailing separator.
fs::path a = "/home/user";
fs::path b = "documents";
std::cout << (a / b) << "\n"; // "/home/user/documents"
std::cout << (a / "/etc") << "\n"; // "/etc" <-- NOT "/home/user/etc"!
That second line surprises almost everyone the first time they see it. Per the standard, if the right-hand operand of / is itself an absolute path (or has a root-name/root-directory), the result of append discards everything from the left-hand path and becomes the right-hand path. This mirrors POSIX shell and Python’s os.path.join behavior, but it’s easy to forget when you’re building paths from user input or configuration values that might unexpectedly start with /.
Trailing separators are the other trap, and they hit filename() and parent_path() specifically:
fs::path p1 = "/home/user/documents";
fs::path p2 = "/home/user/documents/"; // trailing slash
std::cout << p1.filename() << "\n"; // "documents"
std::cout << p2.filename() << "\n"; // "" <-- empty!
std::cout << p1.parent_path() << "\n"; // "/home/user"
std::cout << p2.parent_path() << "\n"; // "/home/user/documents"
The reason is that path decomposes into a sequence of elements, and a trailing separator produces a trailing empty element in that decomposition. filename() returns the last element — which is empty if the path ends in a separator — and parent_path() returns everything except the last element, so with a trailing slash the “parent” is the whole directory path including documents, not /home/user. If your code builds paths by string concatenation from directory listings or user-typed paths and doesn’t normalize trailing slashes, this will silently corrupt filename extraction exactly where you’d least expect it — in a rename or move operation where the destination filename ends up empty.
Lexical operations vs. actual filesystem operations
path gives you two families of normalization: lexical (pure text manipulation, never touches disk) and filesystem-querying (asks the OS, requires the path to exist, follows symlinks). Conflating the two is a recurring source of bugs, especially around symlinks.
flowchart TD
A["fs::path (raw, unnormalized)"] --> B{"Which operation?"}
B -->|"lexically_normal()"| C["Pure text rewrite\nNo disk access\n'..' removed syntactically"]
B -->|"weakly_canonical()"| D["Resolves existing prefix via canonical()\nAppends remaining non-existent tail lexically"]
B -->|"canonical()"| E["Requires path to exist\nResolves every symlink\nThrows filesystem_error if missing"]
C --> F["May be WRONG across symlinks"]
D --> F
E --> G["Guaranteed correct, but requires existence"]
lexically_normal() collapses . and .. components using pure string rules — it has no idea whether any directory in the path is actually a symlink. Consider a/link_to_b/../c, where link_to_b is a symlink pointing somewhere other than a sibling of c. Lexically, .. cancels link_to_b, giving a/c. But if you actually cd through that path on disk, the symlink takes you somewhere else entirely before .. climbs back up, and the real target is not a/c. lexically_normal() will confidently hand you the wrong answer because it was never designed to consult the filesystem — that’s the whole point of it being lexical.
canonical() is the function that gets this right, because it actually resolves the path against the filesystem, following every symlink and requiring the path to exist:
try {
fs::path resolved = fs::canonical("a/link_to_b/../c");
std::cout << resolved << "\n";
} catch (const fs::filesystem_error& e) {
// Thrown if any component doesn't exist
std::cerr << "Path does not exist: " << e.what() << "\n";
}
The cost of that correctness is the throw — canonical() is unusable for paths that don’t exist yet, such as a destination file you’re about to create. That’s what weakly_canonical() is for: it resolves the longest existing prefix of the path with the real canonical() semantics (symlinks and all), then lexically appends whatever trailing components don’t exist yet. It’s a genuinely useful middle ground, but it inherits the same blind spot as lexically_normal() for the non-existent tail — if the missing part of the path would itself cross a symlink once created, weakly_canonical() can’t know that in advance.
The practical rule I use: reach for lexically_normal() only when you explicitly want pure syntactic cleanup — e.g., presenting a tidy path to a user, or comparing two path strings you already know contain no symlinks. The instant a path might involve a symlink and the answer needs to be correct, not just tidy, use canonical() if the path must already exist, or accept that you cannot get a fully reliable answer without it existing.
Comparison is lexical, not semantic
This is the pitfall that has burned me personally, so it’s worth stating as bluntly as the standard states it: path::operator== compares the normalized lexical representation of the two paths as sequences of elements. It does not stat anything, does not resolve symlinks, and does not know that two different strings might refer to the identical file on disk.
fs::path p1 = "/data/current/config.json";
fs::path p2 = "/data/releases/v3/config.json";
// Suppose /data/current is a symlink to /data/releases/v3
// These two paths can refer to the exact same inode on disk...
std::cout << (p1 == p2) << "\n"; // false — compared as text, not as files
fs::path p3 = "/home/user/./file.txt";
fs::path p4 = "/home/user/file.txt";
std::cout << (p3 == p4) << "\n"; // also false! `.` isn't stripped by operator==
That last line trips people up even without symlinks in the picture: operator== does not run lexically_normal() first. . and redundant separators are preserved as distinct elements unless you normalize both sides yourself before comparing. If you actually want “do these two paths point at the same file,” the correct tool is std::filesystem::equivalent(p1, p2), which does query the filesystem (stats both paths and compares device/inode identity), at the cost of requiring both paths to exist:
if (fs::exists(p1) && fs::exists(p2) && fs::equivalent(p1, p2)) {
std::cout << "Same file on disk\n";
}
I once spent the better part of an afternoon chasing a cache-invalidation bug in a build tool where a dependency graph was keyed on fs::path equality. The build config used a symlinked current -> releases/v3 directory so downstream tooling always pointed at “the active release” without editing config files on every deploy. Two build steps referenced what was, on disk, the exact same header file — one through the current symlink, one through the fully resolved releases/v3 path baked into a generated dependency file. The map keyed on path treated them as two unrelated entries, so the incremental build dutifully recompiled things it should have skipped, and worse, occasionally raced two build steps writing to what they each believed was a distinct output path but was actually the same inode. The fix was boring once the actual cause was clear: canonicalize every path to a single canonical form the moment it entered the dependency graph, rather than trusting operator==/std::hash<path> to understand “same file” the way I did. It’s an easy trap because the type looks like it should just work as a map key the way a string does — the standard is explicit that it doesn’t carry filesystem identity, but that detail is easy to skip past on a first read.
The same lexical-vs-real distinction bit me a second time in a smaller way: mixing manual string concatenation with path in the same codebase. A different part of that build tool constructed intermediate output paths with plain std::string concatenation (dir + "/" + name) instead of path’s / operator, and dir came from a config value that sometimes carried a trailing slash and sometimes didn’t. On Linux this mostly worked because a doubled / in a path is harmless to the OS — // and / resolve identically at the syscall level. The bug only showed up once a Windows CI runner was added to the pipeline: the equivalent code path built dir + "\\" + name and a trailing backslash in the config produced a double backslash that a couple of older Win32 APIs treated as the start of a UNC path (\\server\share) rather than a plain doubled separator, and file creation failed with a confusing “network path not found” error nowhere near the actual bug. Switching that one string-concatenation site to path::operator/ fixed it, because / explicitly strips a redundant separator at the join point instead of blindly gluing bytes together — which is exactly the normalization behavior described above, just encountered as a production incident instead of as documentation.
Rules I follow with fs::path
- Treat
pathconstruction fromstd::stringon Windows as a place where encoding bugs hide. Prefer explicit UTF-8 conversion (or the C++20u8stringconstructors) over relying on the ambient code page. - Always use
/orappend()to build paths, never manual string concatenation with+. The separator-collapsing and absolute-path-override behavior of/is a feature, not a surprise, once you know the rule. - Normalize trailing separators deliberately before calling
filename()/parent_path()if the path came from user input, a config file, or a directory listing that might append one inconsistently. - Reach for
lexically_normal()for cosmetic cleanup only. Reach forcanonical()/weakly_canonical()when correctness across symlinks actually matters and you can tolerate (or work around) the existence requirement. - Never use
path::operator==as a proxy for “same file.” Usefs::equivalent()when you mean that, and be prepared to eat the cost of both paths needing to exist.
Renaming extensions and building backup paths
Batch-renaming file extensions
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void rename_extensions(const fs::path& dir,
const std::string& old_ext,
const std::string& new_ext) {
for (const auto& entry : fs::directory_iterator(dir)) {
if (entry.is_regular_file() && entry.path().extension() == old_ext) {
fs::path new_path = entry.path();
new_path.replace_extension(new_ext);
fs::rename(entry.path(), new_path);
std::cout << "Renamed: " << entry.path().filename()
<< " -> " << new_path.filename() << std::endl;
}
}
}
int main() {
rename_extensions("./images", ".jpeg", ".jpg");
return 0;
}
This is a good illustration of path staying purely a name-shaped value right up until fs::rename is called: entry.path() and new_path are both just decomposed strings until that one line actually touches the filesystem. Notice that replace_extension operates lexically too — it doesn’t check whether the resulting filename would collide with an existing file, so a batch rename like this can silently overwrite files if two entries would produce the same new name. Worth a dry-run pass (print instead of rename) before running this against anything you can’t afford to lose.
A backup path built with lexically_normal()
fs::path create_backup_path(const fs::path& original) {
// lexically_normal() first, in case `original` came from user input
// with redundant "." or ".." components — we want the naming logic
// below to work on a clean decomposition.
fs::path normalized = original.lexically_normal();
fs::path backup = normalized;
backup.replace_extension();
std::string backup_name = backup.filename().string() + "_backup";
backup = backup.parent_path() / backup_name;
backup.replace_extension(normalized.extension());
return backup;
}
int main() {
fs::path original = "/home/user/./document.txt";
fs::path backup = create_backup_path(original);
std::cout << "Original: " << original << std::endl;
std::cout << "Backup: " << backup << std::endl;
return 0;
}
Calling lexically_normal() before decomposing is cheap insurance: if original arrived with a stray . (as it does here) or a trailing separator, skipping normalization would make filename() and parent_path() return the wrong pieces, and the generated backup path would be malformed in a way that’s easy to miss in a quick manual test but shows up the first time the function runs against a real, messier input.
Why path surprises people
std::filesystem::path earns the “just a wrapper around a string” reputation right up until you hit one of three walls: the platform-native encoding it stores internally, the lexical-only normalization that doesn’t know a symlink exists, and the lexical-only comparison that doesn’t know two spellings point at the same file. None of these are bugs in the standard library — they’re documented, deliberate design choices that trade semantic correctness for the ability to manipulate paths without touching the disk on every operation. The trade-off is reasonable; the failure mode is that it’s easy to forget you made it until a symlink, a trailing slash, or a non-UTF-8 code page turns a path comparison or normalization into the wrong answer.
Related posts
Frequently Asked Questions (FAQ)
Q. Why does path("/a") / "/b" give /b instead of /a/b?
A. Because operator/ treats a right-hand operand that is itself absolute as replacing the left-hand path entirely — the same rule POSIX shells and os.path.join follow. If you need guaranteed concatenation regardless of what the right side looks like, strip a leading separator from it first or use path::string() concatenation deliberately (and normalize afterward).
Q. Is fs::path safe to use as a std::unordered_map key for “is this the same file”?
A. Only if every path you insert has already been passed through the same canonicalization step (ideally canonical(), since weakly_canonical()/lexically_normal() don’t resolve symlinks). Raw, un-normalized paths hash and compare by lexical content, so two different spellings of the same file will not collide the way you’d want them to.