# csv2, a memory-mapped CSV reader and writer for modern C++

> csv2 is a header-only C++11 library that parses CSV by memory-mapping the file and iterating lazily over rows and cells, with a policy-based template interface for the delimiter, quote character, header handling and whitespace trimming. It parses fast and it does not attempt to be a general CSV dialect engine.

**p-ranav/csv2** — Fast CSV parser and writer for Modern C++

- Repository: https://github.com/p-ranav/csv2
- Stars: 625 · Forks: 111
- Language: C++
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/p-ranav-csv2

## The whole configuration is four template parameters

csv2 is a fast CSV parser and writer for modern C++, and the design decision that defines it is that the format is fixed at compile time through template parameters rather than parsed at runtime.

The reader is declared with four policies.

```cpp
#include <csv2/reader.hpp>

int main() {
  csv2::Reader<csv2::delimiter<','>, 
               csv2::quote_character<'"'>, 
               csv2::first_row_is_header<true>,
               csv2::trim_policy::trim_whitespace> csv;
               
  if (csv.mmap("foo.csv")) {
    const auto header = csv.header();
    for (const auto row: csv) {
      for (const auto cell: row) {
        // Do something with cell value
        // std::string value;
        // cell.read_value(value);
      }
    }
  }
}
```

Those four are the delimiter, the quote character, whether the first row is a header, and the trim policy. Everything about how the file is interpreted follows from them, and the defaults are a comma, a double quote, first row as header, and trimming whitespace.

The consequence is that the library is not a general CSV dialect engine. It does not sniff the format, it does not guess whether a file is semicolon separated, and it does not let a user switch dialect at runtime without recompiling with a different instantiation. For a pipeline that reads one known format, that is a feature. For an application that ingests whatever customers upload, it is a limitation, and the honest way to handle it is to instantiate several readers and pick between them.

The trimming policy is worth a second look, because the default of trimming whitespace is a decision that changes data. A cell that is deliberately padded with spaces loses them, and a quoted cell containing meaningful leading whitespace is affected too. The policy is a template parameter precisely so you can turn that off.

## mmap and lazy iteration, and what that buys you

The two entry points on the reader are `mmap`, which takes a filename, and `parse`, which takes contents you already hold in a `std::string`.

```cpp
  // Use this if you'd like to mmap and read from file
  bool mmap(string_type filename);

  // Use this if you have the CSV contents in std::string already
  bool parse(string_type contents);
```

Both return a bool, so failure is a value rather than an exception, and the `mmap` call in the usage example is inside an `if`, which is the intended shape.

The mmap path is the reason this library is fast on large files. The file is mapped into the address space and the parser walks it in place, so the OS page cache does the caching and the process does not hold a second copy of the data. The topics list for the repository includes memory-mapped-file and mmap, and the benchmark description says the measurement is of the time to memory-map the input CSV file and iterate over every cell in it.

The iteration is lazy at two levels. The reader exposes `rows()` and `cols()`, and a `begin`/`end` pair of `RowIterator`, and the `Row` itself exposes a `CellIterator`. So a program can read a header, then walk rows, and within a row walk cells, without any intermediate materialisation of the file into a vector of vectors of strings.

That laziness has a second payoff beyond memory. If you only need the first ten rows, you stop after ten, and with an mmap-backed reader the cost of the unmapped remainder is close to zero. A parser that built a full in-memory structure would pay for the whole file before you got your first row.

The `header()` accessor returns the first row as a `Row`, and the `first_row_is_header` policy is what makes the row iteration start from the second row, as the API documentation puts it.

## The cell API separates raw text from unescaped text

The `Cell` class has two accessors, and the difference between them is the kind of detail that quietly corrupts data when you get it wrong.

```cpp
// Cell class
class Cell {
public:
  // Get raw contents of the cell
  void read_raw_value(Container& value) const;
  
  // Get converted contents of the cell
  // Handles escaped content, e.g., 
  // """foo""" => ""foo""
  void read_value(Container& value) const;
};
```

`read_raw_value` gives you the raw contents of the cell, and `read_value` gives you the converted contents, handling escaped content, with the comment showing `"""foo"""` becoming `""foo""`. The `Row` class has the same pair of concepts, with `read_raw_value` for the raw contents of the row and a `CellIterator` pair for the cells.

