The same input and the same kernels, in neo's C++ and in Arrow 25.0.1. Across 10 measurements neo finished 2.29× faster in the middle and 3.89× at most; Arrow was not quicker on any.
01 · The race
One kernel at a time
Each piece of work below has its own race, what it measures, what neo is held to, how Arrow does it and every number taken for it. In a race both ships run for the same stretch: Arrow completes the work once, and neo completes it as many times as the measured ratio says, turning around at each end.
Read worldcitiespop from its path to a table in memory: 3,173,958 rows by 7 columns, 151 MB of place names, the file the CSV tools benchmark on. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.
What neo is held to
The same loader the app uses, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.
How Arrow does it
PyArrow's CSV reader, asked for every column as a string so that its table is text, as neo's is. Its thread pools are started on a three-line file before the clock.
Arrow reading the public files · neon-cpp/bench/lang/python/standards.py:95
def load_arrow(path, mode, loads, opt=None):
import pyarrow as pa
import pyarrow.csv as pc
convert = pc.ConvertOptions(column_types={n: pa.string() for n in header_names(path)}) if mode == "text" else pc.ConvertOptions()
parse = pc.ParseOptions(newlines_in_values=True) if opt else pc.ParseOptions()
pc.read_csv(io.BytesIO(b"a,b\n1,x\n2,y\n")) # untimed: the reader's own start-up (its thread pools), on three lines that are not the file
out, rows, cols = [], 0, 0
for _ in range(loads):
t0 = time.perf_counter()
t = pc.read_csv(path, convert_options=convert, parse_options=parse)
out.append(ms_since(t0))
rows, cols = t.num_rows, t.num_columns
del t
return pa.__version__, out, rows, cols, pa.cpu_count()
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Arrow
× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.
23.8
26.3
1.10×
loads after the first, as text, msThe median of the loads after the first, in the same process.
11.4
23.0
2.02×
01.2 · public data · a file this project did not make
Read NYC taxi · 1M from its path to a table in memory: 1,000,000 rows by 51 columns, 425 MB of trips. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.
What neo is held to
The same loader the app uses, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.
How Arrow does it
PyArrow's CSV reader, asked for every column as a string so that its table is text, as neo's is. Its thread pools are started on a three-line file before the clock. The reader is the one shown under the first public file.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Arrow
× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.
72.9
96.1
1.32×
loads after the first, as text, msThe median of the loads after the first, in the same process.
52.9
90.7
1.72×
01.3 · public data · a file this project did not make
Read NYC taxi · 10M from its path to a table in memory: 10,000,000 rows by 51 columns, 4.3 GB of trips. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.
What neo is held to
The same loader the app uses, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.
How Arrow does it
PyArrow's CSV reader, asked for every column as a string so that its table is text, as neo's is. Its thread pools are started on a three-line file before the clock. The reader is the one shown under the first public file.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Arrow
× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.
448
954
2.13×
loads after the first, as text, msThe median of the loads after the first, in the same process.
418
959
2.29×
01.4 · public data · a file this project did not make
Read ClickBench · 1M from its path to a table in memory: 1,000,000 rows by 105 columns, 842 MB of web hits. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.
What neo is held to
The same loader the app uses, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.
How Arrow does it
PyArrow's CSV reader, asked for every column as a string so that its table is text, as neo's is. Its thread pools are started on a three-line file before the clock. The reader is the one shown under the first public file.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Arrow
× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.
126
373
2.95×
loads after the first, as text, msThe median of the loads after the first, in the same process.
96.0
373
3.89×
01.5 · public data · a file this project did not make
Read ClickBench · 10M from its path to a table in memory: 10,000,000 rows by 105 columns, 8.1 GB of web hits. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.
What neo is held to
The same loader the app uses, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.
How Arrow does it
PyArrow's CSV reader, asked for every column as a string so that its table is text, as neo's is. Its thread pools are started on a three-line file before the clock. The reader is the one shown under the first public file.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Arrow
× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.
1,053
3,752
3.56×
loads after the first, as text, msThe median of the loads after the first, in the same process.
1,056
3,829
3.62×
02 · Every number
Public data, from a path to a table
loads after the first, as text · ms, lower is better · 2026-10-11 16:43
kernel
neo
Arrow
× neo
worldcitiespop · first load, as text
23.8
26.3
1.10×
worldcitiespop · loads after the first, as text
11.4
23.0
2.02×
NYC taxi · 1M · first load, as text
72.9
96.1
1.32×
NYC taxi · 1M · loads after the first, as text
52.9
90.7
1.72×
NYC taxi · 10M · first load, as text
448
954
2.13×
NYC taxi · 10M · loads after the first, as text
418
959
2.29×
ClickBench · 1M · first load, as text
126
373
2.95×
ClickBench · 1M · loads after the first, as text
96.0
373
3.89×
ClickBench · 10M · first load, as text
1,053
3,752
3.56×
ClickBench · 10M · loads after the first, as text
1,056
3,829
3.62×
Public CSVs, not ours, from a path to a table in memory, in neo and in engines this project did not write (Arrow 25.0.1, Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6). Milliseconds. worldcitiespop.csv is the file as xsv, qsv, xan and zsv benchmark it; the taxi and ClickBench files are the first records of the files those benchmarks publish, byte for byte, under a header line made from the benchmark's own schema (scripts/fetch_standards.sh; every source and SHA-256 is in bench/results/standards/manifest.jsonl). Every engine runs in a fresh process each round, in an order shuffled each round: the first row is the median first load, the second the median of the loads after it. The file is in the operating system's cache throughout, and each engine's own start-up is done before its first load, so these are the readers and nothing else. neo goes through the loader the app uses. Its table is text and only text: no types, no nulls (an empty field is an empty string), no check that the bytes are UTF-8, every field trimmed and a line end inside quotes made \n, as the app it replaces did; the others asked for text check UTF-8 and keep nulls. So the first two rows are the like-for-like ones; in the last two the others also infer types, which neo's loader does not do, and neo's numbers there are its text load again. Before any time is taken neo's table is compared with DuckDB's reading of the file, and each card says what came of it. pandas is run with its pyarrow engine. Threads are each engine's default: 18 here for the others; neo 16, and 18 from 256 MB of input, counts that were chosen on this machine. A dash is an engine that did not read the file.
03 · Both sides
Arrow's side, in full
Apache Arrow's C++ CSV reader, through PyArrow: the reader several other tools use underneath. Its kernels are written the way its users write them. Its code is beside each race above, and nothing of it is left out here.
neo's side: confidential
neo's kernels, the ports written in-house to beat them, and the techniques behind both stay with the engine. Nothing about how the numbers were made is withheld: the method, the checks every engine had to pass, every number and every place neo is behind are on this page. The engine itself we keep confidential, because we don't want it getting into the wrong hands. The binaries run on request and reproduce every number here.
04 · Diligence
What both sides were held to
Same input, same answer. Every engine reads the same file and prints the same checks line: the filter count, the first sorted values, the group sums. A number enters the ledger only when the checks agree with the C++.
Timed the same way. A kernel is the best of three in one process; a load is the engine's typed read, best of two, with the first read of a fresh process kept separately. Engines that need a warm-up get it before the clock.
Energy is what the machine billed. Millijoules a run, read from the hardware counters for each engine's whole process tree, with the draw at rest subtracted for the seconds of the window.
The machine is named, and so is its load. Apple M5 Max, 18 cores, 26.4.1. The load average before each run is in the ledger, and a study taken on a shared machine says so beside its numbers.
Where Arrow wins, it is shown winning. None of the 10 measurements on this page, and that is also a measurement.
The caveat on this comparison. Arrow is measured on loads only, through PyArrow, with its reader's thread pools started before the clock. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones; Arrow's typed reads are shown but not compared.
What is kept back. neo's kernels, the in-house ports and the techniques behind them: we don't want them getting into the wrong hands. The binaries run on request.
Scripts: scripts/bench_standards.sh · measured 2026-10-11 · the numbers on this page come from the same ledger as the app's own Evals page, written by neon-cpp/scripts/results_md.py.