Working with the File System in C++

A detailed introduction to using the filesystem in C++. Create, delete, move, and navigate through directories and files using the std::filesystem library.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

4c.jpg
Ryan McCombe
Ryan McCombe
Posted

In this lesson, we’ll take a tour of the standard library functionality that helps us work with file systems in a platform-agnostic way.

The file system functionality is available by including <filesystem>

#include <filesystem>

Most of the functions and types we’ll be using are within the std::filesystem namespace, which we’ll alias to fs to keep our code less verbose:

namespace fs = std::filesystem;

Directory Entry

A directory_entry is the main type we use to refer to objects within our file system, such as files and directories. We can initialize a directory_entry by passing the path to it on our hard drive.

#include <filesystem>

int main() {
  namespace fs = std::filesystem;
  fs::directory_entry Directory{"c:/test"};
}

Given paths often include backslashes, we typically populate them from raw strings. This means we don’t need to escape the back slashes, which keeps our paths readable:

#include <filesystem>

int main() {
  namespace fs = std::filesystem;
  fs::directory_entry Directory{R"(c:\test)"};
}

Directory entries have a lot of useful methods we can use to investigate the nature of what our path is pointing at.

For example, we can check if an entry exists at the path by using the exists() method. We can also check if it is a directory or file by using the is_directory() and is_regular_file() methods.

In the following example, c:\test is a directory on our hard drive:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  fs::directory_entry Directory{R"(c:\test)"};
  if (Directory.exists()) {
    std::cout << "The location exists";
  }

  if (Directory.is_directory()) {
    std::cout << "\nIt is a directory";
  }

  if (!Directory.is_regular_file()) {
    std::cout << "\nIt is not a file";
  }
}
The location exists
It is a directory
It is not a file

“Regular” Files?

Almost all of the files we’re familiar with on our hard drives - documents, images, executables, and more, are considered regular files.

Beyond directories and regular files, there are several other things that can exist within our file systems, such as block files and symbolic links. For this lesson, we’ll just focus on directories and regular files.

Creating a Directory

We can create a directory using the create_directory() function, passing a path to the directory we want to create.

#include <filesystem>

int main() {
  namespace fs = std::filesystem;
  fs::create_directory(R"(c:\test)");
}

The function will return true if the directory was created.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  bool success{
      fs::create_directory(R"(c:\test)")};

  if (success) {
    std::cout << "Directory created";
  } else {
    std::cout << "Directory not created";
  }
}
Directory not created

There are two possible reasons the directory will not be created:

  • it already exists, as in this case, or
  • an error was reported from the operating system

We’ll cover errors in more detail later in this lesson.

Deeply Creating a Directory

Sometimes, we’ll want to create a directory structure that is multiple levels deep. For example, we may want to create c:\test\subdirectory, when c:\test does not yet exist.

Using the create_directories method, we can specify the exact directory we want, and if any intermediate directories are missing, they will be created too:

#include <filesystem>

int main() {
  namespace fs = std::filesystem;

  fs::create_directories(
      R"(c:\deeply\nested\directory)");
}

File System Exceptions and Errors

Many of the functions in this lesson can throw exceptions. We can catch these in the normal way, using a try-catch block. The type of exception thrown by file system errors is std::filesystem::filesystem_error.

Like any standard library exception, they have a what() method that we can use to get a description of the error.

For more programmatic error handling, we’re likely to want to use the code() method instead. This returns a numeric error code that is much easier to work with than the string output of what()

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  try {
    fs::create_directory(R"(k:\fake)");
  } catch (fs::filesystem_error e) {
    std::cout << e.code() << '\n';
    std::cout << e.what();
  }
}
system:3
create_directory: The system cannot find
the path specified.: "k:\fake"

The meaning of error codes depends on the operating system our program is running on. The previous example is from Windows, which uses the following error codes: https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes

Getting File Size with file_size()

When our directory_entry is pointing at a file, we can use the file_size method to return its size in bytes:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::directory_entry File{
      R"(c:\test\hello.txt)"};

  std::cout << "File Size: " << File.file_size()
            << " bytes";
}
File Size: 24 bytes

Getting the last modified time with last_write_time()

