C++ file I/O | ifstream, ofstream, and binary reads/writes

Key takeaways

Read and write files with fstream: why streams fail silently instead of crashing, the buffering layer between write() and the disk, text vs binary mode on Windows, RAII close pitfalls, and why std::endl in a loop is a performance trap.

Why file stream I/O deserves careful attention

std::ifstream, std::ofstream, and std::fstream look deceptively simple — open a file, shove some data through << or >>, close it. In practice, the iostream library was designed decades ago around a philosophy of quiet failure rather than loud crashes, and that design choice is the source of most real-world bugs involving file streams. A stream that fails to open, or that hits a write error midway through, does not throw an exception and does not terminate your program. It just quietly stops doing anything, and your code keeps running as if nothing happened. This article focuses specifically on that stream-based I/O layer — ifstream/ofstream/fstream — rather than path manipulation or directory traversal, which are different concerns handled by std::filesystem.

Writing files (ofstream)

#include <fstream>
#include <iostream>
using namespace std;
int main() {
    ofstream file("output.txt");

    if (!file.is_open()) {
        cout << "Failed to open file" << endl;
        return 1;
    }

    file << "Hello, World!" << endl;
    file << "C++ file I/O" << endl;

    file.close();
    cout << "File saved" << endl;

    return 0;
}

This example checks is_open() right after construction, which is the single most important habit to build with fstream. ofstream’s constructor does not throw when the underlying open() call fails — a missing parent directory, a read-only filesystem, a permissions error, or a path that collides with an existing directory all produce the same outcome: a stream object that exists but is in a failed state. If you skip the check, every subsequent << on that stream becomes a silent no-op. Your program will report success, users will believe their data was saved, and nothing will actually be written to disk. This is arguably iostream’s biggest usability trap: it fails the way a network socket degrades under packet loss, not the way a null pointer dereference crashes — quietly, and only detectable if you’re specifically looking for it.

Reading files (ifstream)

#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
    ifstream file("input.txt");

    if (!file.is_open()) {
        cout << "Failed to open file" << endl;
        return 1;
    }

    string line;
    while (getline(file, line)) {
        cout << line << endl;
    }

    file.close();

    return 0;
}

The while (getline(file, line)) idiom works because getline returns a reference to the stream, and streams convert to a boolean based on their internal state flags (good(), eof(), fail(), bad()). Once the stream hits end-of-file or an error, the loop condition evaluates to false and reading stops — no separate eof() check is needed in the loop itself. The subtlety worth understanding is that fail() and eof() are not the same thing: a malformed line that can’t be parsed into the type you asked for (say, int extraction hitting a letter) also sets failbit, and once failbit is set, every future operation on that stream is a no-op until you call clear(). This means a single bad line in an otherwise well-formed file can silently stop all further reads with no crash and no visible symptom beyond “the rest of the file just didn’t get processed.”

Stream state and why it fails silently by design

This is worth dwelling on because it surprises even experienced C++ developers coming from languages where I/O failures throw exceptions by default. iostream’s exception behavior is opt-in:

ifstream file("data.txt");
file.exceptions(ifstream::failbit | ifstream::badbit);
// now a failed open or read throws std::ios_base::failure instead of setting a flag

Without that call, the stream falls back to a state-flag model inherited from C’s stdio conventions: good() means everything is fine, eof() means end-of-file was reached, fail() means a logical error occurred (bad format, failed open), and bad() means a more serious I/O error (hardware fault, broken pipe). The reason this design persists in modern C++ is largely historical inertia and backward compatibility — enabling exceptions by default in C++11 or later would have silently broken decades of existing code that relies on the flag-checking idiom. In my own experience, the practical rule that has saved me the most debugging time is: always check is_open() immediately after opening, and if you’re writing anything where silent data loss is unacceptable (financial records, configuration writes, anything users can’t easily reproduce), turn on exceptions() explicitly rather than trusting yourself to remember every flag check.

The buffering layer: why “written” doesn’t mean “on disk”