So a cell is read into a container you supply, and the container is a template parameter of the library, so you get `std::string`, `std::string_view` or whatever type you instantiate it with. That avoids a copy when the string view suffices, and it avoids an allocation when the string does not.

The `Row` class is similarly small.

```cpp
// Row class
class Row {
public:
  // Get raw contents of the row
  void read_raw_value(Container& value) const;
  
  // Cell iterator
  CellIterator begin() const;
  CellIterator end() const;
};
```

The writer is a separate header, `csv2/writer.hpp`, and a separate class.

```cpp
#include <csv2/writer.hpp>
#include <vector>
#include <string>
using namespace csv2;

int main() {
    std::ofstream stream("foo.csv");
    Writer<delimiter<','>> writer(stream);

    std::vector<std::vector<std::string>> rows = 
        {
            {"a", "b", "c"},
            {"1", "2", "3"},
            {"4", "5", "6"}
        };

    writer.write_rows(rows);
    stream.close();
}
```

The writer takes a delimiter template parameter and an output file stream, and offers `write_row` for a single row and `write_rows` for a list of rows. The project describes it as a basic writer, and the API listing ends there, with no mention of quoting rules, escaping of embedded delimiters, or newline handling. If you need to emit a file that other tools will parse back reliably, that is the part of this library to check carefully, because the reader handles escaped content and the writer's documentation does not say that it does.

## The benchmark numbers and the machine behind them

The README carries a benchmark table, and the way it is presented matters as much as the numbers.

The measurement is described precisely: the average execution time of five runs after three warmup runs, for csv2 to memory-map the input CSV file and iterate over every cell in the CSV. The source is `benchmark/main.cpp`.

```bash
cd benchmark
g++ -I../include -O3 -std=c++11 -o main main.cpp
./main <csv_file>
```

The system details are given too, and you should read them before quoting any figure.

| Type | Value |
| ---- | ---- |
| Processor | Apple M1 Pro (8-core or 10-core CPU) |
| Installed RAM | 16.0 GB |
| OS | MacOS Tahoe 26.6 (25G70) |
| C++ Compiler | Apple clang version 21.0.0 (clang-2100.3.34.2) |

The results are dated 02 OCT 2026 and the datasets are the well-known public ones from Kaggle. The Denver Crime Data set at 111 MB with 7,737,320 cells is listed at 0.077s, and the Bitcoin tweets set at 4 GB with 152,007,894 cells at 3.026s.

Two honest caveats. First, this is a single-machine measurement on one architecture with one compiler, so it tells you about this library on an M1 Pro and not about your workload on a server with a spinning disk or a different memory subsystem. Second, and more interesting, the timings are not proportional to the cell count. The 3.7 GB set with 51 million cells takes 2.526s, while the 4 GB set with 152 million cells takes 3.026s, which means the per-cell cost varies by an order of magnitude across the datasets. Wide rows and long files are not the same workload, and a single average number would hide that.

The benchmark is also a whole-file scan. A program that reads only the first thousand rows of a 4 GB file pays a fraction of that, which is the point of the lazy design.

## Two distribution forms, one header-only library

The repository ships the library in the two forms a C++ project usually needs, and the top-level entries show both.

There is an `include/` directory with the real headers, and a `single_include/` directory holding an amalgamated version, generated by a script in `utils/amalgamate/`.

```bash
python3 utils/amalgamate/amalgamate.py -c single_include.json -s .
```

The configuration is a `single_include.json` file at the top level, and the `-s .` argument points the tool at the repository root. The topics list includes single-header, single-header-lib and header-library, and the whole library is header-only, so there is nothing to link against and nothing to build before you use it.

The single header is the form you drop into a project that does not use CMake, and the `include/` tree is the form you add as a subdirectory or point a compiler at. Either way, the library is a set of templates and inline code rather than a compiled artifact, which is why the CMake package configuration files are small.

For CMake consumers there is more than just the source tree. There is a `csv2Config.cmake.in` and a `csv2.pc.in`, which is the pkg-config template, plus a `csv2.pc` style install. The `CMakeLists.txt` at the root has a `CSV2_BUILD_TESTS` option, and the test build is documented.

```bash
mkdir build && cd build
cmake -DCSV2_BUILD_TESTS=ON ..
make
cd test
./csv2_test
```