We can find the time a file or directory was last modified by using the last_write_time() method on our directory_entry:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::directory_entry File{
      R"(c:\test\hello.txt)"};

  std::cout << "Last Write Time:\n"
            << File.last_write_time();
}
Last Write Time:
2023-06-10 23:10:33.4202407

Copying Files with copy_file

We can copy files using the copy_file method, with two arguments. The first argument is the path to the file we want to copy. The second argument is the path we want to copy it to:

#include <filesystem>

int main() {
  namespace fs = std::filesystem;

  fs::copy_file(R"(c:\test\hello.txt)",
                R"(c:\test\hi.txt)");

We can pass an additional third argument to copy_file, which lets us define how our code should behave if the path defined by the second argument already exists.

In such a scenario, our copy_file call risks overwriting a file.

The std::filesystem::copy_options enumeration contains our options for handling this. They are:

  • none - keep the existing file and throw an exception (default)
  • skip_existing - keep the existing file but do not throw an exception
  • overwrite_existing - overwrite the existing file
  • update_existing - overwrite the existing file if it’s older than the new file, as defined by the last_write_time
#include <filesystem>

int main() {
  namespace fs = std::filesystem;

  fs::copy_file(
      R"(c:\test\hello.txt)",
      R"(c:\test\hi.txt)",
      fs::copy_options::skip_existing);
}

Copying a Directory with copy

We can copy a directory with the copy method.

Similar to copy_file, we can pass options to define how the copy action works. When dealing with overwriting existing files, we have the same 4 options we had with copy_file.

We can also pass up to two additional options that take effect when what we’re copying is a directory:

  • recursive - Recursively copy subdirectories
  • directories_only - Only copy the directory structure - ignore files

Both options are disabled by default, but we can enable them as needed, and we can enable multiple options by combining them with the | operator:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::copy(
      R"(c:\test)", R"(c:\test2)",
      fs::copy_options::skip_existing |
          fs::copy_options::recursive |
          fs::copy_options::directories_only);
}

Additional copy options are available when we’re dealing with things like symbolic links, but that’s out of scope for now.

Note, the copy function can copy a file - it is not restricted to just copying a directory. However, if we're copying a file, the copy_file method makes our intent much clearer and has built in error-checking to ensure the target actually is a file. So, when copying a file, using the copy_file method tends to be recommended.

Removing a File or Directory using remove()

We can remove a file or directory using the remove function:

#include <filesystem>

int main() {
  namespace fs = std::filesystem;
  fs::remove(R"(c:\test)");
}

When the path we provide points to a directory that is not empty, an exception is thrown

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  try {
    fs::remove(R"(c:\test)");
  } catch (fs::filesystem_error e) {
    std::cout << "Code: " << e.code()
              << "\nError: " << e.what();
  }
}
Code: system:145
Error: remove: The directory is not empty.: "c:/test"

When we’re trying to remove a directory, and we want to get rid of everything inside it too, we can use the remove_all method.

Removing a directory and all contents using remove_all()

The remove_all() method removes a directory and all of its contents. It returns an integer, representing how many files and directories were removed.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  uintmax_t filesDeleted{
      fs::remove_all(R"(c:\test)")};

  std::cout << "Deleted " << filesDeleted
            << " files or directories";
}
Deleted 2 files or directories

Moving or Renaming Files and Directories with rename()

We can move or rename a file or directory using the rename() function. The first argument will be a path to the thing we want to move. The second argument will be the location we want to move it to.

#include <filesystem>

int main() {
  namespace fs = std::filesystem;

  fs::rename(R"(c:\test)", R"(c:\test2)");
}

Getting Disk Space using space()

The space function lets us get more information about the storage space associated with a path. It returns a space_info struct, which has three fields:

  • capacity - The total capacity available at the location
  • free - The amount of space free at the location
  • available - The amount of space that is available for our program to write to. This will be less than or equal to the free space

All three values are in bytes:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  const fs::space_info si{fs::space(R"(c:\)")};

  constexpr int bytesInGB{1024 * 1024 * 1024};

  std::cout << "Capacity: "
            << si.capacity / bytesInGB << "GB";
  std::cout << "\nFree: " << si.free / bytesInGB
            << "GB";
  std::cout << "\nAvailable: "
            << si.available / bytesInGB << "GB";
}
Capacity: 437GB
Free: 277GB
Available: 277GB

