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++?
- Opening Files and Open Modes
- Writing to a File
- Reading a File Line by Line
- Appending to a File
- Error Handling: Stream States
- Parsing CSV Files with stringstream
- Binary Files Done Right
- Modern C++: The std::filesystem Library (C++17)
- Key Takeaways
- Frequently Asked Questions
- Conclusion
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:
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 |:
| Mode | Effect |
|---|---|
std::ios::in | Open for reading (default for ifstream) |
std::ios::out | Open for writing (default for ofstream) |
std::ios::app | Append — every write goes to the end |
std::ios::trunc | Truncate — destroy existing contents on open |
std::ios::ate | Open and seek to the end (but writes can go anywhere) |
std::ios::binary | Binary 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:
| Check | True 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:
ofstreamwrites,ifstreamreads,fstreamdoes both, all from<fstream>, all speaking the same<</>>/getlinelanguage ascoutandcin. - 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 (orfail()) after operations, andclear()to recover from malformed input. stringstreamturns a line into a stream — the standard technique for parsing CSV and structured text withgetline‘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::filesystemhandles 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.


