Skip to main content
C++ intermediate Lesson 19 of 23

File I/O in C++

Read and write files with fstream, manipulate paths with std::filesystem, and parse structured data.

File I/O in C++

C++ provides robust file I/O through the <fstream> library and, since C++17, convenient path and directory manipulation via <filesystem>. File I/O is a critical skill because almost every real program needs to read configuration, write logs, process data files, or walk directory trees. The <fstream> types are RAII wrappers — they open in the constructor and close in the destructor, so you never leak file handles even when exceptions are thrown.


Opening Files: ifstream and ofstream

std::ifstream is for reading, std::ofstream is for writing. Both follow RAII: the file is opened when the object is constructed and closed when it is destroyed. Always check the stream after opening — a failed open leaves the stream in a bad state, and subsequent reads or writes silently do nothing rather than reporting an error.

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

int main() {
    // Write to a file — creates it if it doesn't exist, truncates if it does
    std::ofstream out("output.txt");
    if (!out) {
        std::cerr << "Failed to open output.txt\n";
        return 1;
    }
    out << "Hello, file!\n";
    out << 42 << "\n";
    out.close(); // optional: RAII closes on destruction anyway

    // Read from a file
    std::ifstream in("output.txt");
    if (!in) {
        std::cerr << "Failed to open output.txt\n";
        return 1;
    }

    std::string line;
    while (std::getline(in, line)) {
        std::cout << line << "\n";
    }
}

std::ifstream and std::ofstream are RAII wrappers — the file closes automatically when they go out of scope. Always check the stream after opening.


Open Modes

Open modes let you control how a file is opened: whether existing content is preserved or truncated, whether writes append or overwrite, and whether the file is treated as text or binary. Combining them with | gives you precise control.

#include <fstream>

// Append instead of truncating — log files, event streams
std::ofstream log("app.log", std::ios::app);

// Read and write simultaneously — useful for update-in-place operations
std::fstream rw("data.txt", std::ios::in | std::ios::out);

// Binary mode — no newline translation on Windows, exact byte representation
std::ofstream bin("data.bin", std::ios::binary);

// Truncate existing content explicitly
std::ofstream fresh("report.txt", std::ios::out | std::ios::trunc);

The most common combinations:

  • ios::in | ios::out — read/write an existing file without truncating
  • ios::out | ios::app — always append, never overwrite
  • ios::binary — suppress newline translation on Windows

Reading Strategies

Different reading strategies suit different use cases. Line-by-line is good for log files and CSV. Word-by-word is good for tokenized text. Reading the entire file at once is good when you need the full content in memory for processing.

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

// Line by line — most common pattern for text files
void readLines(const std::string& path) {
    std::ifstream in(path);
    std::string line;
    while (std::getline(in, line)) {
        // process line
    }
}

// Word by word — operator>> skips whitespace and reads tokens
void readWords(const std::string& path) {
    std::ifstream in(path);
    std::string word;
    while (in >> word) {
        // process word
    }
}

// Entire file into a string — idiomatic one-liner, faster than looping
std::string readAll(const std::string& path) {
    std::ifstream in(path);
    std::ostringstream buf;
    buf << in.rdbuf();  // rdbuf() streams the entire file buffer directly
    return buf.str();
}

buf << in.rdbuf() is the idiomatic one-liner to slurp a whole file. It is faster than looping because it bypasses character-by-character formatting.


Parsing with std::stringstream

std::stringstream lets you treat a string like a stream, which is perfect for parsing structured lines. The std::getline overload with a delimiter is the key tool for CSV and similar formats — it reads until the delimiter rather than whitespace.

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

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

std::vector<Record> parseCSV(const std::string& path) {
    std::ifstream in(path);
    std::vector<Record> records;
    std::string line;

    std::getline(in, line); // skip header row

    while (std::getline(in, line)) {
        std::istringstream ss(line);  // treat each line as a stream
        Record r;
        std::string token;

        std::getline(ss, r.name, ',');   // read until comma
        std::getline(ss, token, ',');
        r.age = std::stoi(token);
        std::getline(ss, token, ',');
        r.score = std::stod(token);

        records.push_back(r);
    }
    return records;
}

// CSV content:
// name,age,score
// Alice,30,98.5
// Bob,25,87.0

