Rand Stats

CSV::Native

zef:ash

CSV::Native

N.B.! Status of this module: WIP (work in progress).

CSV parsing and writing with a native fast path on Raku++, and pure Raku everywhere else. No dependencies. The same program runs on both engines.

Version 0.0.1. The interface below is implemented and tested on both engines; what it leaves out is under Scope.

use CSV::Native;

my @rows = from-csv("a,b\n1,\"x,y\"\n");            # [["a","b"],["1","x,y"]]
my @recs = from-csv("data.csv".IO, :headers);        # [{a => "1", b => "2"}, ...]
my @recs = from-csv($text, :headers<id name>, :sep<;>);

say to-csv([[1, "two", "3,4"]]);                     # 1,two,"3,4"
say to-csv(@recs, :headers<name id>);                # a header line, then rows

say csv-backend();                                   # 'native' or 'raku'
raku   -Ilib examples/roundtrip.raku
rakupp -Ilib examples/roundtrip.raku

Walkthrough

t/mock-customers.csv ships with the distribution: 1,000 invented customer records with quoted company names, non-ASCII cities, a few multi-line notes and CRLF line endings. examples/customers.raku runs every flow below on both engines and prints what is shown.

Rows. Without :headers, every line is a list of Str, the header line included:

use CSV::Native;

my @rows = from-csv("t/mock-customers.csv".IO);
say @rows.elems;     # 1001
say @rows[0];        # [Index Customer Id First Name Last Name Company City …]
say @rows[1][2..5];  # (Ada Easley Wonka Labs Sevilla)

Records. With :headers, the first line names the columns and each record is a hash. Quoted fields arrive decoded:

my @customers = from-csv("t/mock-customers.csv".IO, :headers);
say @customers.elems;                       # 1000
say @customers[0]<Company City>;            # (Wonka Labs Sevilla)
say @customers[0]{'First Name'};            # Ada — a key with a space is a string
say @customers[16]<Notes>;                  # said "call me", then left
say @customers.grep(*<Company>.contains(',')).elems;   # 302

Which implementation answered. native on Raku++ with the extension built, raku everywhere else; the results are the same either way:

say csv-backend();   # native

Writing. to-csv returns text, so a file is one spurt away. A slice of the rows is a valid file, header line included:

"three.csv".IO.spurt(to-csv(@rows[^3]));
print "three.csv".IO.slurp;
# Index,Customer Id,First Name,Last Name,Company,City,Country,Phone,Email,…
# 1,40e938d90c5a9fc,Ada,Easley,Wonka Labs,Sevilla,Spain,202-646-1155,…
# 2,14d96cb14241a7a,Rasmus,Stroustrup,Sirius & Wonka,Paris,France,…

Records write in sorted key order unless :headers picks the columns. Names with spaces need a real list — <Index First Name> would be three words:

my @vips = @customers.grep(*<Notes> eq 'VIP');
"vips.csv".IO.spurt(to-csv(@vips, :headers('Index', 'First Name', 'Company')));
print "vips.csv".IO.slurp;
# Index,First Name,Company
# 97,Frances,"Pied, Tyrell and Acme"
# 194,Audrey,Tyrell & Massive
# …
say from-csv("vips.csv".IO, :headers).elems;   # 10

Round trip. What to-csv writes, from-csv reads back as the same data, and a minimally quoted file comes back byte for byte:

say to-csv(@rows) eq "t/mock-customers.csv".IO.slurp;   # True

