C++ Binary File I/O and std::filesystem Explained
Write and read binary records in C++ with write() and read(), why padding and endianness matter, and std::filesystem for paths, sizes and directory listings.
- Course: C++ study plan
- Module: Streams and files
- Kind: Lesson
- Reading time: 15 min
- Runtime: Clang 18 · C++20
How do you write a struct to a binary file in C++?
Open a std::ofstream with std::ios::binary and call out.write(reinterpret_cast<const char*>(&r), sizeof r); read it back with in.read(reinterpret_cast<char*>(&r), sizeof r). This works only for trivially copyable types — numbers, char arrays and structs of them. A std::string or std::vector member writes its pointer, not its contents, and the file is readable only with the same struct layout and byte order.
Lesson
Text I/O turns every number into digits and back, which is slow, loses precision for floating point, and makes a record's size depend on its values. Binary I/O writes the bytes an object already occupies in memory — a double is eight bytes on disk exactly as it is in RAM — and reads them straight back, so a file of fixed-width records can be indexed by arithmetic: record i starts at byte i * sizeof(Record). The price is that the bytes mean nothing without the struct that wrote them, and nothing about their order is portable across machines. This lesson covers write/read, what may safely be written that way, endianness, and std::filesystem — the C++17 library for paths, sizes, directories and listings that a file-handling program needs around its streams.
write and read
#include <cstdint>
#include <fstream>
struct Record {
std::int32_t id;
double score;
char name[16];
};
int main() {
Record r{7, 91.5, "ada"};
{
std::ofstream out("records.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(&r), sizeof r);
}
std::ifstream in("records.bin", std::ios::binary);
Record back{};
in.read(reinterpret_cast<char*>(&back), sizeof back);
// back.id == 7, back.score == 91.5, back.name is "ada"
return 0;
}
write(const char*, count) copies count bytes from the address; read(char*, count) fills them. Both want a char*, and an object's address is a Record*, so the cast is reinterpret_cast — Module 2 lesson 4's "reinterpret these bytes as that type", which is exactly the meaning here and one of its few legitimate uses. After a read, in.gcount() says how many bytes actually arrived; a short read at the end of the file sets failbit, so while (in.read(...)) is the loop for a whole file of records.
What may be written this way: only trivially copyable types — built-in numbers, char arrays, and structs made of them. A std::string or std::vector is a small handle (a pointer, a size, a capacity); writing sizeof(std::string) bytes of it saves the pointer, not the characters, and reading it back produces an object pointing at memory that no longer exists. static_assert(std::is_trivially_copyable_v<Record>) next to the struct documents the requirement and stops a later std::string member compiling.
Fixed-width records and padding
sizeof(Record) above is 32 on this platform, not 28: the double must sit at an address that is a multiple of 8, so the compiler inserts four bytes of padding after the int32_t. The layout is id (4), padding (4), score (8), name (16). Padding is part of the object and is written and read with it, which is harmless — as long as the reader uses the same struct with the same compiler settings. Reordering the members (double first) removes the padding here; #pragma pack forces it away everywhere at the cost of misaligned access and is not portable.
Because every record has the same size, the file is an array. in.seekg(static_cast<std::streamoff>(i) * sizeof(Record)) positions the stream at record i; in.tellg() reports the position; std::filesystem::file_size divided by sizeof(Record) is the record count. That random access is the whole reason for the format — a text file has to be read from the start to find its hundredth line.
std::ios::binary disables newline translation. On Linux, where the judge runs, it changes nothing; on Windows a text-mode stream rewrites \n as \r\n on the way out and silently corrupts any byte 0x0A inside a double. Always specify it for write/read so the program means the same thing everywhere.
Endianness
A four-byte int holding 1 is stored as 01 00 00 00 on x86-64 — little-endian, least significant byte first — and as 00 00 00 01 on a big-endian machine. write emits whichever order the CPU uses, so a binary file is only readable on machines that agree. <bit> reports the order (std::endian::native == std::endian::little), and a format meant to travel serialises each integer byte by byte with shifts ((v >> 8) & 0xFF, …) in a declared order, or uses std::byteswap — C++23, reading only here. Network protocols are big-endian by convention; most file formats written today are little-endian. For a file a program writes and reads on the same machine — a cache, a save file, this lesson's exercise — the native order is fine, and a small header with a magic number and version lets a reader refuse a file it does not understand.
std::filesystem
<filesystem> (C++17) is the portable answer to "does this file exist, how big is it, what is in this directory". The namespace is long, so the idiom is an alias: namespace fs = std::filesystem;.
| Call | Result |
|---|---|
fs::path p = fs::path("data") / "q1.csv"; | joins with the platform separator |
p.filename(), p.stem(), p.extension(), p.parent_path() | q1.csv, q1, .csv, data |
fs::exists(p), fs::is_regular_file(p), fs::is_directory(p) | queries |
fs::file_size(p) | bytes, as std::uintmax_t; throws for a directory |
fs::create_directory(p) / fs::create_directories(p) | one level / the whole chain; true if created |
fs::remove(p) / fs::remove_all(p) | one entry / a tree; returns whether removed / how many entries |
fs::rename(from, to), fs::copy_file(from, to) | as named |
for (const auto& entry : fs::directory_iterator(p)) | every entry; entry.path(), entry.is_regular_file(), entry.file_size() |
fs::path is a string with path semantics: / joins components, string() converts back, and generic_string() always uses / even on Windows, which is what a path written into a file or a log should use. A file stream's constructor accepts a path directly.
Two properties matter for a judged program. The order in which directory_iterator yields entries is unspecified — it is whatever the operating system returns — so a listing that must be deterministic collects the names into a std::vector and sorts them. And fs::current_path() is an absolute path that differs on every machine; never print it. Timestamps (fs::last_write_time) are the same kind of value.
namespace fs = std::filesystem;
fs::create_directory("inbox");
{ std::ofstream a("inbox/a.txt"); a << "hello\n"; }
std::vector<std::string> names;
for (const auto& entry : fs::directory_iterator("inbox")) {
names.push_back(entry.path().filename().string());
}
std::sort(names.begin(), names.end());
for (const auto& n : names) std::cout << n << ' ' << fs::file_size(fs::path("inbox") / n) << '\n';
fs::remove_all("inbox"); // 2: the file and the directory
Errors: two flavours
Every filesystem function comes in two overloads. The plain one throws fs::filesystem_error (a std::system_error whose what() names the operation and the path) on failure — file_size of a missing file, create_directory where a file of that name exists. The overload with a trailing std::error_code& parameter reports through it instead and never throws: auto size = fs::file_size(p, ec); if (ec) { /* ec.message() */ }. The second form suits a tool that inspects many files and wants to keep going; the first suits code where a missing file is a bug. Module 15 lesson 5 has std::error_code in general.
Pitfalls
| Mistake | What happens |
|---|---|
out.write(reinterpret_cast<const char*>(&str), sizeof str) | Writes the string's handle; the characters are not in the file. |
| Reading a record with a different struct layout | Fields shift; values are garbage with no error. |
Forgetting std::ios::binary | Fine on Linux; corrupt on Windows. |
| Assuming the file's byte order matches another machine | Little- and big-endian disagree; serialise explicitly. |
Printing directory_iterator order as it comes | Unspecified order; sort first. |
fs::file_size on a directory or missing path | Throws filesystem_error; use the error_code overload if that is expected. |
Key takeaways
write/readmove raw bytes; cast withreinterpret_cast<const char*>and only for trivially copyable types — never astd::stringorstd::vector.- Fixed-width records make a file an array:
seekg(i * sizeof(Record));sizeofincludes padding (32 for the record above, on this platform). - Always open binary files with
std::ios::binary; byte order is the CPU's, so serialise explicitly for portable formats. std::filesystemgivespatharithmetic,exists,file_size,create_directory,remove_allanddirectory_iterator— whose order you sort.- Every function has a throwing overload and an
error_codeone; never printcurrent_path()or timestamps in a judged program.
Common questions
Why is sizeof a struct larger than the sum of its members?
The compiler inserts padding so each member sits at an address that is a multiple of its alignment. A struct holding an int32_t then a double is 16 bytes, not 12, because four padding bytes put the double on an 8-byte boundary. Ordering members from largest to smallest often removes the interior padding.
What is endianness?
Endianness is the order in which a multi-byte number's bytes are stored: little-endian machines such as x86-64 store the least significant byte first, so an int holding 1 is 01 00 00 00, while big-endian machines store it the other way round. A binary file written with write uses the CPU's order, so portable formats serialise each integer byte by byte in a declared order.
How do I list the files in a directory in C++?
Use C++17 std::filesystem: for (const auto& entry : std::filesystem::directory_iterator(dir)) visits every entry, with entry.path().filename() and entry.is_regular_file(). The order is unspecified — whatever the operating system returns — so collect the names into a vector and sort them when the output must be deterministic.
How do I get a file's size in C++?
std::filesystem::file_size(path) returns the size in bytes as std::uintmax_t. It throws std::filesystem::filesystem_error for a missing file or a directory; the overload taking a std::error_code& reports the failure through it instead of throwing.
Why open a file with std::ios::binary?
It disables newline translation. On Linux it changes nothing, but on Windows a text-mode stream rewrites every newline byte as a carriage return plus newline, which corrupts any 0x0A byte inside binary data such as a double. Always use binary mode for read and write.
Exercises
Fixed-width record file
Read an integer n, then n records <id> <score> <name> (an int, a decimal, a one-word name of at most 15 characters), then an index k (0-based, less than n). Pack every record into
struct Record {
std::int32_t id;
double score;
char name[16];
};
(zero-initialise it and copy the name into the array) and write them all to records.bin, opened with std::ios::binary, using write and reinterpret_cast<const char*>. Close it and print bytes=<std::filesystem::file_size("records.bin")>. Reopen the file in binary mode, seekg straight to record k — its offset is k * sizeof(Record) — read it and print record[k]=<id> <name> <score with 1 decimal>. Then seekg(0) and read every record in a while (in.read(...)) loop to print count=<n> mean=<mean score, 2 decimals> best=<name of the highest score, first on ties>. Delete the file at the end.
Input: n, n records, k. Output: three lines.
3
1 91.5 ada
2 78.5 bob
3 91.5 cy
1
prints
bytes=96
record[1]=2 bob 78.5
count=3 mean=87.17 best=adaDirectory listing, sorted
Read an integer n, then n lines of <filename> <content> — a one-word file name (no spaces or slashes) followed by the rest of the line, which may be empty. Create a directory named inbox with std::filesystem::create_directory and write each file as inbox/<filename> holding the content followed by one newline. A repeated file name overwrites the earlier file.
Then list the directory with std::filesystem::directory_iterator, collecting each regular file's name and size, sort the names (the iterator's order is unspecified), and print <name> <size> per file, then files=<count> total=<sum of sizes>. Finally remove the whole directory with std::filesystem::remove_all and print removed=<the number it returns> — the files plus the directory itself.
Input: n, then n lines. Output: the sorted listing, then two lines.
3
b.txt hello world
a.txt hi
c.log
prints
a.txt 3
b.txt 12
c.log 1
files=3 total=16
removed=4In this module: Streams and files
← String streams and formatting — building text in memory · Checkpoint — Streams and files →