Binary File I/O

Binary I/O is necessary for non-textual data: images, serialized structs, custom file formats, network packet captures. read() and write() transfer raw bytes, and reinterpret_cast tells the compiler to treat a struct as a byte sequence. Always open in ios::binary to prevent the OS from translating newline bytes.

#include <fstream>
#include <cstdint>
#include <vector>

struct Packet {
    uint32_t id;
    float value;
};

void writeBinary(const std::string& path, const std::vector<Packet>& packets) {
    std::ofstream out(path, std::ios::binary);
    uint32_t count = packets.size();
    // Write count first so the reader knows how many packets to expect
    out.write(reinterpret_cast<const char*>(&count), sizeof(count));
    out.write(reinterpret_cast<const char*>(packets.data()),
              count * sizeof(Packet));
}

std::vector<Packet> readBinary(const std::string& path) {
    std::ifstream in(path, std::ios::binary);
    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));
    return packets;
}

Be aware that binary layouts are not portable across platforms with different endianness or struct padding. For portable serialization, consider a format like Protocol Buffers or MessagePack.


std::filesystem (C++17)

<filesystem> replaces ad-hoc POSIX/Win32 calls with a clean, cross-platform API. Before C++17, directory traversal required platform-specific code (opendir on POSIX, FindFirstFile on Windows). Now it is the same code everywhere. Path objects handle / concatenation, extension queries, stem extraction, and normalization across platforms.

#include <filesystem>
#include <iostream>
#include <system_error>

namespace fs = std::filesystem;

void filesystemDemo() {
    fs::path p = "/tmp/demo";

    // Create directory tree — creates intermediate directories too
    fs::create_directories(p / "data" / "output");

    // Query file info
    fs::path file = p / "data" / "input.csv";
    if (fs::exists(file)) {
        std::cout << "Size: " << fs::file_size(file) << " bytes\n";
        std::cout << "Last write: "
                  << fs::last_write_time(file).time_since_epoch().count()
                  << "\n";
    }

    // Copy and rename — cross-platform, handles overwrite options
    fs::copy(file, p / "data" / "output" / "input.csv",
             fs::copy_options::overwrite_existing);
    fs::rename(p / "data" / "output" / "input.csv",
               p / "data" / "output" / "processed.csv");

    // Walk a directory tree recursively
    for (const auto& entry : fs::recursive_directory_iterator(p)) {
        std::cout << entry.path() << "\n";
    }
}

// Error handling without exceptions — use error_code overloads when failure is expected
void safeDelete(const fs::path& p) {
    std::error_code ec;
    fs::remove_all(p, ec);
    if (ec) {
        std::cerr << "Error: " << ec.message() << "\n";
    }
}

Use std::error_code overloads when you want to handle errors without exceptions.


Putting It Together: File Processor

This example combines directory iteration, path manipulation, and stream I/O into a complete file processing pattern. It demonstrates how the pieces fit together in real-world code.

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

namespace fs = std::filesystem;

// Process all .txt files in inputDir, adding line numbers, writing to outputDir
void processDirectory(const fs::path& inputDir, const fs::path& outputDir) {
    fs::create_directories(outputDir);  // ensure output directory exists

    for (const auto& entry : fs::directory_iterator(inputDir)) {
        if (entry.path().extension() != ".txt") continue;  // skip non-.txt files

        std::ifstream in(entry.path());
        std::ofstream out(outputDir / entry.path().filename());

        std::string line;
        int lineCount = 0;
        while (std::getline(in, line)) {
            out << ++lineCount << ": " << line << "\n";  // prefix with line number
        }

        std::cout << "Processed " << entry.path().filename()
                  << " (" << lineCount << " lines)\n";
    }
}

int main() {
    processDirectory("input", "output");
}

This pattern — iterate a directory, filter by extension, transform line-by-line, write to a parallel output directory — covers a large class of file processing tasks cleanly.

Frequently Asked Questions

When should I use binary vs text mode?
Binary mode for non-textual data (images, serialized structs) and when exact byte representation matters. Text mode handles newline translation but that may corrupt binary data.
What's the advantage of std::filesystem over POSIX calls?
Cross-platform, type-safe paths, cleaner API, and automatic error handling via exceptions or error_code.