Skip to content
C++
Library
since C++17
Basic

std::filesystem

Cross-platform filesystem operations in C++17 — path manipulation, directory traversal, file status queries, and file I/O utilities.

std::filesystemsince C++17

std::filesystem (header <filesystem>) provides a portable, object-oriented API for path manipulation, file metadata queries, directory traversal, and common filesystem operations, replacing platform-specific POSIX and Win32 APIs.

Overview

Before C++17, portable filesystem code required #ifdef-switching between POSIX (stat, opendir, readdir) and Win32 (FindFirstFile, _stat). std::filesystem provides a single API across all three major platforms. It was adopted from Boost.Filesystem with minor changes and standardized as ISO/IEC TS 18822 before merging into C++17.

The library centers on three types:

  • fs::path — an immutable path value (not necessarily an existing file)
  • fs::directory_entry — a cached view of a filesystem entry (type, size, times)
  • fs::file_status — file type and permission bits

All free functions have two overloads: one that throws fs::filesystem_error on failure, and one that writes to a std::error_code output parameter instead. Prefer the error_code overloads in any code that handles expected failures (missing files, permission denied) rather than wrapping calls in try/catch.

Syntax

cpp
#include <filesystem>
namespace fs = std::filesystem;  // conventional alias

// Path construction
fs::path p = "/home/user/data.csv";        // from string literal
fs::path p2 = fs::current_path() / "out";  // operator/ concatenates

// Query functions (throw on error)
bool ok   = fs::exists(p);
uintmax_t n = fs::file_size(p);

// Query functions (no-throw)
std::error_code ec;
uintmax_t n2 = fs::file_size(p, ec);  // ec.value() == 0 on success

// Directory iteration
for (const fs::directory_entry& e : fs::directory_iterator(p)) { ... }
for (const fs::directory_entry& e : fs::recursive_directory_iterator(p)) { ... }

fs::path Operations in Depth

fs::path is a value type wrapping a pathname string. It stores native encoding internally (wchar_t sequences on Windows, char on POSIX) but exposes a rich decomposition and composition API. All decomposition is purely lexical — no filesystem access occurs.

Path composition with operator/

The / operator (and /= compound assignment) appends a path component, inserting a separator if needed and discarding the left-hand side when the right-hand side is absolute.

cpp
// C++17
fs::path base = "/var/log";
fs::path full = base / "app" / "error.log";
// "/var/log/app/error.log"

// Right-hand absolute path replaces the left
fs::path replaced = base / "/etc/hosts";
// "/etc/hosts"  — base is discarded

// Build up incrementally
fs::path p;
p /= "build";
p /= "output";
p /= "main.o";
// "build/output/main.o"

Path decomposition

cpp
// C++17
fs::path p = "/usr/local/lib/libfoo.so.2";

p.root_name();    // ""        (empty on POSIX; "C:" on Windows)
p.root_directory(); // "/"
p.root_path();    // "/"       (root_name + root_directory)
p.relative_path(); // "usr/local/lib/libfoo.so.2"
p.parent_path();  // "/usr/local/lib"
p.filename();     // "libfoo.so.2"
p.stem();         // "libfoo.so"   — strips the last extension only
p.extension();    // ".2"

// A deeper stem example
fs::path p2 = "archive.tar.gz";
p2.stem();      // "archive.tar"  — only last extension stripped
p2.extension(); // ".gz"

Modifying path components

The mutating methods return *this to allow chaining. They operate lexically — no disk access.

cpp
// C++17
fs::path p = "/home/user/report.txt";

// Replace extension
p.replace_extension(".bak");
// "/home/user/report.bak"

p.replace_extension("");     // remove extension entirely
// "/home/user/report"

// Replace filename
p.replace_filename("summary.pdf");
// "/home/user/summary.pdf"

// Remove filename (equivalent to parent_path())
p.remove_filename();
// "/home/user/"

Lexical normalisation

cpp
// C++17
fs::path messy = "/var/./log/../log/app//";
fs::path clean = messy.lexically_normal();
// "/var/log/app"

