Before C++17, working with paths and directories meant using platform-specific APIs: POSIX (opendir, stat, unlink) on Linux and macOS, Win32 (FindFirstFile, CreateDirectory, DeleteFile) on Windows. The C++17 <filesystem> header changed that. It standardizes a single, portable API for paths, files, and directories. This chapter covers what's in it, how the path type works, the main operations (create, remove, copy, rename, iterate), and how error handling splits into throwing and non-throwing overloads.
The library lives in the <filesystem> header, inside the std::filesystem namespace. The namespace name is long enough that almost every codebase aliases it:
After that alias, the code reads fs::path, fs::exists, fs::create_directories, and so on. This chapter uses that convention throughout.
The library covers four broad capabilities:
| Capability | What it does | Main types or functions |
|---|---|---|
| Path manipulation | Build, decompose, compare, normalize paths | fs::path, / operator, stem, extension |
| Queries | Ask if a thing exists, what kind it is, how big | exists, is_regular_file, is_directory, file_size |
| Mutations | Create, remove, rename, copy files and directories | create_directories, remove, rename, copy_file |
| Iteration | Walk a directory, optionally recursively | directory_iterator, recursive_directory_iterator |
Two things matter up front. First, the library handles path separators automatically. The source code uses forward slashes ("orders/2026-01-15") and the library produces backslashes on Windows and forward slashes on POSIX systems when it interacts with the OS. Second, most functions come in two overloads: one that throws fs::filesystem_error on failure and one that takes an std::error_code& and reports failure through it.
fs::path is the central type. It represents a path, which may or may not refer to something that exists on disk. A path is a sequence of characters with structure, like an std::string with extra methods that understand directory separators and file extensions.
A path can be constructed from a string literal, an std::string, or by joining parts with the / operator. The / operator is the idiomatic way to build paths piece by piece.
The / operator inserts the platform-correct separator between parts. On Windows it produces backslashes; on POSIX it produces forward slashes. The separator never appears in source code, which is the point.
A fs::path can be broken into pieces. Each piece has a name and a method.
The diagram shows the same path decomposed five different ways. root_path() is the part that starts at the top (the leading / on POSIX, or C:\ on Windows). parent_path() is everything except the last component. filename() is the last component. stem() is the filename without its extension. extension() includes the leading dot.
The code that produces those values:
Each method returns a fs::path, not a string. To get a string, call .string() (returns std::string) or .u8string() (returns a UTF-8 std::string, useful for cross-platform code that handles non-ASCII filenames). Printing a fs::path directly with std::cout inserts quotes around it, which is sometimes desirable and sometimes not. Calling .string() gives bare text.
A path can be absolute (starts at the root, like /var/orders/...) or relative (resolved against the current directory, like orders/...). The library doesn't care which kind gets built; both work the same way until handed to an OS-touching function, at which point a relative path is resolved against the process's current directory.
A path can also be converted back to a string in a few ways:
For pure ASCII filenames the two are identical. For paths containing non-ASCII characters, u8string() is the portable choice because it's always UTF-8, regardless of the OS's native encoding.
Before reading from or writing to a file, code often needs to check whether it's there and what kind of thing it is. The library has a small set of query functions for this.
exists is the broad question: "is there anything at this path?" That includes regular files, directories, symbolic links, devices, sockets, and so on. The is_* family is more specific.
A subtle point: is_regular_file(p) returns false if p doesn't exist, but it also returns false if p is a directory or a symlink. It's a positive test for one specific kind of thing. For "is this a file the program can open and read bytes from", is_regular_file is the right call.
file_size returns the size in bytes as a std::uintmax_t. Calling it on a directory or a non-existent path throws fs::filesystem_error. The non-throwing overload appears later.
last_write_time returns when the file was last modified. The type is a fs::file_time_type, which is a std::chrono::time_point from a special clock (fs::file_time_type::clock). Converting it to a human-readable string takes more work than expected:
The output's actual number depends on when the program runs. The point is that the conversion takes a few lines of chrono plumbing. In C++20 the standard added std::chrono::clock_cast which makes this conversion cleaner, but for C++17 the cast above is the portable pattern.
There are two functions for creating directories, and the difference matters more than the names suggest.
fs::create_directory(p) creates a single directory at p. It fails (returns false) if p already exists, and it throws if the parent of p doesn't exist.
fs::create_directories(p) (note the plural) creates every missing directory along the path, like mkdir -p. It returns true if any new directories were created, and false if everything was already there. It does not throw when the directory already exists.
When in doubt, prefer create_directories. It does the right thing whether the path exists or not, and it handles the common case of "make sure this directory hierarchy is in place" in one call. The only reason to use create_directory is when the code needs to detect "this already exists" and the parent is guaranteed to be there.
Both functions touch the filesystem, which is orders of magnitude slower than in-memory operations. Creating thousands of directories in a tight loop is fine because each call is independent, but neither should appear in a hot inner loop unnecessarily.
The library has two removal functions, and the distinction follows the same single-versus-recursive pattern.
fs::remove(p) deletes a single file or an empty directory. It returns true if something was deleted, false if p didn't exist. Trying to remove a non-empty directory throws.
fs::remove_all(p) deletes p and everything inside it if p is a directory. It returns the number of files and directories it deleted, as a std::uintmax_t.
The count from remove_all includes both files and directories. In this example: b.txt, sub/c.txt, sub/, and fs-demo-remove/ add up to four. a.txt is missing from the count because the earlier remove already deleted it.
remove_all walks the directory tree and deletes one entry at a time. For a directory with thousands of files, that's thousands of system calls. It's still much faster than spawning a shell to run rm -rf, but "remove a directory tree" is not a single OS primitive on most systems.
fs::rename(from, to) does both renaming (change the filename) and moving (change the directory) in one call. The function takes two paths and atomically renames the source to the destination.
rename is the usual way to move a file because, on the same filesystem, the OS does it without copying any bytes. It updates the directory entry.
There's an important caveat. rename only works within a single filesystem. If from is on one mount point and to is on another (for example, moving a file from /home to a USB drive mounted at /mnt/usb), the call fails with EXDEV on POSIX or its Windows equivalent. The fix in that case is fs::copy_file followed by fs::remove. The library doesn't do this fallback automatically, because the cost (copying potentially gigabytes) is too large to hide behind an innocent-looking name.
If the destination already exists, behavior depends on the OS. On POSIX, rename overwrites the destination without warning. On Windows it fails. The library exposes whatever the OS does, so for consistent behavior, check fs::exists(to) first and decide explicitly.
Copying is richer than the other operations because there are more knobs: overwrite or not, recursive or not, copy symlinks or follow them. The library exposes the options through a fs::copy_options enum that acts as a bitmask.
The simplest function is fs::copy_file(from, to). It copies a single regular file. If to already exists, it throws by default. To overwrite, pass fs::copy_options::overwrite_existing.
For copying a whole directory tree, use fs::copy(from, to, options). The options that matter most are:
| Option | Effect |
|---|---|
copy_options::none (default) | Copies regular files. Skips directories unless explicitly handled. |
copy_options::recursive | Recurses into subdirectories. Without this, only top-level files are copied. |
copy_options::overwrite_existing | Overwrites existing files at the destination. |
copy_options::skip_existing | Skips files that already exist at the destination. |
copy_options::update_existing | Overwrites only if the source is newer than the destination. |
copy_options::copy_symlinks | Copies symlinks as symlinks (don't follow them). |
copy_options::directories_only | Copies the directory structure but not the file contents. |
Combine options with |, like other bitmask enums.
Without recursive, the call would copy a and b as empty directories and skip their contents. That's rarely the desired result, so copy_options::recursive | copy_options::overwrite_existing is the standard combination for "make this destination look like the source."
fs::copy reads and writes every byte for every file. For large trees it's slower than rename (which is a metadata-only operation when source and destination are on the same filesystem). Use rename when moving, copy only when two copies of the data are needed.
The library provides two iterator types for walking a directory.
fs::directory_iterator(p) yields each entry in p one level deep. It doesn't recurse into subdirectories.
fs::recursive_directory_iterator(p) yields every entry under p, depth-first.
Both work cleanly with range-based for loops because they expose begin() and end() through the standard non-member machinery.
The names are collected into a vector and sorted because the order in which directory_iterator returns entries is not specified by the standard. Different filesystems give different orders (some alphabetical, some by inode creation time, some by hash). For deterministic output, sort.
For a recursive walk, the loop is structurally the same:
Filtering by extension is one of the most common operations during iteration. entry.path().extension() returns a fs::path (not a string), which compares equal to the literal ".txt" because path has overloads for string comparison.
The entry from these iterators is a fs::directory_entry. It caches information about the file (size, type, modification time), so calls like entry.is_regular_file() are essentially free after the first one. That's a useful optimization when the underlying filesystem is slow (network mounts, encrypted filesystems).
Every process has a current working directory. The library exposes two overloads of fs::current_path: the no-argument form reads it, and the one-argument form sets it.
The exact output depends on where the program runs from, so only the filename portion of the current path is printed to keep the demo reproducible.
Changing the current path is a global, process-wide effect. Code anywhere else in the process that takes a relative path will now resolve it differently. That's a strong reason to avoid fs::current_path(p) in libraries and multi-threaded code; prefer working with absolute paths and let the caller manage the working directory. When changing it is necessary, save the original first and restore it on the way out, as the example does.
Most filesystem functions come in two overloads.
The first overload throws fs::filesystem_error on failure. The examples above use this form.
The second overload takes a trailing std::error_code& parameter. On failure, it fills the error code and returns instead of throwing. The signature looks like this:
The noexcept here matters. Functions in the error-code form are guaranteed not to throw, which makes them safer to call from destructors, noexcept functions, and other places where exceptions would be a problem.
The same query in both forms:
Which form to use? The general guidance:
noexcept context.A tempting third option is the throwing form wrapped in try/catch everywhere. That works but it's noisy and slow when failures are routine, because constructing and throwing an exception is much more expensive than checking an error code.
Throwing an exception costs roughly a few microseconds on a modern x86 chip. That's nothing for a one-off failure, but for iterating over a directory tree and checking each file, the error-code form is the right choice.
The error-code form also allows continuing after a failure, which can be useful when iterating. The iterator constructors themselves accept an std::error_code&, and the code can choose to skip-on-error rather than abort.
A small program puts everything together. Given a list of orders, each tagged with a date and an ID, the program creates orders/YYYY-MM-DD/ directories and writes one file per order into the matching day's folder. Then it walks the tree and prints what it produced.
A few things to note about this code:
create_directories is called for every order. It's idempotent, so calling it after the directory already exists is cheap and safe. There's no need to track which dates have been seen./ operator builds paths cleanly: root / o.date / filename. No manual string concatenation, no platform-specific separators.std::ofstream takes it directly because the <fstream> types accept fs::path since C++17. (Earlier, the path needed .string() or .c_str().) A constructed path can be handed to std::ofstream as covered in the File Writing chapter.recursive_directory_iterator does not guarantee any order.That's about thirty lines of code, all portable, and the pre-C++17 equivalent would require POSIX or Win32 code.
The library handles separators automatically. Source code uses forward slashes, and the library either keeps them (POSIX) or converts to backslashes (Windows) when talking to the OS. Comparison of paths happens in normalized form, so "a/b" and "a\\b" compare equal on Windows and unequal on POSIX. The cleanest habit is to write forward slashes consistently and let the library worry about the rest.
A few real-world details to be aware of:
"FILE.TXT" and "file.txt" are different paths even if they'd refer to the same file on a case-insensitive macOS filesystem. The library doesn't know the filesystem type; it just compares the strings.fs::path stores the native form. Use u8string() to interoperate across platforms or write paths to text files.<filesystem> lives in a separate static library, requiring the link flag -lstdc++fs: g++ -std=c++17 file.cpp -o file -lstdc++fs. From GCC 9 onward the flag is no longer needed; the implementation is in the main standard library. Clang has a similar history with -lc++fs. A link error mentioning std::filesystem symbols is usually this cause.That covers the practical side. The library is one of the cleanest additions C++17 made, and any code that used to ship with platform-specific path-handling can usually delete those branches in favor of <filesystem>.
A condensed reference for the main functions and what they return.
| Function | Returns | Notes |
|---|---|---|
path(s) | path | Construct from a string or another path |
p / q | path | Join two paths with the platform separator |
p.root_path() | path | The root portion (e.g., / on POSIX) |
p.parent_path() | path | Everything except the last component |
p.filename() | path | The last component |
p.stem() | path | Filename without extension |
p.extension() | path | Extension including the leading dot |
p.string() | std::string | Native-encoding string |
p.u8string() | std::string | UTF-8 string |
exists(p) | bool | True if anything is at p |
is_regular_file(p) | bool | True only for regular files |
is_directory(p) | bool | True only for directories |
is_symlink(p) | bool | True only for symbolic links |
file_size(p) | uintmax_t | Size in bytes; throws if p isn't a file |
last_write_time(p) | file_time_type | Last modification time |
create_directory(p) | bool | Creates one directory; parent must exist |
create_directories(p) | bool | Creates every missing directory in the path |
remove(p) | bool | Removes a file or empty directory |
remove_all(p) | uintmax_t | Removes a tree; returns count deleted |
rename(from, to) | void | Same-filesystem move or rename |
copy_file(from, to, opts) | bool | Copy a single regular file |
copy(from, to, opts) | void | Copy a file, link, or directory (with options) |
directory_iterator(p) | iterator | Walks p one level deep |
recursive_directory_iterator(p) | iterator | Walks the tree under p |
current_path() | path | Reads the process's current directory |
current_path(p) | void | Sets the process's current directory |
Every function with (p) here also has an overload that takes a trailing std::error_code& and is noexcept.
10 quizzes