Skip to main content
C++ · lesson 19 of 24
C++ intermediate Lesson 19 of 24

File I/O in C++

C++ file I/O run for real: fstream open modes and silent failures, line/word/whole-file reads, CSV parsing, binary records, and std::filesystem.

By SkillByExample · Updated

<fstream> streams are RAII wrappers: the constructor opens the file and the destructor closes it, so handles don’t leak even when exceptions fly. C++17’s <filesystem> adds portable path and directory operations. Every example below runs in an empty directory.

Opening Files: ifstream and ofstream

std::ofstream writes, std::ifstream reads. The important part is what happens when an open fails.

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

int main() {
    {
        std::ofstream out("output.txt");            // creates or truncates
        if (!out) { std::cerr << "cannot open output.txt\n"; return 1; }
        out << "Hello, file!\n" << 42 << "\n";
    }                                               // closed (and flushed) here

    std::ifstream in("output.txt");
    std::string line;
    int n = 0;
    while (std::getline(in, line)) std::cout << ++n << ": " << line << "\n";

    std::ifstream missing("no-such-file.txt");
    std::cout << std::boolalpha << "missing opened? " << missing.is_open() << "\n";
    int value = -1;
    missing >> value;                               // silently does nothing
    std::cout << "read from missing: " << value << ", fail() = " << missing.fail() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o basic basic.cpp
$ ./basic
1: Hello, file!
2: 42
missing opened? false
read from missing: -1, fail() = true

The inner braces close out, which flushes it, before the file is reopened for reading. The missing file is the lesson: opening it threw nothing, and reading from it threw nothing either — value kept its -1 and only fail() reveals the problem. Check a stream immediately after opening it (if (!in)), or call in.exceptions(std::ios::failbit) to make failures throw.

Open Modes

Modes combine with | and decide whether existing content is kept, truncated or appended to.

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

std::string slurp(const char* path) {
    std::ifstream in(path);
    std::ostringstream buf;
    buf << in.rdbuf();
    return buf.str();
}

int main() {
    { std::ofstream log("app.log"); log << "start\n"; }
    { std::ofstream log("app.log", std::ios::app); log << "appended\n"; }
    std::cout << "after app:\n" << slurp("app.log");

    { std::ofstream log("app.log"); log << "oops\n"; }       // default mode truncates
    std::cout << "after plain ofstream:\n" << slurp("app.log");

    {
        std::fstream rw("app.log", std::ios::in | std::ios::out);  // no truncation
        rw.seekp(0);
        rw << "OO";                                             // overwrite 2 bytes
    }
    std::cout << "after in|out overwrite:\n" << slurp("app.log");
}
$ g++ -std=c++20 -Wall -Wextra -o modes modes.cpp
$ ./modes
after app:
start
appended
after plain ofstream:
oops
after in|out overwrite:
OOps

std::ios::app sends every write to the end. A plain std::ofstream defaults to out | trunc, so the third open silently wiped both lines — the most common way to lose a log file. in | out opens an existing file without truncating it, and seekp(0) positioned the write at the start, so “OO” overwrote the first two bytes of “oops”. There is no insert mode: writing in the middle of a file overwrites.

Reading Strategies

Line by line, token by token, or everything at once:

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

int main() {
    std::ifstream in("poem.txt");
    std::string line;
    int lines = 0;
    while (std::getline(in, line)) ++lines;                // line by line

    in.clear();                                             // reset EOF state...
    in.seekg(0);                                            // ...and rewind
    std::string word;
    int words = 0;
    while (in >> word) ++words;                             // whitespace-separated tokens

    std::ifstream again("poem.txt");
    std::ostringstream buf;
    buf << again.rdbuf();                                   // whole file at once
    std::string all = buf.str();

    std::cout << lines << " lines, " << words << " words, " << all.size() << " bytes\n";
    std::cout << "last word: " << word << "\n";
}
$ printf 'Roses are red,\nviolets are blue,\nC++ streams are RAII\nand so are you.\n' > poem.txt
$ g++ -std=c++20 -Wall -Wextra -o reading reading.cpp
$ ./reading
4 lines, 14 words, 70 bytes
last word: you.

After the getline loop hits end-of-file, the stream is in a failed state; clear() resets it and seekg(0) rewinds — without clear(), the word loop would read nothing. >> skips any whitespace, so line breaks don’t matter to it. buf << again.rdbuf() copies the whole file in one go; its 70 bytes include the four newlines that getline strips.

Parsing CSV with std::istringstream

An std::istringstream turns one line into a stream, and std::getline(ss, field, ',') reads up to each comma.

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

struct Record { std::string name; int age; double score; };

int main() {
    std::ifstream in("people.csv");
    std::string line;
    std::getline(in, line);                                // skip the header
    std::vector<Record> records;
    int line_no = 1;
    while (std::getline(in, line)) {
        ++line_no;
        std::istringstream ss(line);
        Record r;
        std::string age, score;
        std::getline(ss, r.name, ',');                     // read up to each comma
        std::getline(ss, age, ',');
        std::getline(ss, score, ',');
        try {
            r.age = std::stoi(age);
            r.score = std::stod(score);
            records.push_back(r);
        } catch (const std::exception& e) {
            std::cout << "line " << line_no << " skipped (" << e.what() << "): " << line << "\n";
        }
    }
    for (const auto& r : records) std::cout << r.name << " | " << r.age << " | " << r.score << "\n";
}
$ printf 'name,age,score\nAlice,30,98.5\nBob,n/a,87\nCarol,41,77.25\n' > people.csv
$ g++ -std=c++20 -Wall -Wextra -o csv csv.cpp
$ ./csv
line 3 skipped (stoi): Bob,n/a,87
Alice | 30 | 98.5
Carol | 41 | 77.25

Bob’s age isn’t a number, so std::stoi threw std::invalid_argument (whose what() in libstdc++ is just the function name) and the line was skipped with a message instead of crashing the whole import. Real-world CSV also has quoted fields containing commas, which this simple splitter doesn’t handle — use a CSV library for anything you don’t control.

Binary File I/O

write() and read() move raw bytes. Open with std::ios::binary and cast the object’s address to char*.

#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <vector>

struct Packet { std::uint32_t id; float value; };

int main() {
    std::vector<Packet> out_packets{{1, 1.5f}, {2, -2.0f}};
    {
        std::ofstream out("data.bin", std::ios::binary);
        std::uint32_t count = out_packets.size();
        out.write(reinterpret_cast<const char*>(&count), sizeof count);
        out.write(reinterpret_cast<const char*>(out_packets.data()), count * sizeof(Packet));
    }
    std::cout << "sizeof(Packet) = " << sizeof(Packet) << ", file size = "
              << std::filesystem::file_size("data.bin") << " bytes\n";

    std::ifstream in("data.bin", std::ios::binary);
    std::uint32_t count = 0;
    in.read(reinterpret_cast<char*>(&count), sizeof count);
    std::vector<Packet> packets(count);
    in.read(reinterpret_cast<char*>(packets.data()), count * sizeof(Packet));
    for (const auto& p : packets) std::cout << "id " << p.id << " value " << p.value << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o binary binary.cpp
$ ./binary
sizeof(Packet) = 8, file size = 20 bytes
id 1 value 1.5
id 2 value -2
$ od -A d -t x1 data.bin
0000000 02 00 00 00 01 00 00 00 00 00 c0 3f 02 00 00 00
0000016 00 00 00 c0
0000020

The file is 4 bytes of count plus two 8-byte packets: 20 bytes. od shows the layout exactly: 02 00 00 00 is the count in little-endian byte order (least significant byte first, as x86 stores it), then 01 00 00 00 for id 1 and 00 00 c0 3f, the IEEE-754 float 1.5, also little-endian. A big-endian machine would read the count as 33554432. That, plus padding and type sizes, is why raw struct dumps are not a portable file format.

std::filesystem (C++17)

Paths, directories, copying and iteration, portably:

#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <vector>
namespace fs = std::filesystem;

int main() {
    fs::path root = "demo";
    fs::create_directories(root / "data" / "output");        // makes every missing level
    fs::path file = root / "data" / "input.csv";
    std::ofstream(file) << "a,b\n1,2\n";                       // temporary stream, closed at ;

    std::cout << "path " << file << "\n  parent " << file.parent_path() << ", filename "
              << file.filename() << ", stem " << file.stem() << ", ext " << file.extension() << "\n";
    std::cout << "size " << fs::file_size(file) << " bytes\n";

    fs::copy(file, root / "data" / "output" / "input.csv", fs::copy_options::overwrite_existing);
    fs::rename(root / "data" / "output" / "input.csv", root / "data" / "output" / "done.csv");

    std::vector<fs::path> all;
    for (const auto& entry : fs::recursive_directory_iterator(root)) all.push_back(entry.path());
    std::sort(all.begin(), all.end());                         // iteration order is unspecified
    for (const auto& p : all) std::cout << (fs::is_directory(p) ? "[dir]  " : "[file] ") << p.string() << "\n";

    std::error_code ec;
    auto n = fs::file_size("missing.txt", ec);                 // no exception
    std::cout << "missing.txt: " << ec.message() << " (returned " << static_cast<long long>(n) << ")\n";
    std::cout << "removed " << fs::remove_all(root) << " entries\n";
    try { std::cout << fs::file_size("missing.txt"); }
    catch (const fs::filesystem_error& e) { std::cout << "throwing overload: " << e.what() << "\n"; }
}
$ g++ -std=c++20 -Wall -Wextra -o fs fs.cpp
$ ./fs
path "demo/data/input.csv"
  parent "demo/data", filename "input.csv", stem "input", ext ".csv"
size 8 bytes
[dir]  demo/data
[file] demo/data/input.csv
[dir]  demo/data/output
[file] demo/data/output/done.csv
missing.txt: No such file or directory (returned -1)
removed 5 entries
throwing overload: filesystem error: cannot get file size: No such file or directory [missing.txt]

operator/ joins path components with the platform’s separator. create_directories made all three levels at once. recursive_directory_iterator visits entries in an unspecified order, so the program sorts them before printing. The last lines show the two error styles: the std::error_code overload of file_size reports “No such file or directory” and returns static_cast<uintmax_t>(-1), printed here as -1, while the throwing overload raises filesystem_error with the operation and path in its message. remove_all returns how many entries it deleted: 5 files and directories.

Putting It Together: a File Processor

Iterate a directory, filter by extension, transform each file line by line into a parallel output directory:

#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace fs = std::filesystem;

int main() {
    fs::path input = "input", output = "output";
    fs::create_directories(output);
    for (const auto& entry : fs::directory_iterator(input)) {
        if (entry.path().extension() != ".txt") continue;         // skip other files
        std::ifstream in(entry.path());
        std::ofstream out(output / entry.path().filename());
        std::string line;
        int count = 0;
        while (std::getline(in, line)) out << ++count << ": " << line << "\n";
        std::cout << "processed " << entry.path().filename() << " (" << count << " lines)\n";
    }
}
$ mkdir input && printf 'first\nsecond\n' > input/notes.txt && printf 'x\n' > input/todo.txt && printf 'skip me' > input/image.png
$ g++ -std=c++20 -Wall -Wextra -o processor processor.cpp
$ ./processor
processed "todo.txt" (1 lines)
processed "notes.txt" (2 lines)
$ cat output/notes.txt
1: first
2: second

image.png was skipped by the extension check, and each .txt file got numbered lines in output/. directory_iterator order is unspecified, so the two “processed” lines can appear in either order on another filesystem.

Practice

  1. Make basic.cpp throw instead of failing silently when no-such-file.txt can’t be opened.

    Solution
    std::ifstream missing;
    missing.exceptions(std::ios::failbit | std::ios::badbit);
    try {
        missing.open("no-such-file.txt");
    } catch (const std::ios_base::failure& e) {
        std::cout << "open failed: " << e.what() << "\n";
    }

    Set exceptions() before open(). Beware that with failbit enabled, a getline loop also throws when it reaches end of file.

  2. Write a function count_lines(const fs::path&) that returns std::optional<int> — empty if the file can’t be opened.

    Solution
    std::optional<int> count_lines(const std::filesystem::path& p) {
        std::ifstream in(p);
        if (!in) return std::nullopt;
        int n = 0;
        for (std::string line; std::getline(in, line);) ++n;
        return n;
    }

    count_lines("poem.txt") returns 4, and a missing file gives std::nullopt.

  3. In csv.cpp, also reject lines where the age parses but has trailing junk, like Dan,4x,50.

    Solution
    std::size_t used = 0;
    r.age = std::stoi(age, &used);
    if (used != age.size()) throw std::invalid_argument("age");

    std::stoi("4x") returns 4 and sets used to 1; comparing it to the field length catches the leftover x.

  4. Change binary.cpp to store the count as big-endian bytes explicitly, so the file is the same on every platform.

    Solution
    unsigned char be[4] = {
        static_cast<unsigned char>(count >> 24), static_cast<unsigned char>(count >> 16),
        static_cast<unsigned char>(count >> 8),  static_cast<unsigned char>(count)};
    out.write(reinterpret_cast<const char*>(be), 4);
    // reading:
    unsigned char b[4];
    in.read(reinterpret_cast<char*>(b), 4);
    std::uint32_t count = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3];

    Shifts operate on values, not memory layout, so this code produces 00 00 00 02 on any machine.

  5. Extend processor.cpp to print the total size in bytes of all .txt files it processed, using std::filesystem.

    Solution
    std::uintmax_t total = 0;
    // inside the loop, after the extension check:
    total += entry.file_size();
    // after the loop:
    std::cout << "total " << total << " bytes\n";

    directory_entry::file_size() may use cached metadata from the directory scan. For the sample input it prints 15 (13 + 2).

Frequently Asked Questions

When should I open a file in binary mode?
Use std::ios::binary for non-text data such as images or serialized structs, where every byte must be preserved. On Windows, text mode translates \n to \r\n and back, which corrupts binary data; on Linux the two modes behave the same, but binary still documents intent.
What happens if an ifstream fails to open?
Nothing loud: no exception is thrown by default. The stream's fail bit is set, is_open() returns false, and every later read quietly does nothing, leaving your variables unchanged. Always test the stream right after opening it.
What is the advantage of std::filesystem over POSIX calls?
std::filesystem (C++17) gives one portable API for paths, directory creation, copying, renaming and recursive iteration, instead of opendir on POSIX and FindFirstFile on Windows. Each operation comes in a throwing form and an std::error_code form for when failure is expected.
Is writing a struct with write() portable?
No. The bytes depend on the platform's endianness, type sizes and struct padding, so a file written on one system may not read correctly on another. Use fixed-width types and explicit byte order, or a serialization format such as Protocol Buffers, for files that cross machines.
How do I read a whole file into a std::string?
Open an std::ifstream and stream its buffer into an std::ostringstream with buf << in.rdbuf(), then call buf.str(). It copies the file in bulk rather than character by character.