A detail that trips up a lot of people writing their first logging or save-file code: calling file << data does not mean the bytes are on the physical disk when that line returns. ofstream wraps a filebuf, which maintains its own internal buffer (typically a few kilobytes) to batch small writes into fewer, larger system calls — because a write() syscall for every character would be prohibitively slow. Data you write sits in that userspace buffer until one of a few things happens: the buffer fills up, you explicitly call flush() or use std::endl (which flushes), you call close(), or the stream object is destroyed.

flowchart LR
    A["file << data"] --> B["filebuf<br/>internal buffer"]
    B -->|"buffer full,<br/>flush(), or close()"| C["write() syscall"]
    C --> D["OS page cache"]
    D -->|"fsync or<br/>OS-scheduled writeback"| E["physical disk"]

Even after that write() syscall succeeds, the data typically lands in the operating system’s page cache, not the physical disk platter or SSD cells — the OS batches its own writeback for performance. This is why a program can report “file saved successfully,” and a power loss or hard crash a moment later can still result in an empty or truncated file. If durability genuinely matters — a transaction log, a database’s write-ahead log, a save file that must survive a crash — you need to go further than close() and either use platform APIs (fsync/FlushFileBuffers) via the native file descriptor, or accept that plain iostream gives you no durability guarantee beyond “the OS has it now.”

I ran into this the hard way once with a small utility that streamed intermediate results to a log file during a long batch job. The tool called ofstream::write() throughout the run and only called close() at the very end, once, after the whole batch completed. During a test run, the machine’s power supply died about ninety percent of the way through — and after rebooting, the log file was essentially empty except for a handful of lines. Nothing was “lost” in the sense of iostream misbehaving; the writes had genuinely never made it past the buffer, because I had structured the code around a single trailing close() instead of periodic flush() calls after each meaningful chunk of work. The fix wasn’t complicated — flush after each logged unit of work — but the incident is a good illustration of why “the code called write() and didn’t error” is not the same claim as “the data is safe.”

File modes

// overwrite
ofstream file1("file.txt");
// append
ofstream file2("file.txt", ios::app);
// read
ifstream file3("file.txt");
// read + write
fstream file4("file.txt", ios::in | ios::out);
// binary
ofstream file5("file.bin", ios::binary);

Each open mode changes stream behavior in ways that are easy to get subtly wrong. ios::app doesn’t just start writing at the end of the file — every write, regardless of where seekp last positioned the stream, is atomically appended at the current end of file. That matters for multi-process logging: two processes appending to the same file with ios::app won’t interleave writes mid-line the way naive seekp-then-write logic could. ios::in | ios::out on an fstream opens for both reading and writing without truncating, which is what you want for in-place binary record updates, but note that switching between reading and writing on the same stream without an intervening seekg/seekp call is undefined behavior in the standard — a gotcha that’s easy to hit when refactoring code that used to be read-only or write-only.

Text mode vs binary mode

ofstream file("data.bin", ios::binary);

On POSIX systems (Linux, macOS), text mode and binary mode are functionally identical — there’s no translation layer. On Windows, they are not. In text mode, the C runtime translates every \n you write into a \r\n pair on the way out, and does the reverse translation on the way in. For genuine text files this is usually invisible and even convenient, since it matches the platform’s native line-ending convention. But if you’re writing raw binary data — a serialized struct, an image, a compressed blob, network packet capture — and that data happens to contain the byte 0x0A (which is exactly the ASCII value of \n) anywhere in its byte stream, text mode will “helpfully” rewrite it into two bytes, 0x0D 0x0A, corrupting your data. 0x0A is an extremely common byte value to appear incidentally inside binary payloads, so this isn’t a rare edge case — it’s something you will eventually hit if binary data ever goes through a text-mode stream on Windows.