std::filesystem::path

In the previous examples, we’re representing our paths as simple strings, but the underlying type is a std::filesystem::path. This type has some additional useful functions specific to this use case.

path objects can be created from simple strings, which is what our code has been doing so far:

#include <filesystem>

int main() {
  namespace fs = std::filesystem;
  fs::path Location{R"(c:\test)"};
}

We can get the string representation of a path using the string() method, which is useful when we want to display it:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  fs::path Location{R"(c:\test)"};

  std::cout << Location.string();
}
c:\test

We can also get the path associated with a directory entry using the path() method:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  fs::directory_entry Entry{R"(c:\test)"};

  std::cout << Entry.path().string();
}
c:\test

path methods

A variety of methods give us access to specific parts of the path. These also return path objects, so in the following example we use the string method to display them:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  fs::path Location{R"(c:\test\hello.txt)"};

  std::cout << "File Name: "
            << Location.filename().string();

  std::cout << "\nFile Stem: "
            << Location.stem().string();

  std::cout << "\nFile Extension: "
            << Location.extension().string();

  std::cout << "\nParent Path: "
            << Location.parent_path().string();

  std::cout << "\nRoot Path: "
            << Location.root_name().string();
}
File Name: hello.txt
File Stem: hello
File Extension: .txt
Parent Path: c:\test
Root Path: c:

Equivalent boolean methods are available that return true or false based on the existence of any of these path parts.

We can access these by prepending has_ to the method names. For example, has_filename() and has_extension()

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::path Directory{R"(c:\test\)"};
  if (!Directory.has_filename()) {
    std::cout << Directory.string()
              << " has no file name\n";
  }

  fs::path File{R"(c:\hi.txt)"};
  if (File.has_extension()) {
    std::cout << File.string()
              << " has a file extension";
  }
}

Note the result of these functions is based entirely on the format of the provided string.

These methods do not check the underlying file system. For example, the has_filename method returns true if it seems like the provided path has a file name. But, to check what actually exists at that location, we will need to create a directory_entry, not just a path.

Working with path file names

When working with paths, a common requirement is manipulate the file name, so we have some methods to help us there:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::path File{R"(c:\test\hello.txt)"};
  std::cout << File.string() << '\n';

  File.replace_filename("world.txt");
  std::cout << File.string() << '\n';

  File.replace_extension("doc");
  std::cout << File.string() << '\n';

  File.remove_filename();
  std::cout << File.string();
}
c:\test\hello.txt
c:\test\world.txt
c:\test\world.doc
c:\test\

Use cases for these methods typically come up when we're trying to rename or copy files that have paths we don't necessarily know in advance. For example, we can be creating a function that accepts a path as an argument:

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

void CreateBackup(const fs::path& Path) {
  fs::path Backup{Path};
  Backup.replace_extension("backup");
  fs::copy_file(Path, Backup);
}

int main() {
  CreateBackup(R"(c:\test\hello.txt)");
}

Appending to a path

The path type also overrides the /= operator, which allows us to create paths to subdirectories or files, by automatically appending separators that are appropriate to the operating system.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::path File{R"(c:\)"};
  std::cout << File.string() << '\n';

  File /= "test";
  std::cout << File.string() << '\n';

  File /= "hello.txt";
  std::cout << File.string() << '\n';
}
c:\
c:\test
c:\test\hello.txt

These methods return a reference to the original object, allowing them to be chained:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::path File{R"(c:\test\hello.txt)"};
  std::cout << File.string() << '\n';

  File.remove_filename() /= "subdirectory";
  std::cout << File.string();
}
c:\test\hello.txt
c:\test\subdirectory

Relative Paths

All the paths we’ve shown so far have been absolute paths, but we can also use relative paths.

We can check if a path is relative or absolute using the is_relative and is_absolute methods.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::path A{R"(c:\test\hello.txt)"};
  if (A.is_absolute()) {
    std::cout << "A is Absolute";
  }

  fs::path B{R"(hello.txt)"};
  if (B.is_relative()) {
    std::cout << "B is Relative";
  }
}
A is Absolute
B is Relative