// Relative normalisation
fs::path rel = "a/b/../../c/./d";
rel.lexically_normal();  // "c/d"

// Make relative to a base (no stat — purely lexical)
fs::path full  = "/usr/local/lib/pkgconfig";
fs::path base2 = "/usr/local";
full.lexically_relative(base2);  // "lib/pkgconfig"

Platform-portable string extraction

cpp
// C++17
fs::path p = "/home/user/data.csv";

p.string();          // native encoding (char on POSIX, narrow on Windows)
p.wstring();         // wchar_t string (useful on Windows)
p.u8string();        // UTF-8 encoded — std::string in C++17, std::u8string in C++20
p.u16string();       // UTF-16
p.u32string();       // UTF-32
p.generic_string();  // forward slashes on ALL platforms — use for logging/display
p.generic_u8string(); // forward slashes + UTF-8

// On Windows:
// p.string()          might be "C:\\Users\\user\\data.csv"
// p.generic_string()  gives   "C:/Users/user/data.csv"

Best Practice. Use path.generic_string() whenever you display or log a path — it guarantees forward slashes on all platforms and avoids confusing output in logs.

std::error_code Overloads vs Exceptions

Every std::filesystem free function exists in two forms. The choice between them signals intent: are you handling an expected failure mode, or guarding against a programming error?

cpp
// C++17

// --- Throwing version ---
// Use when failure is unexpected (programmer error, corrupted state)
try {
    fs::remove("/tmp/scratch.dat");
} catch (const fs::filesystem_error& e) {
    // e.what()   — human-readable description
    // e.path1()  — first path involved
    // e.path2()  — second path (for two-argument operations like copy/rename)
    // e.code()   — the std::error_code
    std::cerr << "Unexpected error: " << e.what() << '\n';
    throw;
}

// --- Non-throwing version ---
// Use when failure is expected (file may not exist, permission may be denied)
std::error_code ec;
fs::remove("/tmp/scratch.dat", ec);
if (ec) {
    if (ec == std::errc::no_such_file_or_directory) {
        // file was already gone — that's fine
    } else {
        std::cerr << "remove failed: " << ec.message() << '\n';
    }
}

The error_code returned belongs to std::system_category() on POSIX (errno values) and std::system_category() on Windows (GetLastError values). You can compare against std::errc enumerators portably:

cpp
// C++17
std::error_code ec;
fs::file_size("missing.txt", ec);

if (ec == std::errc::no_such_file_or_directory) { /* ENOENT */ }
if (ec == std::errc::permission_denied)         { /* EACCES */ }
if (ec == std::errc::not_a_directory)           { /* ENOTDIR */ }

Best Practice. Reserve exceptions for truly unexpected failures. Use error_code overloads for anything the user, network, or OS can cause — missing files, permission issues, full disks. This keeps hot paths allocation-free (exception throwing allocates) and makes error handling explicit.

Directory Traversal

Non-recursive: fs::directory_iterator

fs::directory_iterator visits the immediate children of a directory in an unspecified order (filesystem order, typically creation order on some filesystems). It does not recurse into subdirectories.

cpp
// C++17
fs::path dir = "src";

for (const fs::directory_entry& entry : fs::directory_iterator(dir)) {
    if (entry.is_regular_file()) {
        std::cout << "  file: " << entry.path().filename().string() << '\n';
    } else if (entry.is_directory()) {
        std::cout << "  dir:  " << entry.path().filename().string() << '\n';
    } else if (entry.is_symlink()) {
        std::cout << "  link: " << entry.path().filename().string() << '\n';
    }
}

Recursive: fs::recursive_directory_iterator

Descends into every subdirectory. Exposes depth() to know the current recursion level and disable_recursion_pending() to skip descending into the current entry's children.

cpp
// C++17
for (const fs::directory_entry& entry :
        fs::recursive_directory_iterator("project")) {
    // entry.depth() — 0 for direct children of "project"
    std::string indent(entry.depth() * 2, ' ');
    std::cout << indent << entry.path().filename().string() << '\n';
}