I’ve been bitten by exactly this once, early in a project that had to run on both Linux and Windows. A serialization routine wrote fixed-size struct records to disk using ofstream opened without ios::binary, because the code had originally been written and tested only on Linux, where the missing flag has zero visible effect. It worked flawlessly in CI on Linux for weeks. The first time someone ran the same build on a Windows machine, files that were supposed to be an exact multiple of the struct size came out slightly larger and unreadable — extra 0x0D bytes had been injected wherever a struct field’s raw bytes happened to contain 0x0A. The fix was a one-word addition, ios::binary, but tracking down why deserialization was misaligned on Windows only, and only for certain records, took far longer than it should have, precisely because the corruption was silent, platform-specific, and data-dependent rather than a hard crash.

RAII close vs explicit close

{
    ofstream file("output.txt");
    file << "data";
}
// file is closed here automatically when it goes out of scope

fstream’s destructor calls close() for you, which is genuinely useful — you don’t leak file handles just because you forgot to close something, and exception-safety comes for free since the destructor runs during stack unwinding. But there’s a real cost to relying on it exclusively: a destructor in C++ cannot safely throw, and by convention, none of the standard library’s destructors do. If the final flush inside that implicit close() fails — because the disk filled up, a network drive was disconnected, or a quota was hit — that failure has no channel to reach your code. The stream’s failbit gets set internally, but nobody is checking it, because the object is already being destroyed and there’s no line of your code executing at that point to inspect it.

ofstream file("output.txt");
file << "data";
file.close();
if (!file) {
    // handle the failure — this is the only place you'll ever see it
    cerr << "Write failed or could not flush to disk" << endl;
}

Calling close() explicitly and then checking the stream’s state afterward is the only way to actually observe a late-stage write failure. This matters most for anything where “the save silently didn’t happen” is a real cost to the user — configuration files, exported reports, anything without an easy retry path. For a scratch temp file or a log where you don’t care about individual write failures, relying on RAII’s implicit close is perfectly fine and is exactly the trade-off RAII is designed to make: convenience and safety-net cleanup in exchange for not being able to react to the outcome.

sequenceDiagram
    participant Code as Your code
    participant Stream as ofstream
    participant OS as OS / disk

    Code->>Stream: write data
    Stream->>Stream: buffer in filebuf
    Code->>Stream: close() (explicit)
    Stream->>OS: flush + close syscall
    OS-->>Stream: success or failure
    Stream-->>Code: failure visible via operator bool
    Note over Code,OS: Without explicit close(),\nthe same failure happens\nin the destructor and is unobservable

std::endl vs ‘\n’ — a performance trap

ofstream file("large_log.txt");
for (int i = 0; i < 1'000'000; ++i) {
    file << "log line " << i << endl; // flushes on every iteration
}

std::endl does two things: it inserts a '\n' character, and it calls flush(). That second part is the trap. Flushing forces the buffered data out through a system call immediately, which defeats the entire purpose of the buffering layer described earlier. In a tight loop writing a million lines, that’s a million unnecessary syscalls instead of a few hundred large, buffer-sized writes — the difference in wall-clock time can easily be an order of magnitude or more, and it’s one of the most common “why is my C++ file writing so slow” questions. The fix is almost always to use '\n' instead and let the buffer do its job:

ofstream file("large_log.txt");
for (int i = 0; i < 1'000'000; ++i) {
    file << "log line " << i << '\n'; // no forced flush
}
file.flush(); // flush once, when you actually need the guarantee

This doesn’t mean flush() or endl are mistakes to avoid entirely — they’re the right tool when you specifically need a durability checkpoint, such as after writing a complete, meaningful unit of work in a crash-sensitive log. The mistake is reaching for endl reflexively out of habit in every line of a hot loop, which is exactly what happens when code gets copy-pasted from a small example (where the performance difference is invisible) into a production path processing large volumes of data.

A CSV reader, a logger, and binary struct dumps

Reading a CSV file line by line