Relative paths are relative to some other directory, sometimes called the “current path” or “current working directory”

We can see the current working directory using the current_path method. Its default value depends on our settings, but we can pass a new path to that function, to set what our working directory should be.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  fs::directory_entry Entry{R"(hello.txt)"};

  std::cout
      << "The default working directory is:\n"
      << fs::current_path().string();

  if (!Entry.exists()) {
    std::cout << "\nThe file was not found\n\n";
  }

  fs::current_path(R"(c:\test)");

  std::cout << "The current working directory "
               "was changed to:\n"
            << fs::current_path().string();

  if (Entry.exists()) {
    std::cout << "\nThe file was found!";
  }
}
The default working directory is:
C:\Users\ryanm\repos\cpp-tuts
The file was not found

The current working directory was changed to:
c:\test
The file was found!

Directory Iteration using directory_iterator

We can navigate through a directory using a directory_iterator pair.

By passing a path to this type, we can define the directory we want to iterate through. This will act as our starting point.

The default constructor for directory_iterator returns an end iterator. We can increment our starting iterator and, once it becomes equal to the default-constructed iterator, we know we have navigated through the entire directory.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::directory_iterator Start{R"(c:\test)"};
  fs::directory_iterator End{};

  for (auto Iter{Start}; Iter != End; ++Iter) {
    std::cout << Iter->path().string();
    if (Iter->is_directory()) {
      std::cout << " (Directory)";
    } else if (Iter->is_regular_file()) {
      std::cout << " (" << Iter->file_size()
                << " Bytes)";
    }
    std::cout << '\n';
  }
}
c:\test\hello.txt (24 Bytes)
c:\test\subdirectory (Directory)
c:\test\world.txt (62 Bytes)

By default, the directory_iterator only iterates through the first level of entries within our directory. If an entry is a subdirectory, we will not navigate into it.

We could build this logic ourselves, by implementing recursive behavior when is_directory returns true. However, the standard library has built this for us, in the form of the recursive_directory_iterator

Recursive Directory Iteration using recursive_directory_iterator

This iterator works in much the same way as the directory_iterator, except it will navigate into any subdirectories it finds along the way.

The following code is identical to the previous example. Only the highlighted lines have been changed to switch to a recursive iterator.

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;

  fs::recursive_directory_iterator Start{
      R"(c:\test)"};
  fs::recursive_directory_iterator End{};

  for (auto Iter{Start}; Iter != End; ++Iter) {
    std::cout << Iter->path().string();
    if (Iter->is_directory()) {
      std::cout << " (Directory)";
    } else if (Iter->is_regular_file()) {
      std::cout << " (" << Iter->file_size()
                << " Bytes)";
    }
    std::cout << '\n';
  }
}

We now navigate into subdirectories:

c:\test\hello.txt (24 Bytes)
c:\test\subdirectory (Directory)
c:\test\subdirectory\deep.txt (10 Bytes)
c:\test\world.txt (62 Bytes)

The recursive iterator has an additional method, called depth(). This will return the depth of the recursion, ie, how deeply nested the current directory entry is, relative to our starting point:

#include <filesystem>
#include <iostream>

int main() {
  namespace fs = std::filesystem;
  using std::cout, std::setw;

  fs::recursive_directory_iterator Start{
      R"(c:\test)"};
  fs::recursive_directory_iterator End{};

  for (auto Iter{Start}; Iter != End; ++Iter) {
    std::cout << "Depth " << Iter.depth()
              << " - " << Iter->path().string()
              << '\n';
  }
}
Depth 0 - c:\test\hello.txt
Depth 0 - c:\test\subdirectory
Depth 1 - c:\test\subdirectory\deep.txt
Depth 0 - c:\test\world.txt

Up next, we’ll start working with file streams. These are the mean way we create, read, and update files within our file systems.

Was this lesson useful?

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Files and Serialization
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

C++ File Streams

A detailed guide to reading and writing files in C++ using the standard library’s fstream objects.
63.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved