#085 – Using std::filesystem for cross-platform directory logic

Listing the files in a directory is an embarrassingly common operation that, until C++17, had no cross-platform answer in the standard library. Programs that needed it reached for POSIX’s <dirent.h> on Unix, the Win32 FindFirstFile family on Windows, and a tangle of #ifdef _WIN32 macros to bridge the two. Path manipulation was worse — the directory separator alone (/ vs \), the encoding (UTF-8 vs UTF-16), and the case-sensitivity rules all forked along OS lines.

C++17’s <filesystem>, standardised from boost::filesystem, collapses all of that into one library that compiles identically across platforms and produces correct behaviour on each. This Nibble walks through the parts of the API that actually pay off when you’re writing portable directory logic.

Three things to take away:

  • std::filesystem::path is the vocabulary type. Build paths with the / operator; it inserts the platform’s preferred separator and avoids string concatenation entirely.
  • Directory iteration is one for-loop over directory_iterator (single level) or recursive_directory_iterator (whole subtree). Both work with range-based for and standard algorithms.
  • Every operation has two overloads: a throwing one and one that takes a std::error_code&. Reach for the error-code form when failures are part of normal control flow.

The pre-C++17 pain

Before <filesystem>, listing files in a directory looked like this on Unix:

#include <dirent.h>

DIR* dir = opendir("/tmp");
if (!dir) { /* error */ }
while (dirent* entry = readdir(dir)) {
    // entry->d_name
}
closedir(dir);

And like this on Windows:

#include <windows.h>

WIN32_FIND_DATA data;
HANDLE h = FindFirstFile(L"C:\\tmp\\*", &data);
if (h == INVALID_HANDLE_VALUE) { /* error */ }
do {
    // data.cFileName  (wchar_t* on Windows)
} while (FindNextFile(h, &data));
FindClose(h);

Different APIs, different types, different error conventions, different string encodings. A real-world cross-platform codebase wrapped both behind a #ifdef-driven abstraction — which had to be written, debugged, and maintained per project. <filesystem> retires that whole category of glue code.

path: one type, one set of operations

The vocabulary type is std::filesystem::path. It represents a filesystem path as a sequence of components, abstracted from the platform’s native string type. Construction accepts string literals, std::stringstd::wstring, and other path objects:

#include <filesystem>
namespace fs = std::filesystem;

fs::path config = "/etc/myapp/config.toml";
fs::path windows_path = R"(C:\Users\alice\Documents)";
fs::path mixed = "data/cache/items.bin";   // forward slashes work everywhere

The standard recommends forward slashes for portability — they’re the generic format every implementation accepts. Backslashes are platform-specific (legal on Windows, ambiguous elsewhere) and should be avoided in source unless you explicitly mean a Windows-only path.

The single most useful operator on path is /, which joins two path components with the right separator:

fs::path home = std::getenv("HOME") ? std::getenv("HOME") : "/";
fs::path config_dir = home / ".config" / "myapp";
fs::path config_file = config_dir / "settings.json";

On Linux, config_file becomes /home/alice/.config/myapp/settings.json. On Windows (with home set to C:\Users\alice), it becomes C:\Users\alice\.config\myapp\settings.json. The same source code, the same output structure, the right separator for each platform. No string concatenation, no #ifdef, no separator strings.

The class’s accessors decompose paths into their parts:

fs::path p = "/usr/local/bin/clang++";
p.parent_path();   // "/usr/local/bin"
p.filename();      // "clang++"
p.stem();          // "clang++"
p.extension();     // ""

fs::path doc = "report.final.pdf";
doc.stem();        // "report.final"
doc.extension();   // ".pdf"

These accessors do the right thing across platforms and don’t require you to find the last / (or \, or both) by hand.

Directory iteration

Walking a directory is one range-based for loop over directory_iterator:

for (const fs::directory_entry& entry : fs::directory_iterator{ "/tmp" }) {
    if (entry.is_regular_file()) {
        std::cout << entry.path().filename() << '\n';
    }
}

directory_entry is a small object that caches metadata about each entry — its path, whether it’s a file/directory/symlink, its size, its last-write time. The fields are queried lazily on most implementations, so iterating without inspecting metadata is cheap.

For a whole subtree, swap in recursive_directory_iterator:

