neo vs pandas · library 3.0.6

neo vs pandas.

The same input and the same kernels, in neo's C++ and in pandas 3.0.6. Across 26 measurements neo finished 40.5× faster in the middle and 612× at most; pandas was quicker on 1: filter, count: one run from rest.

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 pandas does it and every number taken for it. In a race both ships run for the same stretch: pandas completes the work once, and neo completes it as many times as the measured ratio says, turning around at each end.

  1. 01.1 · load the CSV, parse and type82.6×
  2. 01.2 · filter amount > 500, count18.2×
  3. 01.3 · filter into a new table31.1×
  4. 01.4 · sort, as a permutation62.7×
  5. 01.5 · sort into a new table40.5×
  6. 01.6 · sum by region612×
  7. 01.7 · load worldcitiespop92.6×
  8. 01.8 · load NYC taxi · 1M58.7×
  9. 01.9 · load NYC taxi · 10M75.4×
  10. 01.10 · load ClickBench · 1M61.2×
  11. 01.11 · load ClickBench · 10Mnot measured

01.1 · kernel · 1,000,000 rows

load the CSV, parse and type

neo 2.77 ms · pandas 229 ms · 82.6× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Read a 46 MB CSV of a million rows and six columns into a table: split the bytes into fields, parse the amount column into numbers, and dictionary-encode the region column. Each engine's typed read, best of two, with the first read of a fresh process kept separately.

What neo is held to

The same table the app loads, with the amount column typed and the region column encoded at load, so that no kernel after it touches text. Before a time is taken every engine prints its table as one number and its checks, and the line under the table says whether they agreed.

How pandas does it

pandas's default read_csv, which infers a type for each column. The same read through its pyarrow engine is timed beside it and kept in the ledger.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
res["load_first_ms"], res["load_ms"] = load_twice(lambda: pd.read_csv(args.csv))
_, res["load_pyarrow_ms"] = load_twice(lambda: pd.read_csv(args.csv, engine="pyarrow"), "load_pyarrow_mj")

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msThe typed read, best of two, with the first read of a fresh process kept separately.2.7722982.6×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1721,6369.51×

Read it with this

  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.2 · kernel · 1,000,000 rows

filter amount > 500, count

neo 0.018 ms · pandas 0.328 ms · 18.2× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Count the rows whose amount is over 500: a million comparisons on a typed column, the answer one integer. Each library's count is written the way its users write it.

What neo is held to

The count every other engine prints, under the JavaScript node's rule that NaN never matches. What comes out is a selection the next kernel reads from, not a copy of the rows.

How pandas does it

A comparison that builds a boolean column of a million entries, then a sum over it to count the true ones.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("filter", lambda: int((amount > 500).sum()))

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.0180.32818.2×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1.082.612.42×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.0.7821.141.45×
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.8.720.7000.08×

Read it with this

  • pandas is ahead of neo on energy, one run from rest, and is shown ahead.
  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.3 · kernel · 1,000,000 rows

filter into a new table

neo 0.380 ms · pandas 11.8 ms · 31.1× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Keep the rows whose amount is over 500 and build a new table of them, about half the file, every column copied.

What neo is held to

The same rows every other engine keeps, every column copied, checked by the same line.

How pandas does it

The same boolean column used as a mask: pandas copies the matching rows of every column into a new DataFrame.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("filter_mat", lambda: df[amount > 500])

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.38011.831.1×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.21.687.04.04×

Read it with this

  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.4 · kernel · 1,000,000 rows

sort, as a permutation

neo 1.03 ms · pandas 64.8 ms · 62.7× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Produce the stable ascending order of the amount column as a permutation of row indices, without moving any other column. SQL engines have no such form, so DuckDB and Spark sit this one out.

What neo is held to

A stable ascending order under the JavaScript rule that NaN sorts first and ties keep their place. The first sorted values are printed and compared with every other engine's.

How pandas does it

NumPy's stable argsort on the amount column as a plain array.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("sort_perm", lambda: np.argsort(amount.to_numpy(), kind="stable"))

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.1.0364.862.7×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.64.54416.84×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.2.7372.126.5×
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.44.83738.34×

Read it with this

  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.5 · kernel · 1,000,000 rows

sort into a new table

neo 2.72 ms · pandas 110 ms · 40.5× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Sort by amount and build the whole sorted table, every column reordered.

What neo is held to

The whole table in that order, every column, with the same first values as every other engine.

How pandas does it

sort_values on amount, which reorders every column into a new DataFrame.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("sort_mat", lambda: df.sort_values("amount"))

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.2.7211040.5×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1446374.43×

Read it with this

  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.6 · kernel · 1,000,000 rows

sum by region

neo 0.020 ms · pandas 12.2 ms · 612× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

Total the amount column for each of five regions: a grouped sum, the kernel every dashboard runs.

What neo is held to

The JavaScript loop's result to the bit. A floating-point sum depends on the order it is taken in, and neo is held to the row order's answer whatever it does to get there.

How pandas does it

A groupby on the region column and a sum of amount within each group.

pandas, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("agg", lambda: df.groupby("region")["amount"].sum())

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.02012.2612×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1.5590.658.5×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.0.75817.723.4×
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.9.1157.26.28×

Read it with this

  • pandas runs this on one thread and neo on sixteen, so part of this multiple is a thread count.

01.7 · public data · a file this project did not make

load worldcitiespop

neo 11.4 ms · pandas 1,053 ms · 92.6× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

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 pandas does it

read_csv with dtype=str and keep_default_na off, through its multithreaded pyarrow engine rather than its single-threaded default.

pandas reading the public files · neon-cpp/bench/lang/python/standards.py:111
def load_pandas(path, mode, loads, opt=None):
    import pandas as pd
    pd.read_csv(io.BytesIO(b"a,b\n1,x\n2,y\n"), engine="pyarrow")  # untimed: the reader's own start-up
    out, rows, cols = [], 0, 0
    for _ in range(loads):
        t0 = time.perf_counter()
        # engine="pyarrow": pandas' multithreaded reader, one keyword from the default (its C engine is single-threaded:
        # 1.3-1.7 s on worldcitiespop where this takes tens of ms — bench/results/standards.jsonl, the records of 09:02-09:22)
        eng = (opt or {}).get("engine", "pyarrow")
        df = pd.read_csv(path, dtype=str, keep_default_na=False, engine=eng) if mode == "text" else pd.read_csv(path, engine=eng)
        out.append(ms_since(t0))
        rows, cols = df.shape
        del df
    import pyarrow as pa
    return pd.__version__, out, rows, cols, (pa.cpu_count() if (opt or {}).get("engine", "pyarrow") == "pyarrow" else 1)

neo's side is confidential. What it is held to is above; why it is kept is under 03.

Every number taken for it

measurementneopandas× 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.81,07245.0×
loads after the first, as text, msThe median of the loads after the first, in the same process.11.41,05392.6×

01.8 · public data · a file this project did not make

load NYC taxi · 1M

neo 52.9 ms · pandas 3,103 ms · 58.7× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

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 pandas does it

read_csv with dtype=str and keep_default_na off, through its multithreaded pyarrow engine rather than its single-threaded default. 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

measurementneopandas× 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.93,15143.2×
loads after the first, as text, msThe median of the loads after the first, in the same process.52.93,10358.7×

01.9 · public data · a file this project did not make

load NYC taxi · 10M

neo 418 ms · pandas 31,512 ms · 75.4× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

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 pandas does it

read_csv with dtype=str and keep_default_na off, through its multithreaded pyarrow engine rather than its single-threaded default. 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

measurementneopandas× 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.44831,83771.0×
loads after the first, as text, msThe median of the loads after the first, in the same process.41831,51275.4×

01.10 · public data · a file this project did not make

load ClickBench · 1M

neo 96.0 ms · pandas 5,876 ms · 61.2× neo. Against every engine →

neo

pandas

0 runs

0 runs

What this measures

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 pandas does it

read_csv with dtype=str and keep_default_na off, through its multithreaded pyarrow engine rather than its single-threaded default. 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

measurementneopandas× 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.1265,94447.0×
loads after the first, as text, msThe median of the loads after the first, in the same process.96.05,87661.2×

01.11 · public data · a file this project did not make

load ClickBench · 10M

pandas: not measured. Against every engine →

What this measures

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 pandas does it

read_csv with dtype=str and keep_default_na off, through its multithreaded pyarrow engine rather than its single-threaded default. 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

measurementneopandas× 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——
loads after the first, as text, msThe median of the loads after the first, in the same process.1,056——

Read it with this

  • pandas did not read this file, so there is nothing to race.

02 · Every number

Against the libraries people reach for first

1,000,000 rows · ms, lower is better · 2026-10-11 21:23

kernelneopandas× neo
load the CSV (parse + type)2.7722982.6×
filter amount > 500, count0.0180.32818.2×
filter into a new table0.38011.831.1×
sort, as a permutation1.0364.862.7×
sort into a new table2.7211040.5×
sum(amount) by region0.02012.2612×
filter, count: one run from rest0.7821.141.45×
sort, permutation: one run from rest2.7372.126.5×
sum by region: one run from rest0.75817.723.4×

