File Handling in C++: Streams, std::filesystem and Practical Examples

File handling in C++ uses the stream classes ifstream, ofstream and fstream. Tested examples cover reading, writing, errors, binary files and C++17.

File handling in C++: data flowing as streams between a file and a program in both directions

Every useful program eventually needs to remember something after it exits — settings, scores, logs, data. In C++, that job belongs to file streams: the same << and >> machinery you already use with cout and cin, pointed at a file instead of the console. Learn one idea — a file is just another stream — and most of file handling comes free.

This guide covers the full modern toolkit: the three file stream classes and their open modes, reading and writing text, appending, proper error handling with stream states, parsing CSV data with stringstream, binary I/O done safely, and the C++17 std::filesystem library for working with files and directories themselves. Every program compiles clean with g++ -std=c++17 -Wall -Wextra and runs exactly as shown — outputs included.

Table of Contents

What Is File Handling in C++?

File handling in C++ means reading from and writing to files through the stream classes in the <fstream> header: ofstream writes to files, ifstream reads from them, and fstream does both. File streams inherit everything from the standard streams — the same <<, >>, and getline() you use with cout and cin work on files unchanged.

The three classes sit at the bottom of C++’s stream family, and seeing where they hang clarifies why they behave like cin and cout:

The C++ Stream Class Family ios shared state & formatting istream >> • getline() • get() • read() cin lives here ostream << • put() • write() cout lives here iostream input + output ifstream read from files fstream read and write ofstream write to files #include <fstream> brings in all three green classes

Everything an ostream can do (<<, put(), write()), an ofstream can do — because it is one. That inheritance relationship is C++ classes doing exactly what they were designed for, and it means this article is really about three small additions to what you already know: opening, closing, and checking.

If you are coming from C’s FILE* and fopen(), our companion guide to file handling in C covers that API — C++ programs can use either, but streams are the idiomatic choice. If streams themselves are new to you, start with the basics of C++ programming first.

Opening Files and Open Modes

A file stream opens either at construction or via open():

std::ofstream out("data.txt");                        // open at construction
std::ifstream in;
in.open("data.txt");                                  // or open later
std::ofstream log("app.log", std::ios::app);          // with an open mode

The open modes control how the file is opened, and they combine with |:

ModeEffect
std::ios::inOpen for reading (default for ifstream)
std::ios::outOpen for writing (default for ofstream)
std::ios::appAppend — every write goes to the end
std::ios::truncTruncate — destroy existing contents on open
std::ios::ateOpen and seek to the end (but writes can go anywhere)
std::ios::binaryBinary mode — no newline translation

Two defaults worth memorizing: an ofstream opened plainly truncates the file (out implies trunc unless app is given), and an fstream opens with in | out — reading and writing the same file, which is its whole reason to exist.

Writing to a File

#include <fstream>
#include <iostream>

int main()
{
    std::ofstream outfile("notes.txt");   // creates (or overwrites) the file

    if (!outfile.is_open()) {             // always check before writing
        std::cerr << "Could not open notes.txt for writing\n";
        return 1;
    }

    outfile << "This file is created with C++ streams.\n";
    outfile << "Line two: numbers work too: " << 42 << '\n';
    outfile << "The title of this article is File Handling in C++\n";

    std::cout << "notes.txt written successfully\n";
    return 0;
}   // outfile goes out of scope here -- RAII closes the file automatically

Output (and notes.txt now contains the three lines):

notes.txt written successfully

Note what is missing: a close() call. File streams are RAII types — the destructor flushes and closes the file when the object goes out of scope, on every exit path including exceptions. Call close() explicitly only when you need the file closed before the scope ends (to reopen it for reading, for instance, or to check that the final flush succeeded on critical data). “Always close manually” is advice from the era this article was first written in; modern C++ lets the object do its job.

Reading a File Line by Line

#include <fstream>
#include <iostream>
#include <string>

int main()
{
    std::ifstream infile("notes.txt");

    if (!infile.is_open()) {
        std::cerr << "Could not open notes.txt for reading\n";
        return 1;
    }

    std::string line;
    int lineNo = 1;
    while (std::getline(infile, line)) {   // the canonical read loop
        std::cout << lineNo++ << ": " << line << '\n';
    }
    return 0;
}

Output:

1: This file is created with C++ streams.
2: Line two: numbers work too: 42
3: The title of this article is File Handling in C++

while (std::getline(...)) is the canonical loop: getline returns the stream, the stream converts to true while reads succeed, and the loop exits cleanly at end of file. Note also std::string instead of a fixed char buffer — no size guessing, no truncation, no overflow.

