C++ std::filesystem File Status: file_status, symlink_status, perms, and Timestamps
Key takeaways
One fs::status call returns a file_status holding the file type and permission bits; status follows symlinks and symlink_status does not. This guide covers type and permission checks, converting last_write_time, error_code overloads for bulk jobs, and why Windows perms only reflect the read-only attribute.
What is File Status?
“Does this file exist?” is rarely the whole question. A backup tool needs to know whether a path is a regular file, a directory or a symbolic link; a log cleaner needs modification times; an installer needs to know whether it can write. C++17’s std::filesystem answers all of these from one metadata query, and std::filesystem::file_status is the value that holds the answer: a file type plus a permission mask.
The main thing to understand before the examples is that each call such as fs::is_directory(p) or fs::file_size(p) performs its own system call (stat on POSIX, GetFileAttributesEx and friends on Windows). Calling five predicates on one path means five trips to the OS, and the file can change between them. Querying fs::status(p) once and asking the returned file_status is both cheaper and more consistent.
#include <filesystem>
#include <iostream>
// Namespace alias
namespace fs = std::filesystem;
fs::path p = "file.txt";
auto status = fs::status(p);
if (status.type() == fs::file_type::regular) {
std::cout << "Regular file" << std::endl;
}
File Type
fs::file_type type = fs::status(p).type();
// Type check
fs::is_regular_file(p);
fs::is_directory(p);
fs::is_symlink(p);
fs::is_block_file(p);
fs::is_character_file(p);
fs::is_fifo(p);
fs::is_socket(p);
All of these is_*(path) overloads except is_symlink follow symbolic links, and all of them throw fs::filesystem_error on an OS error other than “not found”. A missing path is not an error for them: they simply return false. Each also has an overload taking std::error_code& that never throws, and an overload taking a file_status that does no I/O at all.
Block devices, character devices, FIFOs and sockets are POSIX concepts (/dev/sda, /dev/null, named pipes, Unix-domain sockets). On Windows these checks almost always return false. They matter mostly for tools that walk /dev or /proc, or that must not try to read a FIFO, which would block forever waiting for a writer.
Practical Examples
Example 1: File Information
void printFileInfo(const fs::path& p) {
if (!fs::exists(p)) {
std::cout << "File not found" << std::endl;
return;
}
auto status = fs::symlink_status(p); // don't follow links, so we can see them
std::cout << "Path: " << p << std::endl;
std::cout << "Type: ";
if (fs::is_regular_file(status)) {
std::cout << "Regular file" << std::endl;
std::cout << "Size: " << fs::file_size(p) << " bytes" << std::endl;
} else if (fs::is_directory(status)) {
std::cout << "Directory" << std::endl;
} else if (fs::is_symlink(status)) {
std::cout << "Symbolic link" << std::endl;
}
}
This function uses symlink_status, not status. With fs::status, the symlink branch could never run: status follows the link and reports the type of the target, so a link to a file is reported as a regular file. It is an easy mistake because the code compiles and simply never prints “Symbolic link”. Note also that fs::exists(p) follows links too, so a broken symlink makes this function print “File not found” even though the link itself exists; if that case matters, test fs::exists(fs::symlink_status(p)) instead.
p is printed with quotes ("file.txt") because operator<< for fs::path uses std::quoted. Use p.string() if you want the bare text.
Example 2: Modified Time
void printModifiedTime(const fs::path& p) {
auto ftime = fs::last_write_time(p);
// C++17 approximation: shift by the offset between the two clocks' "now"
// (C++20: std::chrono::clock_cast<std::chrono::system_clock>(ftime))
auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
ftime - fs::file_time_type::clock::now() +
std::chrono::system_clock::now()
);
std::time_t cftime = std::chrono::system_clock::to_time_t(sctp);
std::cout << "Modified: " << std::ctime(&cftime);
}
The awkward conversion exists because in C++17 file_time_type uses an implementation-defined clock that is not guaranteed to be system_clock. On MSVC it counts from 1601 (the Windows FILETIME epoch), while system_clock counts from 1970, so a direct cast gives dates centuries off. The trick above measures both clocks’ “now” and shifts by the difference, which is accurate to within the tiny gap between the two now() calls. C++20 fixed this properly with std::chrono::file_clock and clock_cast, and with C++20 <format> you can print the result directly with std::format("{:%F %T}", ...).
Example 3: Permission Check
void checkPermissions(const fs::path& p) {
auto perms = fs::status(p).permissions();
std::cout << "Permissions: ";
if ((perms & fs::perms::owner_read) != fs::perms::none) {
std::cout << "r";
}
if ((perms & fs::perms::owner_write) != fs::perms::none) {
std::cout << "w";
}
if ((perms & fs::perms::owner_exec) != fs::perms::none) {
std::cout << "x";
}
std::cout << std::endl;
}
Example 4: Space Information
void printSpaceInfo(const fs::path& p) {
auto space = fs::space(p);
std::cout << "Capacity: " << space.capacity << " bytes" << std::endl;
std::cout << "Free: " << space.free << " bytes" << std::endl;
std::cout << "Available: " << space.available << " bytes" << std::endl;
}
free and available differ on purpose. On Linux ext4, a percentage of blocks is typically reserved for root, so free includes that reserve while available is what an unprivileged process can actually use. Decisions like “is there room to write this backup?” should use available. The numbers describe the whole volume containing p, not the directory itself.
Permission Setting
fs::path p = "file.txt";
// Add permission
fs::permissions(p, fs::perms::owner_write,
fs::perm_options::add);
// Remove permission
fs::permissions(p, fs::perms::owner_write,
fs::perm_options::remove);
// Set permission
fs::permissions(p, fs::perms::owner_all);
The default option is perm_options::replace, so the last call sets the mode to exactly 0700 and removes every group and other bit that was there, which is rarely what “give the owner full access” means. Use add or remove when you want to change individual bits. On a symbolic link, permissions changes the target unless you also pass perm_options::nofollow (which many Linux systems do not support for links).
On Windows the call can only toggle the read-only attribute: removing all write bits sets it, adding any write bit clears it. Execute, group and other bits have no effect, and ACLs are untouched.
file_status and perms
std::filesystem::file_status is a value containing single query result. Mainly bundles two things:
type()→file_type:regular,directory,symlink,not_found, etc. State not following symbolic link is obtained withsymlink_status, followed result withstatuspattern is frequently used.permissions()→perms: Read/write/execute bits for owner, group, others. Check “set or not” withnoneand bit AND.
fs::file_status st = fs::status(p, ec);
if (!ec && st.type() != fs::file_type::not_found) {
auto pm = st.permissions();
bool owner_read = (pm & fs::perms::owner_read) != fs::perms::none;
}
status(p) queries symbolic link’s final target, use symlink_status(p) if link type itself is needed. Broken link can make status not_found, so distinguishing link first with symlink_status is advantageous for debugging.
File Type Check (Summary)
file_type also distinguishes “lack of information” states like unknown, none, not_found. In practice, usually use convenience functions together.
| Purpose | Recommended API |
|---|---|
| Regular file check | fs::is_regular_file(st) or is_regular_file(p) |
| Directory check | fs::is_directory(st) |
| Symbolic link check | fs::is_symlink(st) — based on symlink_status |
| Block/character device etc | is_block_file, is_character_file, is_fifo, is_socket |
auto st = fs::status(path);
if (fs::is_regular_file(st)) { /* … */ }
else if (fs::is_directory(st)) { /* … */ }
auto lst = fs::symlink_status(path);
if (fs::is_symlink(lst)) {
auto target_st = fs::status(path); // Target following link
}
Note: If path doesn’t exist, type() is not_found and exists(path) is false. is_regular_file returns false if doesn’t exist, so to distinguish “missing file” from “exists but directory”, safer to see status and file_type together.
Permission Check
perms is as much as possible abstraction of POSIX-style bit mask. OS meaning like execute permission on regular file, directory execute bit (allow search) should be confirmed in documentation and actual environment.
bool can_owner_write(const fs::path& p, std::error_code& ec) {
auto st = fs::status(p, ec);
if (ec) return false;
auto pm = st.permissions();
return (pm & fs::perms::owner_write) != fs::perms::none;
}
// Print owner rwx in one line (POSIX style)
void print_owner_rwx(fs::perms pm) {
char r = (pm & fs::perms::owner_read) != fs::perms::none ? 'r' : '-';
char w = (pm & fs::perms::owner_write) != fs::perms::none ? 'w' : '-';
char x = (pm & fs::perms::owner_exec) != fs::perms::none ? 'x' : '-';
std::cout << r << w << x;
}
Check before write example:
std::error_code ec;
if (can_owner_write(path, ec)) {
// Overwrite etc
}
Windows: Read-only attribute and ACL may not correspond 1:1 with filesystem’s perms. For cross-platform tools, handling actual open/ofstream failure is often more reliable than “retry permissions on failure”.
Even on POSIX, can_owner_write answers a narrower question than it seems. It checks the owner bit, but the running process may not be the owner; it may be in the group, or be root, which ignores the bits entirely. Read-only mounts, ACLs, SELinux and a full disk are all invisible in perms. And the result can be stale by the time you open the file (the time-of-check to time-of-use problem). I treat permission bits as a hint for user-facing messages, and let the actual open decide. If the open fails, errno / the std::error_code from the stream or OS call tells you why, which is what you want to report anyway.
Practice: Backup Script (Concept)
Backup like “copy only files modified in recent N days” selects targets with last_write_time and file_status. Large trees combine with recursive_directory_iterator.
// Namespace alias
namespace fs = std::filesystem;
using file_clock_t = fs::file_time_type::clock; // don't name this "clock": it clashes with ::clock()
bool needs_backup(const fs::path& p,
fs::file_time_type cutoff,
std::error_code& ec) {
auto st = fs::status(p, ec);
if (ec || !fs::is_regular_file(st)) return false;
auto mtime = fs::last_write_time(p, ec);
if (ec) return false;
return mtime >= cutoff;
}
cutoff is a point in time such as “now minus 7 days”, expressed in the same clock: auto cutoff = file_clock_t::now() - std::chrono::hours(24 * 7);. Building it from file_time_type::clock directly avoids the conversion problem from Example 2, because both sides of the comparison use the same epoch. When copying, fs::equivalent can detect two paths that are the same file (hard links, or a path reached through a symlink) so it is not copied twice.
The function makes two system calls per file (status then last_write_time). When it runs inside a directory walk, prefer the directory_entry members (entry.is_regular_file(), entry.last_write_time()); on most implementations the directory iteration already fetched some of that data, so the members can avoid extra calls. Modification time is also an imperfect signal for backups: tools that preserve timestamps (cp -p, archive extraction) can produce files that are new to the system but old by mtime.
Practice: Log Management (Rotation·Cleanup)
Old log deletion is decided by regular file + modified time. In C++17, file_time_type and clock conversion differ by implementation, so safer to make one reference time as file_time_type and compare last_write_time for each item.
void prune_old_logs(const fs::path& log_dir,
fs::file_time_type cutoff,
std::error_code& ec) {
for (const auto& e : fs::directory_iterator(log_dir, ec)) {
std::error_code fec; // per-file errors must not end the loop
if (!e.is_regular_file(fec)) continue;
auto mt = fs::last_write_time(e.path(), fec);
if (fec) continue;
if (mt < cutoff) {
fs::remove(e.path(), fec);
}
}
}
// cutoff must be value made with same clock aligned to "7 days ago from now" etc.
// With C++20 `std::chrono::file_clock`, conversion and comparison become clearer.
The per-file fec fixes a subtle bug in the obvious version of this loop. If the same ec is used for the directory iterator and for last_write_time, one unreadable file sets ec, and an if (ec) break; at the top of the next iteration then stops the whole cleanup, silently leaving every later log in place. Keeping the constructor’s ec for “could not open the directory” and a fresh error code for each file keeps the two failure kinds apart. Note that the range-for still uses the iterator’s throwing operator++; if increment errors matter, write the loop with it.increment(ec) explicitly.
Deleting the entry you are currently visiting is fine with directory_iterator. Whether files created or deleted by other processes during the walk show up is unspecified, so a log writer rotating files at the same moment can cause an occasional not_found from remove, which the loop tolerates.
For a size limit rather than an age limit, check the volume with fs::space as shown earlier, collect the log files with their times, sort oldest first, and delete until available is back above your threshold.
Platform Differences
| Topic | POSIX (Linux, macOS) | Windows |
|---|---|---|
| Permission bits | Traditional rwx, model similar to chmod | perms is simplified·emulated; ACL·attributes separate |
| Path separator | / | Both \ and / allowed but display often \ |
| Symbolic link | Widely used | Environment dependent like admin rights·developer mode |
| Case sensitivity | Usually sensitive | Basically case-insensitive in path comparison |
Practice recommendation: Cross-platform code uses fs::path operations, gets hint from perms for permissions but supplements with final I/O failure handling. Deployment scripts can branch permissions call by OS or limit to documentation.
Common Issues
Issue 1: Existence Check
// ❌ Without existence check
auto size = fs::file_size("file.txt"); // Exception
// ✅ With existence check
if (fs::exists("file.txt")) {
auto size = fs::file_size("file.txt");
}
// ✅✅ No race: let the call report the error
std::error_code ec;
auto sz = fs::file_size("file.txt", ec);
if (ec) { /* ec.message(): "No such file or directory" etc. */ }
The thrown exception is fs::filesystem_error, whose what() includes the path, for example filesystem error: cannot get file size: No such file or directory [file.txt] with libstdc++. The exists check narrows the window but does not close it: the file can be deleted between the two calls. The error_code overload handles that case and also saves a system call. On failure it returns static_cast<std::uintmax_t>(-1), so always test ec rather than the returned size.
Issue 2: Directory Size
// ❌ file_size on directory
// auto size = fs::file_size("dir"); // Exception
// ✅ Recursive calculation
uintmax_t size = 0;
for (const auto& entry : fs::recursive_directory_iterator("dir")) {
if (entry.is_regular_file()) {
size += entry.file_size();
}
}
This sum is the total of file lengths, not the space used on disk, so it will not match du for sparse files or small files that each occupy a full block. recursive_directory_iterator does not follow directory symlinks by default, which avoids cycles; if you pass directory_options::follow_directory_symlink, a link pointing to a parent directory makes the walk loop until the path becomes too long. Hard-linked files are counted once per link.
Issue 3: Symbolic Link
fs::path link = "symlink";
// Link itself status
auto linkStatus = fs::symlink_status(link);
// Link target status
auto targetStatus = fs::status(link);
Issue 4: Permission Error
// ❌ Exception if no permission
for (const auto& entry : fs::recursive_directory_iterator("/")) {
// Permission error
}
// ✅ Ignore error
for (const auto& entry : fs::recursive_directory_iterator("/",
fs::directory_options::skip_permission_denied)) {
std::cout << entry.path() << std::endl;
}
Walking / on Linux without the option throws filesystem_error with “Permission denied” as soon as it reaches a directory such as /root, and because the exception comes from operator++, the loop simply ends there. skip_permission_denied only covers directories you cannot open; other errors, such as a directory disappearing during the walk, still throw. Walking / also enters /proc and /sys, whose files report sizes of 0 or 4096 that have nothing to do with their contents.
File Comparison
fs::path p1 = "file1.txt";
fs::path p2 = "file2.txt";
// Same file?
if (fs::equivalent(p1, p2)) {
std::cout << "Same file" << std::endl;
}
// Compare modified time
if (fs::last_write_time(p1) > fs::last_write_time(p2)) {
std::cout << "p1 is newer" << std::endl;
}
equivalent compares device and inode (or the Windows file ID), not names or contents: two identical copies are not equivalent, while a.txt and a hard link to it are. It reports an error if neither path exists. Comparing last_write_time works because both values come from the same clock, but timestamp resolution varies by filesystem (FAT stores modification times in 2-second steps), so two files written in quick succession can compare equal.
FAQ
Q1: Why does is_symlink(fs::status(p)) always return false?
A: status follows the link and describes the target. Use fs::symlink_status(p) (or fs::is_symlink(p), which uses it internally) to see the link itself.
Q2: Should I use the throwing overloads or the error_code overloads?
A: Throwing overloads are fine for one-off operations where failure is exceptional. In loops over many files, and whenever a missing file is a normal outcome, use std::error_code overloads so one bad entry does not abort the whole job, and use a fresh error code per file.
Q3: How do I compare a file’s modification time with “7 days ago”?
A: Build the cutoff from the same clock: fs::file_time_type::clock::now() - std::chrono::hours(24 * 7). Converting between file_time_type and system_clock is only needed for display; in C++20 use std::chrono::clock_cast.
Q4: Why do permissions look wrong on Windows?
A: The Windows implementations map only the read-only attribute into perms: a writable file typically reports all write bits, a read-only one reports none. Real access is controlled by ACLs, which std::filesystem does not expose.
Q5: Is checking exists before opening a file enough?
A: No. The file can change between the check and the open. Open the file and handle the failure; use exists only for decisions where a stale answer is harmless.