Filtering by extension

cpp
// C++17 — collect all .cpp files under a directory tree
std::vector<fs::path> sources;

std::error_code ec;
fs::recursive_directory_iterator it("src", ec);
fs::recursive_directory_iterator end;

for (; it != end; it.increment(ec)) {
    if (ec) { ec.clear(); continue; }  // skip unreadable dirs
    const fs::directory_entry& entry = *it;
    if (entry.is_regular_file() && entry.path().extension() == ".cpp") {
        sources.push_back(entry.path());
    }
}

Alternatively, use the range-for form with skip_permission_denied:

cpp
// C++17
for (const auto& e : fs::recursive_directory_iterator(
        "src", fs::directory_options::skip_permission_denied)) {
    if (e.is_regular_file() && e.path().extension() == ".cpp") {
        sources.push_back(e.path());
    }
}

Best Practice. Prefer calling entry.is_regular_file() and entry.is_directory() on the directory_entry object rather than the free functions fs::is_regular_file(path). The entry methods reuse cached stat data fetched during iteration; the free functions issue a fresh syscall per file.

File Operations

Copying

fs::copy handles files and directories. The default behaviour rejects overwriting an existing destination — control this with copy_options.

cpp
// C++17

// Copy a single file (throws if dst exists)
fs::copy("src/main.cpp", "backup/main.cpp");

// Overwrite if destination already exists
fs::copy("src/main.cpp", "backup/main.cpp",
         fs::copy_options::overwrite_existing);

// Skip if destination is already up-to-date (based on mtime)
fs::copy("src/main.cpp", "backup/main.cpp",
         fs::copy_options::update_existing);

// Skip without error if destination exists
fs::copy("src/main.cpp", "backup/main.cpp",
         fs::copy_options::skip_existing);

// Recursive directory copy
fs::copy("build/artifacts", "deploy/",
         fs::copy_options::overwrite_existing | fs::copy_options::recursive);

// Copy symlinks as symlinks (default would follow and copy target)
fs::copy("link_to_file", "link_copy",
         fs::copy_options::copy_symlinks);

// Non-throwing form
std::error_code ec;
fs::copy("src", "dst", fs::copy_options::recursive, ec);
if (ec) { std::cerr << "copy: " << ec.message() << '\n'; }

Renaming and moving

fs::rename maps to rename(2) on POSIX. If source and destination are on the same filesystem, it is atomic (the inode is re-pointed). Cross-device rename will fail with EXDEV — in that case, copy-then-delete.

cpp
// C++17
fs::rename("old_name.txt", "new_name.txt");   // same dir — atomic on POSIX

// Safe write-then-rename pattern
fs::path target = "config.json";
fs::path tmp    = target;
tmp += ".tmp";

{
    std::ofstream out(tmp);
    out << new_content;
}  // flush + close before rename

fs::rename(tmp, target);  // atomic swap on POSIX

Removing files and directories

cpp
// C++17
bool was_present = fs::remove("scratch.dat");    // false if already absent
// fs::remove on a non-empty directory throws — use remove_all

std::uintmax_t count = fs::remove_all("build/");  // recursive, returns entry count
// returns (uintmax_t)-1 on error (C++17); use error_code overload to check

std::error_code ec;
fs::remove_all("build/", ec);
if (ec) { std::cerr << "remove_all: " << ec.message() << '\n'; }

Creating directories

cpp
// C++17
fs::create_directory("logs");           // creates one directory; fails if parent absent
fs::create_directories("logs/2026/06"); // creates entire chain — no error if already exists

// Check the return value: false means dir already existed (not an error)
bool created = fs::create_directories("out/reports");

File Status Queries

Existence, size, and modification time

cpp
// C++17
fs::path p = "data.bin";

bool exists  = fs::exists(p);            // follows symlinks
uintmax_t sz = fs::file_size(p);         // throws if p is a directory or absent