#include <fstream>
#include <sstream>
#include <vector>
#include <iostream>
using namespace std;
struct Student {
    string name;
    int age;
    double score;
};
vector<Student> readCSV(const string& filename) {
    vector<Student> students;
    ifstream file(filename);

    if (!file.is_open()) {
        cout << "Failed to open file" << endl;
        return students;
    }

    string line;
    getline(file, line);  // skip header

    while (getline(file, line)) {
        stringstream ss(line);
        Student s;
        string token;

        getline(ss, s.name, ',');
        getline(ss, token, ',');
        s.age = stoi(token);
        getline(ss, token, ',');
        s.score = stod(token);

        students.push_back(s);
    }

    file.close();
    return students;
}
int main() {
    vector<Student> students = readCSV("students.csv");

    for (const auto& s : students) {
        cout << s.name << ", age " << s.age << ", score " << s.score << endl;
    }

    return 0;
}

Notice that this parser has no defense against a malformed row: if a line has fewer than three comma-separated fields, getline(ss, token, ',') will fail to extract anything into token, stoi/stod will throw std::invalid_argument on an empty string, and the whole function will propagate an unhandled exception out of readCSV. Real-world CSV data — exported from spreadsheets, hand-edited, or generated by another system with slightly different assumptions — routinely contains ragged rows, stray quotes, or an extra trailing comma. Production CSV parsing code needs to either wrap each row’s parsing in a try/catch and skip or log the bad row, or validate field counts before conversion, rather than assuming every row matches the header’s shape.

An append-only log file with explicit flushes

#include <fstream>
#include <iostream>
#include <ctime>
using namespace std;
class Logger {
private:
    ofstream logFile;

    string getCurrentTime() {
        time_t now = time(0);
        char buf[80];
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", localtime(&now));
        return string(buf);
    }

public:
    Logger(const string& filename) {
        logFile.open(filename, ios::app);
    }

    ~Logger() {
        if (logFile.is_open()) {
            logFile.close();
        }
    }

    void info(const string& message) {
        logFile << "[" << getCurrentTime() << "] [INFO] "
                << message << '\n' << flush;
    }

    void error(const string& message) {
        logFile << "[" << getCurrentTime() << "] [ERROR] "
                << message << '\n' << flush;
    }
};
int main() {
    Logger logger("app.log");

    logger.info("program start");
    logger.info("loading data...");
    logger.error("file not found");
    logger.info("program exit");

    return 0;
}

This Logger class is a good illustration of a reasonable trade-off between the performance guidance above and the durability guidance from the buffering section: it uses '\n' plus an explicit flush rather than endl, which is functionally identical to endl in behavior (both flush) but makes the intent explicit at the call site — a maintainer reading '\n' << flush immediately understands that the flush is deliberate, whereas endl buried in a chain of << operators is easy to miss. For a logger specifically, flushing on every call is usually the right call despite the per-call cost, because the entire point of a log is to have an accurate record available for postmortem debugging even if the process crashes immediately after writing an entry — the durability benefit outweighs the throughput cost for anything short of an extremely hot logging path. Also worth noting: the constructor never checks whether logFile.open() succeeded, so if the log directory doesn’t exist or isn’t writable, every subsequent info()/error() call becomes a silent no-op — the exact failure mode discussed earlier in this article, now hiding inside a class that looks complete.

Writing and reading a struct as raw bytes

#include <fstream>
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
struct GameSave {
    int level;
    int score;
    int lives;
    char playerName[50];
};
void saveGame(const string& filename, const GameSave& save) {
    ofstream file(filename, ios::binary);
    file.write(reinterpret_cast<const char*>(&save), sizeof(GameSave));
    file.close();
    cout << "Game saved" << endl;
}
GameSave loadGame(const string& filename) {
    GameSave save{};
    ifstream file(filename, ios::binary);

    if (file.is_open()) {
        file.read(reinterpret_cast<char*>(&save), sizeof(GameSave));
        file.close();
        cout << "Game loaded" << endl;
    }

    return save;
}
int main() {
    GameSave save{};
    save.level = 10;
    save.score = 5000;
    save.lives = 3;
    strcpy(save.playerName, "Player1");

    saveGame("save.dat", save);

    GameSave loaded = loadGame("save.dat");
    cout << "level: " << loaded.level << endl;
    cout << "score: " << loaded.score << endl;
    cout << "lives: " << loaded.lives << endl;
    cout << "name: " << loaded.playerName << endl;

    return 0;
}