The anti-pattern to unlearn: while (!infile.eof()). The eof flag is set only after a read fails, so an eof-controlled loop processes the last line twice (or processes garbage on a failed read). Test the read operation itself, never the flag in advance — this single fix resolves the most common file-handling bug in student C++.

Appending to a File

std::ios::app turns a log file into an actual log — opens never truncate, and every write lands at the end:

#include <fstream>
#include <iostream>
#include <string>

int main()
{
    /* ios::app -- every write goes to the end; the file is never truncated */
    std::ofstream log("app.log", std::ios::app);
    log << "New session started\n";
    log.close();

    std::ifstream in("app.log");
    std::string line;
    int count = 0;
    while (std::getline(in, line)) count++;
    std::cout << "app.log now has " << count << " line(s)\n";
    return 0;
}

Run it twice and the output proves the append (captured from two real runs):

app.log now has 1 line(s)
app.log now has 2 line(s)

Error Handling: Stream States

Every stream carries four state-testing member functions — and knowing which one fires when is what separates robust file code from lucky file code:

CheckTrue when…
is_open()The stream is attached to an open file
good()No error flags set — all clear
eof()A read hit the end of the file
fail()An operation failed (bad format, file missing, …)
bad()Unrecoverable I/O error (disk-level trouble)

Here they are working on real malformed input — a file containing 10 20 thirty 40:

#include <fstream>
#include <iostream>
#include <string>

int main()
{
    // numbers.txt contains: 10 20 thirty 40
    std::ofstream("numbers.txt") << "10 20 thirty 40\n";

    std::ifstream in("numbers.txt");
    int value;
    int sum = 0;

    while (in >> value) {          // stops at "thirty" -- extraction fails
        sum += value;
    }

    std::cout << "Sum before failure: " << sum << '\n';
    std::cout << "eof():  " << in.eof()  << '\n';   // 0: not at end of file
    std::cout << "fail(): " << in.fail() << '\n';   // 1: extraction failed

    in.clear();                    // reset the error flags
    std::string bad;
    in >> bad;                     // consume the offending token
    std::cout << "Offending token was: " << bad << '\n';

    while (in >> value)            // resume reading the rest
        sum += value;
    std::cout << "Final sum: " << sum << '\n';
    return 0;
}

Output:

Sum before failure: 30
eof():  0
fail(): 1
Offending token was: thirty
Final sum: 70

The output tells the whole story: the loop stopped at thirty with fail() set but eof() not set — the stream wasn’t at the end, it was stuck. clear() resets the flags, consuming the bad token unsticks it, and reading resumes. That clear-consume-resume pattern is the standard recovery for malformed input.

Parsing CSV Files with stringstream

Real files carry structure, and the streams toolkit handles that too: read each line with getline, then treat the line itself as a stream via std::istringstream and split it on commas:

#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>

struct Student {
    std::string name;
    int score;
};

int main()
{
    std::ofstream("grades.csv") << "Amina,91\nBilal,84\nChen,77\n";

    std::ifstream in("grades.csv");
    std::vector<Student> students;
    std::string line;

    while (std::getline(in, line)) {          // one CSV row per line
        std::istringstream row(line);         // treat the line as a stream
        Student s;
        std::string scoreText;

        if (std::getline(row, s.name, ',') && std::getline(row, scoreText)) {
            s.score = std::stoi(scoreText);
            students.push_back(s);
        }
    }

    int total = 0;
    for (const Student& s : students) {
        std::cout << s.name << " scored " << s.score << '\n';
        total += s.score;
    }
    std::cout << "Class average: " << total / static_cast<int>(students.size()) << '\n';
    return 0;
}

Output:

Amina scored 91
Bilal scored 84
Chen scored 77
Class average: 84

getline‘s third argument is the delimiter — ',' here — which is the entire trick. (Production-grade CSV with quoted fields and embedded commas deserves a library; for the data files and logs most programs actually read, this pattern is the workhorse.)

Binary Files Done Right

Binary I/O writes raw bytes with write() and read() — no formatting, no newline translation. It comes with one iron rule: only trivially copyable types with fixed sizes. Never write a struct containing std::string — a string holds a pointer to its text, so raw-writing it saves a memory address that means nothing when read back (a bug that has shipped in many old tutorials, including the previous version of this article):

#include <cstdint>
#include <fstream>
#include <iostream>
#include <type_traits>

/* Binary I/O rule: only trivially copyable types with FIXED sizes.
 * No std::string members -- a string holds a pointer, and writing a
 * pointer to disk saves an address, not the text. */
struct EmployeeRecord {
    std::int32_t id;
    char name[16];          // fixed-size buffer, not std::string
    double salary;
};
static_assert(std::is_trivially_copyable_v<EmployeeRecord>,
              "raw write() requires a trivially copyable type");