fs::file_time_type mtime = fs::last_write_time(p);

// Convert to wall clock (C++20 provides clock_cast)
auto sys = std::chrono::clock_cast<std::chrono::system_clock>(mtime);  // C++20
std::time_t t = std::chrono::system_clock::to_time_t(sys);

fs::status and fs::file_status

fs::status(p) issues one stat call and returns a fs::file_status object encapsulating both the file type and the permission bits. Use it when you need both, to avoid two separate syscalls.

cpp
// C++17
fs::file_status st = fs::status("report.pdf");

// File type
st.type() == fs::file_type::regular;
st.type() == fs::file_type::directory;
st.type() == fs::file_type::symlink;  // only via symlink_status()
st.type() == fs::file_type::not_found;

// Convenience predicates (same as calling fs::is_regular_file etc. with status)
fs::is_regular_file(st);
fs::is_directory(st);

// Permission bits
fs::perms perms = st.permissions();
bool readable = (perms & fs::perms::owner_read) != fs::perms::none;

Permissions

fs::permissions maps to chmod(2) on POSIX and SetFileAttributes / ACL APIs on Windows. The perm_options argument controls whether the given bits replace, are added to, or are removed from the current permissions.

cpp
// C++17

// Read current permissions
fs::file_status st = fs::status("secret.key");
fs::perms p = st.permissions();

bool owner_r = (p & fs::perms::owner_read)  != fs::perms::none;
bool owner_w = (p & fs::perms::owner_write) != fs::perms::none;
bool owner_x = (p & fs::perms::owner_exec)  != fs::perms::none;

// --- Replace: set exact bits ---
fs::permissions("secret.key",
    fs::perms::owner_read | fs::perms::owner_write,
    fs::perm_options::replace);   // equivalent to chmod 600

// --- Add: add execute bit without touching others ---
fs::permissions("script.sh",
    fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec,
    fs::perm_options::add);       // equivalent to chmod +x

// --- Remove: strip all write bits (make read-only) ---
fs::permissions("readme.txt",
    fs::perms::owner_write | fs::perms::group_write | fs::perms::others_write,
    fs::perm_options::remove);    // equivalent to chmod a-w

// --- Non-throwing form ---
std::error_code ec;
fs::permissions("log.txt", fs::perms::owner_read, fs::perm_options::replace, ec);
if (ec) { std::cerr << "chmod: " << ec.message() << '\n'; }

The full fs::perms bitmask mirrors the POSIX mode bits:

EnumeratorOctalMeaning
fs::perms::owner_read0400Owner read
fs::perms::owner_write0200Owner write
fs::perms::owner_exec0100Owner execute
fs::perms::group_read0040Group read
fs::perms::group_write0020Group write
fs::perms::group_exec0010Group execute
fs::perms::others_read0004Others read
fs::perms::others_write0002Others write
fs::perms::others_exec0001Others execute
fs::perms::set_uid04000Set-UID
fs::perms::set_gid02000Set-GID
fs::perms::sticky_bit01000Sticky bit

Temporary Files

fs::temp_directory_path() returns the OS-provided temporary directory (/tmp on POSIX, %TEMP% on Windows). C++17 provides no function to create a unique temporary file atomically — for that you need a platform call or a library.

cpp
// C++17 — a simple (non-atomic) temp path
fs::path tmp_dir  = fs::temp_directory_path();
fs::path tmp_file = tmp_dir / ("myapp_" + std::to_string(std::time(nullptr)) + ".tmp");

// WARNING: another process could create the same name between this line
// and when you open it. Fine for low-contention single-process tools;
// not safe for security-sensitive code.
std::ofstream out(tmp_file);

For robust, race-free temporary file creation, use the POSIX mkstemp fallback on POSIX platforms:

cpp
// POSIX fallback — C++17 has no mkstemp equivalent
#include <cstdlib>   // mkstemp
#include <unistd.h>  // close, write