(In LF form: reading a file through IO::Path or IO::Handle goes through the engine's text decoding, which turns CRLF into LF on both engines. The parser keeps whatever line endings it is given — a Str with CRLF in it keeps them, inside quoted fields too.)

What it is

The XS pattern, as JSON::Native does it: the distribution ships C source, the build step compiles it against Raku++'s extension ABI, and the module uses the result if it is there. On Rakudo, or on a Raku++ without the headers or a compiler, the same specification runs as plain Raku from the module file. A failed native build costs speed, never function.

Unlike JSON::Native there is no ecosystem module to fall back on. The ecosystem's Text::CSV depends on a slang, which depends on Rakudo's grammar internals, so it neither loads on another engine nor belongs as a dependency here. So this module carries its own Raku implementation, and the test suite holds the two implementations to one specification: on Raku++ every case runs through both and must give the same rows, the same bytes and the same error message.

The format

RFC 4180, read strictly and written minimally.

Every error names its line:

CSV::Native: unterminated quoted field starting at line 12
CSV::Native: a quote inside an unquoted field at line 3
CSV::Native: text after a closing quote at line 3
CSV::Native: line 7 has 5 fields but the header has 4
CSV::Native: line 7 has 3 fields, expected 4        # under :strict
CSV::Native: duplicate header 'id'

Reading

from-csv($source, *%options) takes a Str, an IO::Path or an IO::Handle and returns an Array of records.

optiondefaultmeaning
:sep,the separator; any non-empty string (;, "\t", ::, )
:quote"the quote; exactly one character
:headersoffTrue: the first record names the columns; a list: these names do, and every record is data
:strictFalseevery record must have as many fields as the first (or as the header)

Without :headers each record is an Array of Str. With it each record is a Hash: a record shorter than the header lacks those keys, a longer one is an error, and a duplicate header name is an error.

Writing

to-csv(@rows, *%options) returns a Str, one line per row. A row is a list of cells or a hash; a cell is written as its .Str, an undefined cell as an empty field.

optiondefaultmeaning
:sep, :quote, "as for reading
:eol"\n""\n", "\r\n" or "\r"
:headerssee belowthe column names, written as the first line
:always-quoteFalsequote every field

A field is quoted when it contains the separator, the quote or a line ending; a quote inside it is doubled. Hash rows follow :headers when given, else the sorted keys of the first row (the one order both engines agree on), and get a header line unless :!headers. List rows get a header line only when :headers names one.

Backends

csv-backendwhen
nativeRaku++ with the compiled extension
rakuRakudo, or Raku++ without the extension

CSV_NATIVE_BACKEND=raku in the environment forces the Raku implementation on a Raku++ that has the extension, to compare the two or to rule the native path out of a suspected bug. The raw implementations are reachable as CSV::Native::parse-raku and CSV::Native::write-raku; the test suite uses them.

Measured

Generated corpora with the shapes real files have (quoted fields with separators and doubled quotes, multi-line fields, non-ASCII), best of three, 2026-09-02, arm64 Mac, Rakudo v2026.08 and Raku++ 3.24.0. The corpora, the generator and the scripts are in the repository under benchmarks/csv, outside the distribution; its README says how to re-run every number.

100,000 rows, 8.5 MBparseparse :headerswritewrite hashes
Raku++, extension107 ms150 ms48 ms137 ms
Raku++, Raku implementation1,075 ms1,778 ms3,551 ms3,984 ms
Rakudo, Raku implementation1,617 ms3,566 ms1,475 ms2,097 ms
Rakudo, Text::CSV 0.02215,245 ms
10,000 rows, 813 KBparseparse :headerswritewrite hashes
Raku++, extension6 ms10 ms4 ms8 ms
Raku++, Raku implementation98 ms151 ms308 ms371 ms
Rakudo, Raku implementation130 ms171 ms143 ms174 ms
Rakudo, Text::CSV 0.0221,631 ms

The Raku implementation is built on split and lines, never on scanning with index, because on Raku++ an index costs the whole string on every call; the first version scanned that way and took 12 s for a thousand rows there. Both implementations are linear in the input.

Scope

Left out of 0.0.1 on purpose:

Requirements

Nothing, to work. For the fast path: Raku++, a C compiler, and Raku++'s headers (<prefix>/include/rakupp/rakupp_ext.h, installed by cmake --install; or RAKUPP_SRC pointing at a checkout's include/). Without them the install still succeeds and the module runs its Raku implementation.

Compatibility

engineversiont/01-parse.tt/02-write.tt/03-mock-file.t
Rakudov2026.0873/7355/5517/17
Raku++, extension3.24.0139/13993/9319/19
Raku++, CSV_NATIVE_BACKEND=raku3.24.073/7355/5517/17

The Raku++ counts are higher because every case runs through both implementations there. Neither version is an established floor; the extension needs a Raku++ with extension ABI 2.

Two engine differences worth knowing: CRLF is one character in Raku ("a\r\nb".chars is 3, and .index("\n") does not find it); and Rakudo applies the single-argument rule to [[1,2]] (it is [1,2]) where Raku++ keeps it nested — write [[1,2],] for a one-row table on both.

Author

Andrew Shitov (zef:ash).

Licence

Artistic-2.0.


Why the module is shaped this way, and what running it under two engines turned up, is in notes/CSV-Native.md.