This pattern — dumping a struct’s raw memory layout straight to disk with reinterpret_cast — is fast to write and fast to run, but it bakes in every assumption about your platform’s memory layout: struct padding, member alignment, and integer endianness all become part of your file format whether you intended that or not. A save file written by a build compiled for x86-64 with one compiler’s padding rules is not guaranteed to load correctly on a different architecture, a different compiler, or even the same compiler with a different struct layout after you add a field in a later version. This is fine for something like a single-player game save that’s never shared across machines or app versions, which is exactly the use case in this example. It becomes a real liability the moment you need forward compatibility (loading an older save format after adding fields), cross-platform support, or interoperability with another program — at which point a length-prefixed, versioned, and endianness-explicit format (or a serialization library) is worth the added complexity.

Unchecked opens, lost buffers, and text-mode corruption

Not checking whether the file opened

Symptom: Empty reads on missing files, or writes that appear to succeed but leave no file changes.

Cause: Skipping is_open().

ifstream file("nonexistent.txt");
if (!file.is_open()) {
    cerr << "Failed to open file" << endl;
    return 1;
}

Forgetting to flush or close before a crash

Symptom: Truncated or empty output after a crash, even though the program appeared to run to completion up to that point.

Fix: Call close() explicitly and check the resulting state for anything where the write actually mattering is non-negotiable; otherwise, understand that scope-exit RAII close covers you for cleanup but not for error reporting.

{
    ofstream file("output.txt");
    file << "data";
}

Text mode mangling binary data on Windows

Symptom: Corrupt numeric or binary data on Windows specifically (identical code is fine on Linux/macOS), often showing up as data that’s slightly larger than expected and misaligned when read back.

Fix: Open with ios::binary for raw structs, serialized data, or any payload that isn’t genuinely line-oriented text.

ofstream file("data.bin", ios::binary);

FAQ

Q1: Test if a file exists?

A: Try opening it, or use std::filesystem::exists.

bool fileExists(const string& filename) {
    ifstream file(filename);
    return file.is_open();
}

Q2: Read entire file into a string?

A:

#include <fstream>
#include <sstream>
string readFile(const string& filename) {
    ifstream file(filename);
    stringstream buffer;
    buffer << file.rdbuf();
    return buffer.str();
}

Q3: File size?

A: seekg/tellg (binary mode avoids text-mode quirks affecting the byte count).

ifstream file("file.txt", ios::binary);
file.seekg(0, ios::end);
size_t size = file.tellg();
file.seekg(0, ios::beg);

Q4: Large files?

A: Read line by line or in fixed-size chunks rather than loading the whole file into memory at once.

ifstream file("large.txt");
string line;
while (getline(file, line)) {
}

Q5: Non-ASCII paths?

A: Use std::filesystem::path and wide-character APIs where required on Windows.

#include <filesystem>
namespace fs = std::filesystem;
fs::path p = U"path/with/unicode";  // platform-specific helpers as needed

Q6: Multiple files at once?

A: Use separate stream objects — there’s no shared state between independent ifstream/ofstream instances.

ifstream file1("input1.txt");
ifstream file2("input2.txt");
ofstream output("output.txt");

The root cause behind most file I/O bugs

The mechanics of ifstream/ofstream/fstream are simple, but the failure modes are not, and almost all of them share the same root cause: iostream was designed to fail through state flags rather than exceptions, and to buffer aggressively rather than write through to disk on every call. Checking is_open()/good() after opening, understanding that close() (not just going out of scope) is where you actually learn whether a write succeeded, opening with ios::binary for anything that isn’t genuinely text, and reaching for '\n' instead of std::endl in hot loops will eliminate the majority of file I/O bugs that show up in real production code.