AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

18 Streams and files / Lesson 5 of 5

Binary I/O and the filesystem

15 min · +10 XP · 2 exercises · 6 questions

Read the text15 min
Exercises0/2
Quiz6 q

Quick answer

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.

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;.

CallResult
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

MistakeWhat 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 layoutFields shift; values are garbage with no error.
Forgetting std::ios::binaryFine on Linux; corrupt on Windows.
Assuming the file's byte order matches another machineLittle- and big-endian disagree; serialise explicitly.
Printing directory_iterator order as it comesUnspecified order; sort first.
fs::file_size on a directory or missing pathThrows filesystem_error; use the error_code overload if that is expected.

Key takeaways

  • write/read move raw bytes; cast with reinterpret_cast<const char*> and only for trivially copyable types — never a std::string or std::vector.
  • Fixed-width records make a file an array: seekg(i * sizeof(Record)); sizeof includes 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::filesystem gives path arithmetic, exists, file_size, create_directory, remove_all and directory_iterator — whose order you sort.
  • Every function has a throwing overload and an error_code one; never print current_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.

Mark as readSign in to keep your place.

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;.

CallResult
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

MistakeWhat 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 layoutFields shift; values are garbage with no error.
Forgetting std::ios::binaryFine on Linux; corrupt on Windows.
Assuming the file's byte order matches another machineLittle- and big-endian disagree; serialise explicitly.
Printing directory_iterator order as it comesUnspecified order; sort first.
fs::file_size on a directory or missing pathThrows filesystem_error; use the error_code overload if that is expected.

Key takeaways

  • write/read move raw bytes; cast with reinterpret_cast<const char*> and only for trivially copyable types — never a std::string or std::vector.
  • Fixed-width records make a file an array: seekg(i * sizeof(Record)); sizeof includes 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::filesystem gives path arithmetic, exists, file_size, create_directory, remove_all and directory_iterator — whose order you sort.
  • Every function has a throwing overload and an error_code one; never print current_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=ada

Directory 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=4

In this module: Streams and files

← String streams and formatting — building text in memory · Checkpoint — Streams and files →