C++ File I/O Explained: Reading, Writing and Real-World Patterns
Every meaningful program eventually needs to remember something. A game saves your progress. A bank logs every transaction. A compiler reads source code from disk. All of these rely on the same fundamental capability: reading from and writing to files. In C++, this isn't just a convenience — it's how your program graduates from a throwaway script to a real-world tool that persists state, shares data with other programs, and operates on inputs larger than what fits in memory.
Opening Files: ifstream, ofstream, and fstream — Picking the Right Tool
C++ gives you three stream classes for file work, all living in the
When you open a file, the operating system hands your program a file descriptor — a low-level handle to the actual bytes on disk. The C++ stream wraps that handle with buffering and operator overloading so you can use familiar >> and << syntax. The buffer is key: writes don't necessarily hit disk instantly. They accumulate in memory and flush when the buffer is full, when you explicitly call flush(), or when the stream is closed. This is why you must close files properly — or use RAII to let the destructor do it — otherwise buffered writes can vanish on a crash.
#include <iostream> #include <fstream> #include <string> int main() { // ofstream creates or overwrites the file — perfect for writing fresh output std::ofstream logWriter("server_log.txt"); // Always check if the file actually opened — never assume it did if (!logWriter.is_open()) { std::cerr << "ERROR: Could not open server_log.txt for writing.\n"; return 1; // Signal failure to the calling process } // Writing works just like std::cout — the << operator is overloaded for streams logWriter << "[INFO] Server started on port 8080\n"; logWriter << "[INFO] Accepting connections...\n"; // Explicitly closing flushes the buffer and releases the OS file handle logWriter.close(); // ifstream opens an existing file for reading only std::ifstream logReader("server_log.txt"); if (!logReader.is_open()) { std::cerr << "ERROR: Could not open server_log.txt for reading.\n"; return 1; } std::string line; std::cout << "--- Contents of server_log.txt ---\n"; // std::getline reads one full line at a time, handling newlines cleanly while (std::getline(logReader, line)) { std::cout << line << "\n"; } logReader.close(); return 0; }
[INFO] Server started on port 8080
[INFO] Accepting connections...
File Open Modes: Why std::ios::app Might Save Your Data
By default, opening a file with ofstream obliterates whatever was already in it. That's fine for generating a fresh report, but catastrophic if you're appending logs throughout the day. Open mode flags control exactly how the OS positions the read/write pointer when the file opens.
Modes are bitwise OR'd together, so you can combine them. The most important ones to internalize: std::ios::app moves the write pointer to the end of the file before every write — even if another process wrote to the file between your writes. std::ios::trunc (the default with ofstream) wipes the file. std::ios::binary skips the newline translation that text mode does on Windows, where becomes \r — this translation will silently corrupt binary data like images or serialized structs.
Using fstream with the right combination of flags gives you the revolving door: open once, read some data, seek to a position, write an update. This is exactly how a simple database index works — and understanding it separates developers who understand I/O from those who just copy-paste examples.
#include <iostream> #include <fstream> #include <string> #include <ctime> // Simulates writing timestamped events to an audit log void appendAuditEvent(const std::string& eventMessage) { // std::ios::app ensures we NEVER overwrite existing log entries std::ofstream auditLog("audit.log", std::ios::app); if (!auditLog) { std::cerr << "Failed to open audit log.\n"; return; } // Get current time as a simple timestamp string std::time_t now = std::time(nullptr); char timestamp[32]; std::strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", std::localtime(&now)); auditLog << "[" << timestamp << "] " << eventMessage << "\n"; // Destructor closes the file and flushes the buffer — RAII in action } // Demonstrates binary mode to write raw struct data without text translation struct PlayerScore { char username[32]; int score; int level; }; void saveBinaryScore(const PlayerScore& player) { // std::ios::binary prevents Windows from converting \n to \r\n in raw bytes std::ofstream scoreFile("scores.dat", std::ios::binary | std::ios::app); if (!scoreFile) { std::cerr << "Failed to open scores.dat.\n"; return; } // Write the raw bytes of the struct directly to disk scoreFile.write(reinterpret_cast<const char*>(&player), sizeof(PlayerScore)); } int main() { // Call this function multiple times — each run APPENDS, never overwrites appendAuditEvent("User 'alice' logged in"); appendAuditEvent("User 'alice' accessed /admin panel"); appendAuditEvent("User 'alice' logged out"); // Read back the audit log to confirm appending worked std::ifstream auditReader("audit.log"); std::string line; std::cout << "=== Audit Log ===\n"; while (std::getline(auditReader, line)) { std::cout << line << "\n"; } // Save a binary game score record PlayerScore topPlayer = {"alice", 98500, 42}; saveBinaryScore(topPlayer); std::cout << "\nBinary score record saved for: " << topPlayer.username << "\n"; return 0; }
[2024-11-15 14:32:07] User 'alice' logged in
[2024-11-15 14:32:07] User 'alice' accessed /admin panel
[2024-11-15 14:32:07] User 'alice' logged out
Binary score record saved for: alice
Seeking Through Files: Random Access with seekg and seekp
Sequential reading — going line by line from top to bottom — covers 80% of use cases. But what about updating a specific record in a file without rewriting the whole thing? That's where random access comes in, and it's where most tutorials drop the ball.
Every open file has a position pointer — imagine a cursor like in a text editor. seekg (seek get) moves the read cursor; seekp (seek put) moves the write cursor. Both take an offset and a reference point: std::ios::beg (start of file), std::ios::cur (current position), or std::ios::end (end of file). tellg and tellp return the current cursor position as a byte offset, which you can store and jump back to later.
This is exactly how SQLite's file format works at a conceptual level — pages of fixed size, seeked to directly by offset rather than scanned sequentially. You don't need to implement a database, but understanding seek lets you build config file patchers, binary data editors, and efficient log-rotation tools that don't load entire files into RAM.
#include <iostream> #include <fstream> #include <cstring> // Fixed-size record — essential for predictable seek arithmetic struct EmployeeRecord { char name[64]; int employeeId; double salary; }; // Write a set of records to a binary file void createEmployeeDatabase(const std::string& filename) { std::ofstream dbFile(filename, std::ios::binary); if (!dbFile) { std::cerr << "Cannot create employee database.\n"; return; } EmployeeRecord employees[] = { {"Alice Martin", 1001, 85000.00}, {"Bob Chen", 1002, 92000.00}, {"Carol Davis", 1003, 78500.00} }; for (const auto& emp : employees) { dbFile.write(reinterpret_cast<const char*>(&emp), sizeof(EmployeeRecord)); } // File closes here via RAII } // Update a single record by its zero-based index — no need to rewrite the file void updateSalary(const std::string& filename, int recordIndex, double newSalary) { // fstream with in|out|binary opens for both reading and writing std::fstream dbFile(filename, std::ios::in | std::ios::out | std::ios::binary); if (!dbFile) { std::cerr << "Cannot open employee database for update.\n"; return; } // Calculate the exact byte offset for the target record std::streampos targetOffset = recordIndex * sizeof(EmployeeRecord); // Read the existing record first to preserve non-salary fields dbFile.seekg(targetOffset); EmployeeRecord target; dbFile.read(reinterpret_cast<char*>(&target), sizeof(EmployeeRecord)); std::cout << "Updating " << target.name << "'s salary from $" << target.salary << " to $" << newSalary << "\n"; target.salary = newSalary; // Seek the WRITE pointer back to the same position and overwrite just this record dbFile.seekp(targetOffset); dbFile.write(reinterpret_cast<const char*>(&target), sizeof(EmployeeRecord)); } // Read and print all records sequentially void printAllEmployees(const std::string& filename) { std::ifstream dbFile(filename, std::ios::binary); if (!dbFile) { std::cerr << "Cannot read employee database.\n"; return; } EmployeeRecord emp; std::cout << "\n--- Employee Records ---\n"; while (dbFile.read(reinterpret_cast<char*>(&emp), sizeof(EmployeeRecord))) { std::cout << "ID: " << emp.employeeId << " | Name: " << emp.name << " | Salary: $" << emp.salary << "\n"; } } int main() { const std::string dbFilename = "employees.dat"; createEmployeeDatabase(dbFilename); printAllEmployees(dbFilename); // Update Bob Chen (index 1) without touching Alice or Carol's records updateSalary(dbFilename, 1, 97500.00); printAllEmployees(dbFilename); return 0; }
--- Employee Records ---
ID: 1001 | Name: Alice Martin | Salary: $85000
ID: 1002 | Name: Bob Chen | Salary: $92000
ID: 1003 | Name: Carol Davis | Salary: $78500
Updating Bob Chen's salary from $92000 to $97500
--- Employee Records ---
ID: 1001 | Name: Alice Martin | Salary: $85000
ID: 1002 | Name: Bob Chen | Salary: $97500
ID: 1003 | Name: Carol Davis | Salary: $78500
Error Handling and Stream State: Why Your Reads Silently Fail
File streams carry an internal state machine with four flags: goodbit (all clear), eofbit (end of file reached), failbit (a logical error — like reading an int when there's a letter), and badbit (a serious I/O error — like a hardware fault or lost network drive). The stream's operator bool() returns false if either failbit or badbit is set, which is why if (!stream) is a valid check.
The subtle trap is that eofbit alone doesn't set operator bool() to false — so a stream at EOF still evaluates to true until you try another read that fails. This is why the canonical while (getline(file, line)) loop works: getline tries to read, fails at EOF, sets failbit, and the loop exits correctly. The classic beginner mistake is checking eof() explicitly: while (!file.eof()) will always execute one extra iteration because eof is only set after a failed read.
For production code, checking the stream state after every significant operation is table stakes. If you're writing a config file parser or a data pipeline, a silent failbit means corrupt output — not a graceful error. Use exceptions for critical paths: file.exceptions(std::ios::failbit | std::ios::badbit) turns stream errors into catchable std::ios_base::failure exceptions.
#include <iostream> #include <fstream> #include <string> #include <stdexcept> // Parses a simple key=value config file with robust error handling void parseConfigFile(const std::string& configPath) { std::ifstream configFile; // Tell the stream to throw on badbit (hardware errors) but NOT failbit // We'll handle failbit manually for fine-grained control configFile.exceptions(std::ios::badbit); try { configFile.open(configPath); if (!configFile.is_open()) { throw std::runtime_error("Config file not found: " + configPath); } std::string line; int lineNumber = 0; // CORRECT pattern: test the result of getline, not eof() directly while (std::getline(configFile, line)) { ++lineNumber; // Skip blank lines and comments if (line.empty() || line[0] == '#') continue; // Find the separator between key and value size_t separatorPos = line.find('='); if (separatorPos == std::string::npos) { std::cerr << "WARNING: Malformed config at line " << lineNumber << ": '" << line << "'\n"; continue; // Don't abort — just skip the bad line } std::string key = line.substr(0, separatorPos); std::string value = line.substr(separatorPos + 1); std::cout << " Config loaded -> [" << key << "] = [" << value << "]\n"; } // After the loop, check WHY we stopped: EOF is fine, anything else is not if (configFile.bad()) { throw std::runtime_error("I/O error while reading config file."); } // eof() + fail() together means normal end-of-file — that's expected } catch (const std::ios_base::failure& ioError) { std::cerr << "Stream I/O failure: " << ioError.what() << "\n"; } catch (const std::runtime_error& appError) { std::cerr << "Application error: " << appError.what() << "\n"; } } void createSampleConfig(const std::string& path) { std::ofstream configWriter(path); configWriter << "# Sample application config\n"; configWriter << "database_host=localhost\n"; configWriter << "database_port=5432\n"; configWriter << "bad line without equals sign\n"; // intentional malformed line configWriter << "max_connections=100\n"; } int main() { const std::string configPath = "app.config"; createSampleConfig(configPath); std::cout << "Parsing config file...\n"; parseConfigFile(configPath); std::cout << "\nTesting with missing file...\n"; parseConfigFile("nonexistent.config"); return 0; }
Config loaded -> [database_host] = [localhost]
Config loaded -> [database_port] = [5432]
WARNING: Malformed config at line 4: 'bad line without equals sign'
Config loaded -> [max_connections] = [100]
Testing with missing file...
Application error: Config file not found: nonexistent.config
| Feature | Text Mode (default) | Binary Mode (std::ios::binary) |
|---|---|---|
| Newline handling | \n translated to \r\n on Windows | Raw bytes written — no translation |
| Human readable | Yes — open in any text editor | No — requires a parser to interpret |
| Safe for structs/images | No — byte values can be altered | Yes — bytes are preserved exactly |
| seekg/seekp reliability | Offsets unreliable due to translation | Offsets are exact and predictable |
| Typical use case | Log files, config files, CSV data | Images, audio, serialized objects, databases |
| Portability risk | High — line endings differ by OS | Low — bytes are bytes on every platform |
🎯 Key Takeaways
- ifstream reads, ofstream writes, fstream does both — pick the most restrictive one that meets your needs, never default to fstream for everything.
- Binary mode (std::ios::binary) is not optional for non-text data — on Windows, text mode silently mutates byte 0x0A and will corrupt images, structs, and binary records in ways that are almost impossible to debug.
- RAII is the right way to manage file lifetimes in modern C++ — let the destructor close and flush the stream instead of calling close() manually, because exceptions will skip your manual close() call.
- Random access with seekg/seekp only gives you predictable O(1) offsets with fixed-size records — variable-length formats require a separate index, which is exactly why binary formats with fixed page sizes dominate in databases and file systems.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Not checking if the file opened successfully — The stream silently stays in a failed state, and every subsequent read or write does nothing. Your program runs to completion producing empty output or stale data with zero error messages. Fix: Always check if (!stream.is_open()) or if (!stream) immediately after opening, and return or throw before doing any I/O.
- ✕Mistake 2: Using while (!file.eof()) as a loop condition — The eofbit is only set AFTER a read operation fails at end-of-file. This means your loop body executes one extra time with a blank or repeated last value, causing subtle data corruption bugs. Fix: Always loop on the read itself — while (std::getline(file, line)) or while (file >> value) — which returns false the moment any read fails.
- ✕Mistake 3: Writing binary data in text mode — On Windows, any byte with value 0x0A (newline) in your raw struct gets silently expanded to 0x0D 0x0A, corrupting the file and making sizeof-based seeks completely wrong. Fix: Always open files containing raw structs, images, or any non-text data with std::ios::binary. If in doubt, use binary mode — it's always safe, text mode is not always safe.
Interview Questions on This Topic
- QWhat is the difference between seekg and seekp, and in what scenario would you use fstream over ifstream or ofstream? Walk me through a concrete example.
- QWhy should you avoid while (!file.eof()) as a loop condition in C++, and what is the correct idiomatic replacement?
- QIf you open a file in text mode on Windows and write a binary struct containing a byte value of 0x0A, what happens — and how does opening in std::ios::binary change that behavior?
Frequently Asked Questions
What is the difference between ifstream, ofstream, and fstream in C++?
ifstream is read-only (input), ofstream is write-only (output), and fstream supports both reading and writing. Use ifstream when you only need to read — it makes your intent clear and prevents accidental writes. Only reach for fstream when you genuinely need to both read and write the same file in a single open, such as when updating records in place.
How do I append to a file in C++ without overwriting existing content?
Open the file with the std::ios::app flag: std::ofstream logFile("app.log", std::ios::app). The app flag positions the write pointer at the end of the file before every single write operation, even if the file was externally modified between writes. Without this flag, ofstream truncates the file by default the moment you open it.
Why does my C++ file read loop process the last line twice?
You're almost certainly using while (!file.eof()) as your loop condition. The eofbit is only set after a read operation fails because there's nothing left to read — so the loop body runs one final time before the flag is checked and found to be true. Fix it by looping on the read operation itself: while (std::getline(file, line)) or while (file >> value). The stream evaluates to false the moment any read fails, giving you exactly the right number of iterations.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.