for (const auto& entry : fs::recursive_directory_iterator{ "/var/log" }) {
    if (entry.is_regular_file() && entry.path().extension() == ".log") {
        std::cout << entry.path() << ": " << entry.file_size() << " bytes\n";
    }
}

Both iterators model std::ranges::input_range, so they compose with ranges algorithms (Nibble #085) and views::filter:

auto cpp_files = fs::recursive_directory_iterator{ project_dir }
    | std::views::filter([](const auto& e) {
        return e.is_regular_file() && e.path().extension() == ".cpp";
      });

for (const auto& entry : cpp_files) {
    process(entry.path());
}

This is the modern idiom: iterator construction does the OS calls; the for-loop and any filter views work entirely in C++.

File system operations

The rest of the API covers the standard operations you’d expect, with cross-platform names:

fs::create_directories(home / ".cache" / "myapp");   // mkdir -p
fs::remove(temp_file);                               // rm
fs::remove_all(scratch_dir);                         // rm -rf

fs::copy(source, dest);                              // cp -r when source is a dir
fs::rename(old_name, new_name);                      // mv

if (fs::exists(p) && fs::is_directory(p)) { /* ... */ }

auto when = fs::last_write_time(p);
auto size = fs::file_size(p);

fs::path tmp = fs::temp_directory_path();            // /tmp, %TEMP%, etc.
fs::path here = fs::current_path();                  // getcwd / GetCurrentDirectory

Each function does the right thing per platform. temp_directory_path returns /tmp on Linux, C:\Users\<user>\AppData\Local\Temp on Windows, /var/folders/... on macOS — all without you checking which OS you’re on.

Error handling: two overloads, two strategies

Every filesystem operation has two overloads. The first throws std::filesystem::filesystem_error (a derived std::system_error) on any failure:

try {
    fs::create_directory("/some/path");
} catch (const fs::filesystem_error& e) {
    std::cerr << e.what() << " on " << e.path1() << '\n';
}

The second takes a std::error_code& out-parameter and never throws:

std::error_code ec;
fs::create_directory("/some/path", ec);
if (ec) {
    std::cerr << "create failed: " << ec.message() << '\n';
}

Choose the throwing form when failure is exceptional (“we absolutely have permission to create this directory; if we don’t, that’s a bug”). Choose the error-code form when failure is expected control flow (“create the directory if it doesn’t exist; check if it’s already there”). The pair mirrors the broader C++ pattern of throwing-vs-explicit error propagation (Nibble #078) — <filesystem> is one of the cleanest examples in the standard library.

A few subtleties worth knowing

A handful of things still differ across platforms even with <filesystem> smoothing the rough edges:

  • Case sensitivity is filesystem-dependent. Linux is case-sensitive by default; Windows and macOS (HFS+/APFS) are case-insensitive. Two paths that compare unequal as strings may refer to the same file. If you need true equality, use fs::equivalent(p1, p2), which checks whether two paths resolve to the same file system entity.
  • Unicode on Windows. Windows paths are natively UTF-16. path accepts UTF-8 input and converts internally; on most modern systems this works transparently. Mixing locale- dependent narrow strings with the path class can produce encoding bugs that don’t surface until non-ASCII filenames appear.
  • Normalization. lexically_normal() returns a path with . and .. resolved syntactically. canonical() does the same but also resolves symlinks and requires the path to exist. Use the right one depending on whether you’re cleaning up user input or asking the OS for the real path.
  • TOCTOU races. if (fs::exists(p)) fs::remove(p); is racey — between the two calls, another process can create or remove the file. The error-code overloads of fs::remove already handle non-existence gracefully; prefer attempt-and-handle over check-then-act for any code that might run concurrently with other filesystem activity.

Takeaway

<filesystem> is the C++17 standardisation of the path, directory, and file-metadata operations every project used to re-implement against POSIX or Win32. Build paths with path and the / operator instead of string concatenation; iterate directories with directory_iterator (one level) or recursive_directory_iterator (whole subtree); query and mutate the file system with the namespace’s free functions; choose between the throwing and error_code-returning overload based on whether failure is exceptional or expected.

The rule is simple: when your program touches files, paths, or directories, the work has been done — reach for std::filesystem and stop writing platform-specific glue.