int main()
{
    EmployeeRecord out { 123, "Sara Malik", 5500.50 };

    /* write the raw bytes */
    std::ofstream ofs("employee.dat", std::ios::binary);
    ofs.write(reinterpret_cast<const char*>(&out), sizeof out);
    ofs.close();

    /* read them back into a fresh object */
    EmployeeRecord in {};
    std::ifstream ifs("employee.dat", std::ios::binary);
    ifs.read(reinterpret_cast<char*>(&in), sizeof in);

    if (ifs.gcount() == sizeof in) {   // verify the full record arrived
        std::cout << "id: " << in.id << ", name: " << in.name
                  << ", salary: " << in.salary << '\n';
        std::cout << "record size on disk: " << sizeof in << " bytes\n";
    }
    return 0;
}

Output:

id: 123, name: Sara Malik, salary: 5500.5
record size on disk: 32 bytes

That last line teaches something the naive byte-count misses: the members total 28 bytes (4 + 16 + 8), but the record occupies 32 — the compiler inserted padding to align the double. Which leads to the honest caveats: raw binary records are compiler- and platform-specific (padding, endianness, type sizes — hence the fixed-width std::int32_t). They are perfect for a program reading its own files on one platform; for files that cross systems or versions, use a defined serialization format instead.

Modern C++: The std::filesystem Library (C++17)

Streams read and write file contents; C++17’s <filesystem> finally gave the standard library the other half — working with files and directories themselves, portably:

#include <filesystem>
#include <fstream>
#include <iostream>

namespace fs = std::filesystem;   // the usual shorthand

int main()
{
    fs::create_directory("reports");                 // make a directory
    std::ofstream("reports/january.txt") << "Q1 data\n";
    std::ofstream("reports/february.txt") << "More Q1 data\n";

    std::cout << "exists: " << fs::exists("reports/january.txt") << '\n';
    std::cout << "size:   " << fs::file_size("reports/january.txt")
              << " bytes\n";

    std::cout << "directory listing:\n";
    for (const fs::directory_entry& entry : fs::directory_iterator("reports"))
        std::cout << "  " << entry.path().filename().string() << '\n';

    fs::rename("reports/january.txt", "reports/jan.txt");   // rename
    fs::remove("reports/february.txt");                     // delete

    std::cout << "after rename and remove:\n";
    for (const fs::directory_entry& entry : fs::directory_iterator("reports"))
        std::cout << "  " << entry.path().filename().string() << '\n';
    return 0;
}

Output:

exists: 1
size:   8 bytes
directory listing:
  february.txt
  january.txt
after rename and remove:
  jan.txt

The everyday vocabulary: fs::exists, fs::file_size, fs::create_directory / create_directories, fs::copy, fs::rename, fs::remove / remove_all, fs::directory_iterator (and its recursive sibling), plus the fs::path type that handles / vs \ separators for you. Before C++17 all of this required platform APIs or third-party libraries; now it ships in the standard library. One production note: every operation above also has an overload taking a std::error_code, for checking failures without exceptions.

Key Takeaways

  • File streams are ordinary streams: ofstream writes, ifstream reads, fstream does both, all from <fstream>, all speaking the same << / >> / getline language as cout and cin.
  • RAII closes files for you — the destructor flushes and closes on every exit path; explicit close() is for closing early, not a ritual.
  • Read with while (std::getline(file, line)) — never loop on !eof(), which fires only after a read fails and processes the last line twice.
  • Check your streams: is_open() after opening, the stream itself (or fail()) after operations, and clear() to recover from malformed input.
  • stringstream turns a line into a stream — the standard technique for parsing CSV and structured text with getline‘s delimiter argument.
  • Binary I/O is for trivially copyable, fixed-size types only — never raw-write std::string, expect struct padding (our 28-byte record occupies 32), and mind endianness across platforms.
  • C++17’s std::filesystem handles the files themselves — existence, sizes, directories, renaming, deleting — portably and in the standard library.

Frequently Asked Questions

Conclusion

The elegance of C++ file handling is that there is almost nothing new to learn: files are streams, streams are objects, and objects clean up after themselves. The genuinely new material lives at the edges — knowing which open mode you want, testing the right state flag, respecting the trivially-copyable rule in binary mode — and every one of those edges is where the bugs in older tutorials (including this article’s own previous version) used to hide.

From here, two directions are worth your time: the C file handling API that underlies and interoperates with all of this, and the broader stream and container machinery in our C++ tutorials — because once files are streams, the next realization is that everything in C++ wants to be one.

Scroll to Top