fs::path make_temp_file() {
    fs::path tmpl = fs::temp_directory_path() / "myapp_XXXXXX";
    std::string s = tmpl.string();
    int fd = ::mkstemp(s.data());  // modifies s in-place, creates and opens atomically
    if (fd == -1) throw std::system_error(errno, std::system_category(), "mkstemp");
    ::close(fd);  // close the fd; caller opens via fs::path
    return fs::path(s);
}

On Windows, GetTempFileName provides similar semantics. A cross-platform option is to generate a UUID (e.g., via /dev/urandom or BCryptGenRandom) and append it to temp_directory_path().

Best Practice. Always delete your temporary files on exit — wrap the path in an RAII guard that calls fs::remove in its destructor, so cleanup happens even on exception.

cpp
// C++17 — RAII temp-file guard
struct TempFile {
    fs::path path;
    explicit TempFile(fs::path p) : path(std::move(p)) {}
    ~TempFile() { std::error_code ec; fs::remove(path, ec); /* ignore error */ }
    TempFile(const TempFile&) = delete;
    TempFile& operator=(const TempFile&) = delete;
};

fs::path and Encoding

fs::path stores its data in the native character type: wchar_t on Windows, char on POSIX. This difference is the source of a subtle portability trap.

cpp
// C++17

// On Windows, if the path contains non-ASCII characters (e.g. Chinese, Arabic,
// Russian filenames), path.string() encodes in the *current ANSI code page*.
// If the code page does not cover those characters, they are replaced with '?'.

fs::path p = fs::u8path(u8"/home/用户/文件.txt");  // construct from UTF-8

std::string narrow = p.string();    // DANGER on Windows: may lose information
std::string utf8   = p.u8string();  // SAFE: always UTF-8, preserves all characters

// On POSIX, path.string() returns the raw bytes — which is usually UTF-8 on
// modern systems, but depends on the locale. u8string() reinterprets those bytes
// as UTF-8 (no conversion), so it is safe on UTF-8 locales.

The distinction matters most when passing paths to logging, databases, or network APIs that expect UTF-8. On Windows, always use p.u8string() or p.wstring() — never p.string() — if the path may contain non-ASCII.

cpp
// C++17 — constructing a path from UTF-8 source text
std::string utf8_input = get_user_input();  // UTF-8 string from UI

#if defined(_WIN32)
    // fs::u8path converts UTF-8 → wchar_t internally on Windows
    fs::path p = fs::u8path(utf8_input);
#else
    fs::path p(utf8_input);  // POSIX: char is already the native type
#endif

// Or portably:
fs::path p2 = fs::u8path(utf8_input);  // correct on all platforms in C++17
// Note: fs::u8path is deprecated in C++20; use fs::path(u8"...") with u8 literals

Common Pitfall. Storing path.string() in a database or config file on Windows can silently corrupt filenames that contain characters outside the system ANSI code page. Store path.u8string() instead and reconstruct with fs::u8path.

Batch status queries via directory_entry

fs::directory_entry caches stat data. When iterating, prefer calling methods on the entry rather than free functions to avoid redundant syscalls.

cpp
std::error_code ec;
for (const fs::directory_entry& e :
        fs::recursive_directory_iterator("src", ec)) {
    if (ec) { /* permission denied on some subdir — continue */ ec.clear(); continue; }

    if (!e.is_regular_file()) continue;  // cached — no syscall
    if (e.path().extension() != ".cpp") continue;

    // file_size() on directory_entry uses cached value on most platforms
    std::println("{:>10} {}", e.file_size(), e.path().string());
}

Compare with calling fs::file_size(e.path()) inside the loop — that issues an extra stat per file.

Space Queries

cpp
fs::space_info si = fs::space("/home");  // C++17
std::println("capacity : {} GiB", si.capacity  / (1ULL << 30));
std::println("free     : {} GiB", si.free       / (1ULL << 30));
std::println("available: {} GiB", si.available  / (1ULL << 30));
// available ≤ free — accounts for reserved blocks (root on Linux)