The tests are a separate binary you run from the `test` subdirectory of the build tree, which is a slightly unusual arrangement but is what the documentation specifies.

There is also a `LICENSE.mio` file alongside the `LICENSE`, which is the single-header distribution's licence copy so that the amalgamated file carries its own terms, and a `.clang-format` with a `clang-format.bash` helper for formatting the sources consistently.

## Where csv2 stops, and what to reach for instead

The boundaries of this library follow directly from its design, and knowing them saves a rewrite.

The first is dialect coverage. The format is decided by four template parameters at compile time, so there is no runtime configuration, no auto-detection and no way to accept a file whose dialect is not known in advance. A tool that must read whatever a user uploads needs either several instantiated readers or a different library.

The second is the writer. It is described as basic, the API is two methods, and the documentation says nothing about escaping. The reader explicitly handles escaped content, which sets an expectation that the writer does not meet in its documentation. If your output has to round-trip through a strict parser, that asymmetry is the thing to test first.

The third is the comparison with the other common approaches. A parser built on `std::getline` plus string splitting is simple and dependency-free, and for a file that fits comfortably in memory it is fast enough that the mmap advantage does not matter. csv2 wins when the file is large relative to your memory budget, or when you want to stop reading partway through, or when you want the header handling and cell iteration semantics already settled.

The other comparison is with a heavier CSV library that supports a full dialect specification, typed field access with per-column converters, and streaming from a socket or a pipe. Those libraries give you more and cost more in compile time and in complexity. csv2 gives you a small, predictable API surface and a compile-time format contract, and it does not attempt schema-aware parsing.

One practical point about the writer that follows from the header-only design: since there is no library to build, adding csv2 to a project is a copy or a subdirectory, and the build cost is measured in template instantiation rather than in link time. For a large project with many translation units, the template-heavy reader can cost more to compile than it saves at runtime, and the single-header form exists partly to manage that.

## Conclusion

csv2 suits a C++ program that needs to read a large CSV quickly and read it lazily, since the memory-mapped reader never loads the whole file and the row and cell iterators let you stop as soon as you have what you need. It does not suit a project that needs to parse every dialect of CSV in the wild, since the format support is fixed by template policy at compile time, and it does not suit a writer-heavy workload, since the writer class is described as basic. Verify first that the delimiter, quote character, header behaviour and trimming you need are all expressible as the four template parameters, that your compiler is C++11 or later with the standard library features the reader relies on, and that the header-only single-header distribution is what you want rather than the CMake package, since the project ships both forms.

## FAQ

### What is the csv2 library?

csv2 is a fast CSV parser and writer for modern C++, a header-only C++11 library. The reader memory-maps the input file and iterates rows and cells lazily, and the format is fixed at compile time through template policies for the delimiter, quote character, header handling and whitespace trimming.

### How do I read a CSV file with csv2?

Include csv2/reader.hpp, declare a csv2::Reader with the four template policies, and call mmap with a filename or parse with contents you already hold. The file returns a bool so you can check for failure, then you can read the header and iterate rows and cells.

### What is the difference between read_raw_value and read_value in csv2?

read_raw_value gives you the raw contents of the cell or row, while read_value gives the converted contents with escaped content handled, so a cell written as triple-quoted foo becomes foo. The container type is a template parameter, so you can read into a std::string or a string view.

### How do I build the csv2 tests?

Create a build directory, run cmake with -DCSV2_BUILD_TESTS=ON pointing at the repository, then make, then run the test binary from the test subdirectory of the build tree. The documented sequence is mkdir build && cd build, cmake -DCSV2_BUILD_TESTS=ON .., make, cd test, ./csv2_test.

### Can I use csv2 as a single header file?

Yes. The repository ships a single_include directory with an amalgamated version generated by python3 utils/amalgamate/amalgamate.py -c single_include.json -s . The library is header-only, so the include/ tree can also be used directly without building anything.

## Sources

- [Issues](https://github.com/p-ranav/csv2/issues)
- [License: MIT](https://github.com/p-ranav/csv2/blob/master/LICENSE)
- [p-ranav/csv2 on GitHub](https://github.com/p-ranav/csv2)
- [README](https://github.com/p-ranav/csv2/blob/master/README.md)
- [Releases](https://github.com/p-ranav/csv2/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/p-ranav-csv2
