A File Is Just Another Stream
Everything you learned about std::cout and std::cin applies to files unchanged. <fstream> gives you three types: std::ofstream for writing, std::ifstream for reading, and std::fstream for both. They use the same << and >> operators, the same std::getline, the same manipulators from <iomanip>, and the same failure states.
You open a file by naming it in the constructor. You do not have to close it: a file stream is an RAII type, so its destructor closes the file when the object goes out of scope, even if an exception is thrown on the way out. Calling close() explicitly is only useful when you want the file released earlier than the end of the block — for example before reopening it for reading.
The single most important habit is to check that the open succeeded. Opening a file can fail for many ordinary reasons: the path is wrong, the directory does not exist, you lack permission, another program has it locked. C++ does not throw for this by default. The stream simply enters a failed state, and every subsequent read returns nothing and every write goes nowhere. A program that skips this check appears to work and produces an empty file or an empty result, which is a genuinely confusing thing to debug.
A file stream converts to bool, so if (!file) is the idiomatic check and reads naturally. is_open() does the same job with a longer name. Either is fine; not checking is not.
One more carry-over from the output lesson: prefer '\n' to std::endl when writing files. endl flushes to disk on every line, and on a file with a lakh of lines that turns one efficient sequence of writes into a lakh of them.
#include <fstream>
#include <iostream>
#include <string>
int main() {
// --- writing ---
std::ofstream out("marks.txt");
if (!out) { // ALWAYS check
std::cerr << "could not open marks.txt for writing\n";
return 1;
}
out << "Ananya 91\n"; // '\n', not endl
out << "Rahul 65\n";
out.close(); // optional — the destructor would
// --- reading ---
std::ifstream in("marks.txt");
if (!in) {
std::cerr << "could not open marks.txt for reading\n";
return 1;
}
std::string line;
while (std::getline(in, line)) {
std::cout << line << '\n';
}
} // `in` closes automatically here - If your program "cannot find" a file that is clearly sitting next to your source code, the cause is almost always the working directory. A relative path like
"marks.txt"is resolved against the directory the program was launched from, not the directory the source or the executable lives in. Running from an IDE frequently means a different directory than running from a terminal.
Open Modes, and the Silent Truncate
This one deserves its own section because it destroys data. Opening a file with std::ofstream erases its existing contents. The default mode is "write from the beginning, discarding whatever was there", and no warning is given. Open a file to add one line to it, forget the mode, and the previous contents are gone.
To add to the end instead, pass std::ios::app as the second constructor argument. Every write then goes to the end of the file, whatever the file already contained. This is the mode you want for a log, a running record, or anything you are accumulating across runs of the program.
The modes are flags and can be combined with |. std::ios::in and std::ios::out select reading and writing, which the three stream types already imply. std::ios::app appends. std::ios::trunc is the explicit form of the truncating behaviour. std::ios::ate opens and seeks to the end but still allows writing elsewhere. std::ios::binary is covered in the last section.
One practical consequence: std::ofstream creates the file if it does not exist, whereas std::ifstream does not — it simply fails. So if you want "read the file if it exists, otherwise start fresh", the correct shape is to attempt the read, check whether it succeeded, and carry on with empty data if it did not.
std::ios::out— write. Implied byofstream, and it truncates by default.std::ios::app— every write goes to the end; existing contents are preserved.std::ios::trunc— explicitly discard existing contents.std::ios::in— read. Implied byifstream.std::ios::ate— open and seek to the end, but writes may go anywhere.std::ios::binary— no text translation; required for non-text data.
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
int main() {
// DESTROYS whatever marks.txt contained
std::ofstream fresh("marks.txt");
fresh << "Ananya 91\n";
fresh.close();
// Appends — the safe choice for a log
std::ofstream log("app.log", std::ios::app);
if (log) log << "run finished\n";
// "Load if present, otherwise start empty"
std::vector<std::string> saved;
{
std::ifstream in("marks.txt");
if (in) {
std::string line;
while (std::getline(in, line)) saved.push_back(line);
} else {
std::cout << "no saved data, starting fresh\n";
}
}
std::cout << saved.size() << " record(s) loaded\n";
} Reading Properly: the eof() Trap
There is one wrong way to write a file-reading loop that appears in a startling number of textbooks, and it produces a specific, recognisable bug: the last line is processed twice.
The wrong version is while (!file.eof()) { std::getline(file, line); ... }. The reason it fails is that eof() does not mean "the next read will fail" — it means "a previous read already hit the end". After reading the final line, the end-of-file flag is not yet set, because that read succeeded. The condition is still true, so the loop runs again, getline fails and leaves line unchanged, and your code processes the previous line's contents a second time.
The correct pattern uses the read itself as the condition. std::getline returns the stream, and a stream converts to true only when it is in a good state, so while (std::getline(file, line)) reads and tests in one step and stops exactly when the read fails. The same shape works for token-by-token reading: while (file >> word) or while (file >> name >> marks).
Choose between them the same way you did for console input. >> reads whitespace-separated tokens and is right when the file is a series of values. std::getline reads a whole line and is right when lines are the unit of meaning, or when fields can contain spaces — a name like "Ananya Sharma" would be split into two tokens by >>.
For the occasional case where you want the entire file as one string, read it with a loop and append, or use the stream iterators from <iterator>. Reading whole files into memory is fine for configuration and small data, and a poor idea for anything that might be gigabytes.
#include <fstream>
#include <iostream>
#include <string>
int main() {
std::ifstream in("marks.txt");
if (!in) { std::cerr << "cannot open\n"; return 1; }
// WRONG — processes the last line twice
// std::string line;
// while (!in.eof()) {
// std::getline(in, line);
// std::cout << line << '\n';
// }
// RIGHT — the read is the condition
std::string line;
while (std::getline(in, line)) {
std::cout << line << '\n';
}
// Token by token, when the file is a series of values
in.clear(); // reset the end-of-file flag
in.seekg(0); // rewind to the start
std::string name;
int marks = 0;
while (in >> name >> marks) {
std::cout << name << " scored " << marks << '\n';
}
} - After a loop ends, the stream is in a failed state. If you intend to read the same file again, you must call
clear()to reset the flags beforeseekg(0)will do anything — seeking on a failed stream is ignored. Forgetting theclear()produces a second loop that silently reads nothing.
Parsing Real Files, Text vs Binary, and Paths
Most files you will actually read have structure — CSV exports, log lines, configuration. The standard technique is two-level: read a whole line with std::getline, then feed that line into a std::istringstream and use std::getline again with a delimiter to split it into fields. This keeps line boundaries and field boundaries separate, which matters as soon as a field contains a space.
Real files also contain surprises, so parse defensively. Skip the header row if there is one. Skip blank lines. Wrap the number conversions in a try/catch, because std::stoi throws on a field that is not a number, and one bad row in a thousand should not abort the whole import — report it and continue.
So far everything has been text: numbers are written as digits, and reading them back means parsing. Binary mode writes the raw bytes of an object instead, using write and read with a reinterpret_cast to char*. It is faster and more compact, and it comes with a serious caveat: the layout depends on the compiler, the platform and the struct's padding, so a binary file written by one program may be unreadable by another. Use binary for your program's own temporary data, and text or a proper format for anything that has to be shared.
Never write a class containing a std::string or a std::vector to a binary file this way. Those objects hold pointers to heap memory, so what gets written is an address, and reading it back in another run gives you a pointer to nothing. Only plain data with fixed-size members can be dumped like this.
Finally, paths. Relative paths resolve against the working directory, which is a frequent source of "file not found". C++17's <filesystem> gives you tools for this: std::filesystem::exists, file_size, current_path, and a path type that joins components correctly on every platform. On Windows, remember that a backslash in a string literal must be doubled — or just use forward slashes, which Windows accepts too.
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
struct Record { std::string name; int roll; double marks; };
int main() {
namespace fs = std::filesystem;
std::cout << "running in: " << fs::current_path() << '\n';
if (!fs::exists("students.csv")) {
std::cerr << "students.csv not found\n";
return 1;
}
std::ifstream in("students.csv");
std::vector<Record> records;
std::string line;
std::getline(in, line); // discard the header row
int lineNo = 1;
while (std::getline(in, line)) {
++lineNo;
if (line.empty()) continue; // skip blanks
std::istringstream fields(line);
std::string name, rollText, marksText;
if (!std::getline(fields, name, ',') ||
!std::getline(fields, rollText, ',') ||
!std::getline(fields, marksText, ',')) {
std::cerr << "line " << lineNo << ": too few fields\n";
continue;
}
try {
records.push_back({name, std::stoi(rollText), std::stod(marksText)});
} catch (const std::exception& e) {
std::cerr << "line " << lineNo << ": " << e.what() << '\n';
}
}
std::cout << records.size() << " records loaded\n";
// Binary: raw bytes. Only for plain data, only for your own program.
int values[3] = {10, 20, 30};
std::ofstream bout("data.bin", std::ios::binary);
bout.write(reinterpret_cast<const char*>(values), sizeof(values));
bout.close();
int loaded[3] = {};
std::ifstream bin("data.bin", std::ios::binary);
bin.read(reinterpret_cast<char*>(loaded), sizeof(loaded));
std::cout << loaded[2] << '\n'; // 30
} <filesystem>requires C++17. On GCC 8 you also had to link with-lstdc++fs; from GCC 9 onwards it is included automatically, so if the linker complains about filesystem symbols, that flag is the first thing to try.
