Text mode is fine when the file is meant for humans to read, but it's the wrong tool for storing structured data like product records or order logs. Binary file I/O lets you write the exact bytes that make up an object to disk and read them back identically, with no formatting, parsing, or conversion in between. This chapter covers what ios::binary actually does, the read and write member functions, how to stream POD structs to disk, random access by record number, the traps around std::string and pointers, and why endianness can corrupt a file when it moves between machines.
Every file stream in C++ defaults to text mode. In text mode, the stream is allowed to translate certain bytes as they cross the boundary between your program and the file. On Windows that means \n (one byte, value 10) is written to disk as \r\n (two bytes, values 13 and 10), and the reverse happens when reading. On Unix-like systems there's no translation, but the standard still permits implementations to perform other transformations.
Binary mode disables this translation. You pass std::ios::binary to the stream constructor or open() call, and the stream becomes a transparent byte pipe: every byte you write hits the disk exactly once, with no substitutions, and every byte on disk reads back exactly as it appears.
The difference matters as soon as you try to write something that isn't text. Consider write a 4-byte integer whose bytes happen to be 0x10 0x0A 0x10 0x0A (the 0x0A is the byte value of \n). In text mode on Windows, each 0x0A becomes 0x0D 0x0A on disk, so your 4-byte integer is stored as 6 bytes. When you read it back you get 6 bytes worth of garbage, not your integer. Open the same file in binary mode and the integer round-trips perfectly.
The rule is simple. If the file is text (newline-separated lines of characters that a person might cat or open in an editor), use text mode. If the file is anything else (numeric data, structs, images, serialized objects), use binary mode. There's no third option, and "I'll just use text mode for everything" is how data files get corrupted on Windows.
Text mode also performs formatted conversions when you use the stream insertion operators (<< and >>). Writing int n = 42; with out << n; produces the two ASCII characters '4' and '2', not the four bytes of the integer's memory. Binary I/O is a different layer of the stream API entirely.
The stream insertion operators (<<, >>) work on formatted text. For raw bytes, the file stream classes give you two member functions:
Both take a char* and a byte count. write copies count bytes from buffer to the stream; read copies up to count bytes from the stream into buffer. The return value is the stream itself, so you can chain or test it in a boolean context.
Two questions show up here. Why char* and not void*? And how do you write an int or a Product through a char*?
C++ uses char* because, by definition, char is the smallest addressable unit and any object can be inspected byte by byte through a char* (or unsigned char*). The standard explicitly carves out this rule: aliasing any object through a char* is well-defined behavior. You can't generally cast an int* to a Product*, but you can always cast any pointer to char* for the purposes of reading the underlying bytes.
To get from an object to a char*, use reinterpret_cast<char*>(&object). To get the byte count, use sizeof(object). The pattern is the same every time:
reinterpret_cast is the cast you use when you genuinely mean "treat these bytes as a different type." It's the right cast here because that's exactly what binary I/O is doing. Using static_cast<char*>(&value) would not compile, because static_cast doesn't allow unrelated pointer conversions.
The other small gotcha is the const. When writing, the pointer is const char* (you're reading from the object). When reading, the pointer is plain char* (you're writing into the object). Pick the cast that matches.
The simplest possible example is round-tripping a single int. The integer represents the current stock count of a product.
The file stock.bin is exactly 4 bytes long (on systems where sizeof(int) == 4, which is every mainstream platform). If you opened it in a hex editor you'd see something like 8E 00 00 00 (the bytes of 142 in little-endian order on x86 and ARM). That's the on-disk representation: no header, no delimiter, just the four bytes of the integer.
Two things are worth noticing. First, the file contains no information about what it holds. There's nothing in those four bytes that says "this is an integer" or "this is a stock count." If you read it back as a float or a short, the bytes are interpreted differently and you get nonsense. Binary files are only as self-describing as you make them. Second, the same code reads and writes the value, which is the whole point: round-tripping is symmetric.
The text-mode equivalent would write the characters '1', '4', '2', taking three bytes for this value but seven bytes for 9999999. Binary is fixed-size; text is variable-size. We'll quantify that trade-off later in the chapter.
Single integers are a warm-up. The real value of binary I/O is dumping whole structs to disk in one call. The pattern works as long as the struct is trivially copyable: every member is itself trivially copyable (basic types, fixed-size arrays of trivial types, other trivially copyable structs), there are no virtual functions, no custom copy/move operations that do anything nontrivial, and no members that own external resources.
Trivially copyable is C++'s formal name for what's commonly called POD (Plain Old Data). The compiler can confirm it for you with std::is_trivially_copyable.
One write call dumps every byte of the struct to disk. One read call pulls them back. The fields appear in loaded exactly as they were in p, because the in-memory representation was copied verbatim.
The static_assert is the safety net. If a future change adds a std::string name; to the struct, the assertion fires at compile time and you find out before the program corrupts a file. Without it, you'd write a struct that contains an internal pointer, and the read would get a pointer that points nowhere useful in the new process.
A memory-layout diagram showing how the struct maps to bytes in the file:
The padding bytes are real. Compilers insert them so that each field starts on a properly-aligned address (a double typically wants 8-byte alignment). They get written to disk and read back along with the data, which is harmless as long as both sides agree on the layout. The exact layout is implementation-defined, so a file written by one compiler isn't guaranteed to be readable by a different compiler. For data that crosses compiler boundaries, define an explicit serialization format. For data that's read by the same program that wrote it, the implicit layout is fine.
To make the round-trip more interesting, the same pattern applied to an array of structs (an inventory file).
Because every Product is the same size, an array of them is just three back-to-back copies of the per-record byte layout. One write call moves 144 bytes (3 records of 48 bytes each, including padding). The file is a flat sequence of fixed-size records with no separators, which is exactly the structure that makes random access possible. We'll exploit that property in a moment.
A binary file isn't just a stream you read front-to-back. The stream maintains a position indicator that tracks the next byte to read or write, and you can move it around. Four functions form the API.
| Function | Stream | Purpose |
|---|---|---|
tellg() | input (ifstream) | Returns the current read position |
seekg(pos) | input | Moves the read position |
tellp() | output (ofstream) | Returns the current write position |
seekp(pos) | output | Moves the write position |
The g stands for "get" (reading from the stream); the p stands for "put" (writing to it). A read-write stream (fstream) has both a get and a put position, which are usually independent.
seek has two flavors: absolute and relative. The absolute form takes a single byte offset from the beginning of the file. The relative form takes an offset and a base point, where the base is one of three constants:
| Constant | Meaning |
|---|---|
std::ios::beg | Start of the file |
std::ios::cur | Current position |
std::ios::end | End of the file |
A small example that opens a file, asks how big it is, and seeks back to the start.
tellg() returns a std::streampos, which is an integer-like type representing a position. You can compare positions, subtract them, and pass them back to seekg. The simplest use is the one above: seek to the end and immediately read the position to get the file's length in bytes.
seekg(0, std::ios::beg) is the canonical "rewind" call. After calling it, the next read starts from byte 0 again. The same applies to seekp on an output stream: write past the end and the file grows; write before the end and you overwrite existing bytes.
When every record in a file is the same size, you can compute the byte offset of record number i in constant time: i * sizeof(Record). That's the move that turns a binary file into a poor person's database.
No scanning. The program seeks straight to byte 48 (which is 1 * sizeof(Product) on a layout where sizeof(Product) == 48), reads 48 bytes, and is done. The same pattern works for any record index, in any order, as many times as you like.
Random access into a fixed-size binary file is O(1) per record, because the offset is a single multiplication and the OS can seek the file pointer directly. Text files with variable-length lines need O(n) to find record n, because you have to scan forward counting newlines.
This is the property that makes flat binary files a reasonable starting point for storage when you don't want a real database. Want to update one record? Seek to its offset, write the new bytes, done. Want to find a record by id? Either scan the whole file (linear) or maintain an index file that maps id to offset (logarithmic if it's sorted, constant if it's a hash). Want to delete a record? Either mark it with a tombstone byte or shift every later record back by one (which is expensive).
The same offset trick works for writing. An example that updates the stock count of record index 0 without rewriting the rest of the file.
The fstream is opened with both std::ios::in and std::ios::out so the same handle can do both. seekg moves the read position; seekp moves the write position. They're independent on most implementations, which is why the program seeks twice. The end result: a single record on disk has its 48 bytes overwritten with new content, and no other byte of the file is touched.
Here we're using exactly what binary I/O needs: std::ios::binary combined with the read/write defaults.
read requests up to count bytes, but it might read fewer. The file could be shorter than expected, the disk could fail, or you could be reading from a pipe that doesn't have that much data ready. To find out how many bytes the last unformatted read actually produced, call gcount().
The stream sets eof (end-of-file) when a read runs out of bytes. gcount() gives the exact byte count from the last read, getline, or other unformatted input call. The combination is how you read whatever's left of a file: ask for more than enough, then check gcount().
The same logic applies to reading record-by-record until the file is exhausted. The typical loop checks the stream's truthiness after the read.
read returns the stream, and the stream evaluates to false when it can no longer satisfy the request (end of file, error, partial read). The loop reads one record per iteration and stops when the file runs out. If the file is corrupted partway through (say, only 30 bytes of a 48-byte record remain), the final read fails and sets eof. You can check gcount() afterward to see how many bytes the last attempt salvaged, which is occasionally useful for recovery code.
The previous examples kept the product name in a char name[32], not a std::string. That wasn't an accident. Writing a std::string directly through reinterpret_cast produces a file you cannot read back, and it's important to understand why.
A std::string is a small object that holds a pointer (or, with the small-string optimization, an inline buffer) to the actual characters on the heap. The string object itself is typically 24 to 32 bytes on a 64-bit system, regardless of the text length. Those bytes are: a pointer to the heap buffer, the current length, and the current capacity. The text characters themselves live somewhere else.
This program is undefined behavior. It might print "USB Cable" if you're unlucky enough to run the read in the same process where the original heap allocation still exists. In any realistic scenario (separate process, fresh program run), the pointer points to freed or unrelated memory, and the program produces garbage or crashes.
The diagram below shows the actual layout. Cyan boxes are the struct itself; orange is the heap data the string points to. Only the cyan bytes get written by the bad write call.
The fix is to write a self-contained representation: the length followed by the bytes. On read-back, you read the length first, allocate or reserve a buffer of that size, and then read that many bytes into it.
The format is now length-prefixed: 4 bytes of length, then length bytes of characters, then the next length, and so on. The file is self-describing within each record: you always know how much to read next. The 4-byte length is enough for strings up to about 4 billion characters, which is more than one would need for a product name.
The same principle applies to std::vector, std::map, and any container that owns external memory. You can't dump the container's bytes; you have to walk the container and write each element's representation, then read them back in the same order. A good rule of thumb: if a type allocates on the heap, it can't be written by write(reinterpret_cast<const char*>(&obj), sizeof(obj)). The static_assert(std::is_trivially_copyable_v<T>) check enforces this at compile time.
Multi-byte integers have an internal byte order. The integer 0x12345678 (decimal 305419896) occupies four bytes, but which byte comes first in memory? Two answers exist:
0x78 0x56 0x34 0x12. This is what x86, x86-64, and most ARM systems use.0x12 0x34 0x56 0x78. This is what older PowerPC, SPARC, and many network protocols use.When you write an integer to disk with out.write(reinterpret_cast<const char*>(&n), sizeof(n)), you're writing the bytes in whatever order your machine uses. If the file is later read on a machine with a different endianness, the bytes are reassembled in the wrong order and the integer comes out wrong.
The original number was 0x12345678. After a round-trip across endianness boundaries, the reader sees 0x78563412, which is a completely different number (2018915346 in decimal). No error is raised. The integers just come out wrong.
The fix is to define a wire format: a documented byte order for the file, separate from whatever the machines happen to use natively. Network protocols traditionally pick big-endian (often called "network byte order"). On read and write, every multi-byte integer is converted between native order and wire order using helper functions. The standard C library has htonl and ntohl (host-to-network long, network-to-host long) for 32-bit values; modern code often uses std::byteswap (C++23) or hand-rolled byte-swap helpers.
For most C++ programs, the practical answer is simpler. If the file will only ever be read on the same architecture that wrote it (which is almost always true for a local cache, save file, or in-process database), endianness doesn't matter. If the file might be exchanged between machines, define a wire format and stick to it. Don't half-handle this: either fully convert every multi-byte value on every read and write, or document that the file is single-architecture only.
The same warning applies to floating-point types. A double on x86 is in IEEE 754 little-endian, and so is a double on ARM64, so in practice this rarely matters. But the C++ standard does not guarantee a particular floating-point format, only that whatever format the implementation uses is consistent within that implementation.
Binary and text formats both have a place. The choice is a trade-off between human readability, file size, portability, and performance.
| Property | Text | Binary |
|---|---|---|
| Human readable | Yes (open in any editor) | No (need a hex viewer) |
| File size (numeric data) | Larger (one byte per digit) | Smaller (4 or 8 bytes per number) |
| Write speed | Slower (formatting) | Faster (memory copy) |
| Read speed | Slower (parsing) | Faster (memory copy) |
| Random access | O(n) without index (variable-length lines) | O(1) with fixed records |
| Portability across architectures | High (ASCII / UTF-8 is universal) | Low (endianness, padding) |
| Portability across compilers | High | Low (struct layout, padding) |
| Version tolerance | High (parsers can skip unknown fields) | Low (offsets shift if record grows) |
| Diff-friendly | Yes | No |
| Good for | Config, logs, CSV, JSON, source code | Large data sets, fixed records, performance-critical I/O |
The rule of thumb most teams settle on: text by default, binary when you have a measured reason. A configuration file should always be text, because operators need to edit it and version control should diff it cleanly. A log file should usually be text for the same reasons. But a 50 GB time-series database, a save file for a game with millions of objects, or a binary protocol over a socket all justify binary: the size savings and the parse-free reads are worth the loss of readability.
Within a single program, the choice often comes down to whether the file ever leaves the process. An on-disk cache that one program writes and reads back can be binary with no headers, no versioning, no endianness concerns. A file that another tool (or another version of the same tool) will read needs a documented format, a version number at the start, and either a binary protocol with explicit byte order or a text format like JSON.
Binary I/O of fixed records is roughly 5 to 10 times faster than text I/O of the same data, because there's no parsing or formatting. For a million int values, a text dump might take 200 ms while a binary dump takes 20 ms on the same disk. The gap narrows for slow disks (the bottleneck is the disk, not the CPU) and widens for fast SSDs.
The last consideration is debugging. A bug in a text-based format is usually obvious: open the file, see the wrong number. A bug in a binary format requires a hex editor and a layout document. If you're choosing binary, plan for the moment when something goes wrong: print a record's bytes in hex, write a small dumper utility, or include a "validate this file" subcommand in the program itself. Future-you will thank present-you.
10 quizzes