The same CSV and the same six kernels in neo's engine and in the libraries people reach for first, each written the way its users write it (Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6, Spark 4.2.0). Milliseconds, best of three; the chip is how many times neo's time a library took. The last three rows are one run from rest: the call made once after 50 ms of nothing, mean of ten, neo's pool told to rest after it as the editor tells it. That is what a click finds; the rows above are runs in a row, every thread awake and the cores at speed.

The same run, in energy

1,000,000 rows · mJ, lower is better · 2026-10-11 21:23

kernelneopandas× neo
load the CSV (parse + type)1721,6369.51×
filter amount > 500, count1.082.612.42×
filter into a new table21.687.04.04×
sort, as a permutation64.54416.84×
sort into a new table1446374.43×
sum(amount) by region1.5590.658.5×
filter, count: one run from rest8.720.7000.08×
sort, permutation: one run from rest44.83738.34×
sum by region: one run from rest9.1157.26.28×

The same CSV and the same six kernels as the times, in the same run (Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6, Spark 4.2.0). Millijoules a run. What macOS billed each engine's process, and for Spark its JVM, for the processor cores it ran on: not the memory, the disk or the screen, and not the machine at rest. A kernel's figure is the energy over its timed runs, and as many more as fill 200 ms, divided by the runs, less what the process draws at rest for as long: a mean, where the time is a best. neo's is of the variant whose time is the number on the Speed side. Each benchmark reads the counter itself; scripts/bench_libraries.sh also reads it from outside the process (neon_energy) and RESULTS.md has both totals. The chip is how many times neo's energy a library used. The last three rows are one run from rest: the call made once after 50 ms of nothing, mean of ten, neo's pool told to rest after it as the editor tells it. That is what a click finds; the rows above are runs in a row, every thread awake and the cores at speed. From rest a run costs less energy than in a row where the work is large (the cores are not at their top voltage) and more where it is tiny (the threads have to be woken for it).

Public data, from a path to a table

loads after the first, as text · ms, lower is better · 2026-10-11 16:43

kernelneopandas× neo
worldcitiespop · first load, as text23.81,07245.0×
worldcitiespop · loads after the first, as text11.41,05392.6×
NYC taxi · 1M · first load, as text72.93,15143.2×
NYC taxi · 1M · loads after the first, as text52.93,10358.7×
NYC taxi · 10M · first load, as text44831,83771.0×
NYC taxi · 10M · loads after the first, as text41831,51275.4×
ClickBench · 1M · first load, as text1265,94447.0×
ClickBench · 1M · loads after the first, as text96.05,87661.2×

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

pandas's side, in full

pandas is the DataFrame library most Python data work starts with. It runs on one thread, which is part of this story. Its kernels are written the way its users write them. Each kernel's lines are beside its race above; this is the whole block they come from, with the checks it prints.

pandas, as its users write it: the six kernels · neon-cpp/bench/lang/python/yardstick.py:197
if args.lib == "pandas":
    t0 = now()
    import numpy as np, pandas as pd
    res["version"] = pd.__version__
    res["startup_ms"] = (now() - t0) * 1000
    res["load_first_ms"], res["load_ms"] = load_twice(lambda: pd.read_csv(args.csv))
    df = pd.read_csv(args.csv)
    res["rows"] = len(df)
    # the pyarrow engine is the faster reader pandas ships; a second number for the reader people switch to
    _, res["load_pyarrow_ms"] = load_twice(lambda: pd.read_csv(args.csv, engine="pyarrow"), "load_pyarrow_mj")
    amount = df["amount"]
    timed("filter", lambda: int((amount > 500).sum()))
    timed("filter_mat", lambda: df[amount > 500])
    timed("sort_perm", lambda: np.argsort(amount.to_numpy(), kind="stable"))
    timed("sort_mat", lambda: df.sort_values("amount"))
    timed("agg", lambda: df.groupby("region")["amount"].sum())
    checks["filter_count"] = int((amount > 500).sum())
    checks["first_sorted"] = [float(x) for x in np.sort(amount.to_numpy())[:3]]
    checks["sums"] = {str(k): float(v) for k, v in df.groupby("region")["amount"].sum().items()}
    res["threads"] = 1  # pandas is single-threaded for every one of these

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

Scripts: scripts/bench_libraries.sh · 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.

More comparisons