Modification Time

cpp
fs::file_time_type mtime = fs::last_write_time("data.bin");  // C++17

// Convert to system_clock (C++20 — clock_cast)
auto sctp = std::chrono::clock_cast<std::chrono::system_clock>(mtime);  // C++20
std::time_t t = std::chrono::system_clock::to_time_t(sctp);
std::println("modified: {}", std::ctime(&t));

// In C++17 only, the conversion is implementation-defined:
// auto sys = decltype(mtime)::clock::to_sys(mtime);  // not portable pre-C++20

Best Practices

Use error_code overloads at system boundaries. Any path that crosses a process boundary (user input, config files, network mounts) can fail in ways that are expected — permission denied, stale NFS handles, deleted files between calls. Reserving exceptions for truly unexpected failures keeps error paths explicit.

Prefer directory_entry methods over free functions inside loops. e.file_size() reuses cached stat data; fs::file_size(e.path()) issues a fresh syscall. On directories with thousands of entries this is measurable.

Normalize before storing or comparing paths. Store p.lexically_normal() in configuration or databases. Raw paths with .. components or doubled separators break string-equality checks and produce confusing log output.

Create via a temporary, then rename. Writing directly to the target leaves a window where the file is partially written. Write to target.string() + ".tmp" and call fs::rename — on the same filesystem this is typically atomic on POSIX (POSIX guarantees rename(2) atomicity; Windows does not guarantee it but MoveFileExW(MOVEFILE_REPLACE_EXISTING) is close).

Handle fs::recursive_directory_iterator permission errors. By default the iterator throws on directories it cannot enter. Pass fs::directory_options::skip_permission_denied or use the error_code overload and clear it per-iteration.

cpp
for (const auto& e : fs::recursive_directory_iterator(
        root, fs::directory_options::skip_permission_denied)) {
    process(e);
}

Common Pitfalls

TOCTOU — check-then-use is a race condition

cpp
// WRONG — another process can delete the file between exists() and open()
if (fs::exists("config.json")) {
    std::ifstream f("config.json");  // may still fail
}

// RIGHT — open unconditionally and handle failure
std::ifstream f("config.json");
if (!f) { /* file absent or unreadable */ }

Even fs::exists(p) + fs::file_size(p) in sequence is a TOCTOU: the file may be deleted between the two calls. Whenever you need multiple facts about the same file, use a single fs::status(p) call and query the returned file_status object.

cpp
fs::path link = "current -> /data/v2";
fs::is_directory(link);                          // follows link → true if target is dir
fs::is_symlink(link);                            // false — status() resolves the link
fs::is_symlink(fs::symlink_status(link));        // true

Use symlink_status() whenever the goal is to inspect the link itself (e.g., cleaning up dangling symlinks).

Path comparison is not case-folded

On Linux, fs::path("File.txt") != fs::path("file.txt"). On Windows and macOS (default HFS+), the same paths may compare equal depending on the filesystem. Use fs::equivalent(p1, p2) to test whether two paths resolve to the same filesystem object — this issues a real stat and handles symlinks, hard links, and case differences correctly.

cpp
// Correct cross-platform identity check
std::error_code ec;
bool same = fs::equivalent("./foo", "/abs/path/to/foo", ec);

fs::file_size follows symlinks and returns the size of the target, not the link. There is no standard way to get the size of the symlink itself (POSIX lstat with st_size gives this, but it is rarely meaningful).

remove_all returns (uintmax_t)-1 on error in C++17

Prior to the C++20 defect fix (LWG 3014), fs::remove_all returns static_cast<uintmax_t>(-1) on error when using the throwing overload — but it still throws, so the return value is not observed. With the error_code overload always check ec rather than the return value.

path.string() is unsafe for non-ASCII on Windows

As described in the encoding section above: on Windows, path.string() encodes in the current ANSI code page. Filenames outside that code page are silently replaced with ?, leading to broken paths at runtime. Always use path.u8string() or path.wstring() for robust cross-platform code.

See Also