Skip to content

Performance

go-dataframe against pandas 3.0 and polars, in time and memory: where it is faster, where polars is, and how it was measured.

All numbers are wall-clock medians of interleaved runs (Go, then pandas and polars, repeated), on an Apple M4 Max (16 cores, 128 GB), macOS, Go 1.27.2, pandas 3.0.6, polars (latest from PyPI via uv), numpy 2.5. The host is shared and was loaded by other work throughout (uptime load averages are given for each run); treat differences under ~1.5x as noise, and the polars column especially, since polars is multi-threaded too and suffers most from a busy machine.

Operations on a table

The table is built identically, value for value, by bench_test.go and bench/bench_pandas.py: key int64 in [0, 1000), x and y float64 in [0, 1), cat a string among 100 values. Operations:

  • groupby_sum: df.groupby("key").sum() — GroupBy("key").Sum()
  • groupby_sum_string: the same, keyed by the string column
  • sort_values: stable sort by x (pandas kind="stable", polars maintain_order=True)
  • filter: df[df.x > 0.5]
  • merge: inner join with a 1000-row table on key (unique on the right)
  • memory: bytes the table holds (Go: heap growth when building it; pandas: memory_usage(deep=True); polars: estimated_size())

Reproduce: sh bench/run.sh 1000000 5 > out.txt && python3 bench/medians.py out.txt.

Run A — load average 13–18 (10^6: 5 rounds, 10^7: 3 rounds)

10^6 rows, milliseconds:

operationgo-dataframepandaspolars
groupby_sum8.79.36.5
groupby_sum_string19.923.76.0
sort_values34.563.322.0
filter6.67.12.3
merge15.920.04.6
memory (MB)40.034.926.9

10^7 rows, milliseconds (this run predates the parallel factorisation and parallel filter, which help the string group-by and the filter):

operationgo-dataframepandaspolars
groupby_sum88.3103.469.3
groupby_sum_string221.7203.759.7
sort_values445.6972.8298.4
filter74.274.619.8
merge161.1233.854.5
memory (MB)400.0349.0269.0

Run B — final code, load average 35–60

10^6 rows:

operationgo-dataframepandaspolars
groupby_sum11.411.135.5
groupby_sum_string16.625.319.5
sort_values51.681.563.0
filter8.99.14.5
merge17.529.317.5
memory (MB)40.034.926.9

10^7 rows:

operationgo-dataframepandaspolars
groupby_sum99.2243.6140.7
groupby_sum_string120.3384.889.1
sort_values559.81350.7571.3
filter49.285.539.7
merge181.9302.6115.1
memory (MB)400.0349.0269.0

Reading these honestly

  • Against pandas: sorting is about 2x faster (stable LSD radix sort), merge 1.3–1.7x, group-by on an int key at parity to 2x, group-by on a string key and the filter at parity or better. Nothing measured is slower than pandas by more than noise, except the string group-by in run A at 10^7, before factorisation went parallel.
  • Against polars: polars is faster on most operations on a quiet machine (2–4x on filter, merge and string group-by); sorting is close.
  • Memory is the weak point. Numeric columns cost what pandas’ cost (8 bytes a value). A string column is a Go []string: 16 bytes of header per value, plus the bytes (shared here, since there are only 100 distinct values). pandas 3 and polars store strings Arrow-style (offsets + one byte buffer), about 11 bytes a value here — so this table is 40 MB here against 35 MB in pandas and 27 MB in polars. Converting such a column to Category (4 bytes a value) makes it the smallest of the three. An Arrow-style string column is the obvious next step.
  • The merge benchmark joins on keys unique on the right with every left row matched, so the left columns are shared rather than gathered (columns are immutable); a join that duplicates or drops left rows gathers them.
  • Go’s garbage collector scans []string columns; with large string columns a share of the time in allocation-heavy operations (filter, merge) is GC.

CSV reader performance

Measured 2026-10-10 with bench/csv/bench.py (reproduce with the command in its docstring; the data files go to a temporary directory outside the repository).

Conditions

  • Apple M4 Max, 16 cores, 128 GB, macOS 26.6.2; Go 1.27.2; pandas 3.0.6, polars 2.0.0, pyarrow (pandas’ engine="pyarrow"), Python 3.12.12.
  • The machine was shared and loaded: load average 14–16 for most runs, rising to 45 during the last (10^7 rows, dates) series. Every reader ran under the same load, but absolute times are pessimistic and the spread is wide; the ratios are the meaningful part.
  • Table: id (int64), x (standard normal, full 17-digit precision), y (two decimals), cat (cat0..cat49), date (YYYY-MM-DD), as written by pandas’ to_csv. 10^6 rows = 49 MB, 10^7 rows = 497 MB.
  • Method: each read in its own process under /usr/bin/time -l; each process times only the read call (warm page cache, no process start-up). The readers run interleaved (ours, pandas C, pandas pyarrow, polars, ours, …), 5 rounds; the table gives the median (min–max).
  • “Data MB”: Go heap live after the read and a GC (runtime.MemStats. HeapAlloc), pandas memory_usage(deep=True).sum(), polars estimated_size(). These are not quite the same measure: ours includes the input buffer the string columns point into (see the memory trade-off in docs/design.md (CSV section)).
  • “Peak RSS”: maximum resident set size of the whole process. The Python processes include ~118 MB for the interpreter and the imports alone.

Default read (date column left as text)

rowsreadermedian s (min–max)MB/sdata MBpeak RSS MB
10^6go ReadCSV0.039 (0.038–0.047)1247103136
10^6pandas, C engine0.385 (0.383–0.392)12752236
10^6pandas, pyarrow engine0.052 (0.050–0.057)93673262
10^6polars0.037 (0.033–0.044)131037226
10^7go ReadCSV0.407 (0.339–0.546)122210311273
10^7pandas, C engine4.223 (3.849–4.434)1185231349
10^7pandas, pyarrow engine0.525 (0.468–0.790)9467321553
10^7polars0.360 (0.355–0.425)13793701314

With the date column parsed (CSVParseDates("date"), parse_dates=["date"], try_parse_dates=True)

rowsreadermedian s (min–max)MB/sdata MBpeak RSS MB
10^6go ReadCSV0.054 (0.043–0.069)89995128
10^6pandas, C engine0.532 (0.487–0.598)9243221
10^6pandas, pyarrow engine0.699 (0.608–0.838)7043336
10^6polars0.048 (0.036–0.132)100931217
10^7go ReadCSV0.362 (0.332–0.452)13729551197
10^7pandas, C engine5.896 (4.963–13.218)844271257
10^7pandas, pyarrow engine7.729 (5.647–13.264)644272300
10^7polars0.417 (0.344–0.871)11903131223

The 10^7-row date series ran while the load average climbed from 15 to 45; its pandas maxima (13 s) and polars spread show it.

Reading

  • Against pandas’ default C parser: about 10× faster (9.9× at 10^6, 10.4× at 10^7), and 10–16× with dates parsed.
  • Against polars: within 5% at 10^6 and about 13% slower at 10^7 on the default read; level (within noise) with dates. Faster than pandas' pyarrow engine on every run but one.
  • Memory: the peak resident size of a read is in line with pandas and polars (lower once their interpreter baseline is counted), but the frame read holds about 2× pandas’ and 2.8× polars’ bytes, because the strings share the input buffer and Go strings carry 16-byte headers. That is the price of zero-copy strings; see docs/design.md (CSV section).

Go benchmarks (go test -bench 'ReadCSV|WriteCSV', in-memory, same table shape, load ~35): ReadCSV 10^6 rows ≈ 40–48 ms (≈1.1–1.3 GB/s); WriteCSV 10^5 rows ≈ 14 ms (≈400 MB/s).

Where the time goes (profile, 10^7 rows)

About half the CPU is the first conversion pass (integer and float parsing; strconv.ParseFloat for 17-digit values), a fifth the tokenizer, an eighth building the string columns, and a tenth the garbage collector marking the string columns. The remaining gap to polars is mostly float parsing (an Eisel–Lemire parser of our own would skip ParseFloat’s re-scan) and GC work on string headers.

Arrow and Parquet: performance

All measurements write and read a 10^6-row frame in memory, comparing arrowio (IPC) and parquetio (Parquet) with pandas (pyarrow engine). They were taken on 2026-10-10.

Setup

Frame. Six columns, built identically on both sides (benchFrame in arrowio/bench_test.go, for IPC, and in parquetio/bench_test.go, for Parquet; arrowio/testdata/bench_pandas.py on the pandas side):

columntypemissing
float64float641% NaN
int64int64none
string1,000 distinct values1%
category10 levelsnone
boolboolnone
datetimedatetimenone

Operations.

  • Parquet with Snappy compression, the default on both sides. parquetio produces a 15.2 MB file.
  • The Arrow IPC file format without compression. For pandas this is to_feather(compression="uncompressed").

Method.

  • 7 runs, with Go and pandas interleaved: a Go run, then a pandas run, and so on.
  • Each run reports the mean of 3 repetitions of each operation, in milliseconds.
  • The table gives the median of the 7 runs.

Machine. Apple M4 Max, 16 cores. The host is shared and was loaded throughout: uptime load averages were 14.97 / 18.50 / 18.88 before the runs and 14.15 / 17.96 / 18.67 after.

Versions. Go 1.27.2, apache/arrow-go v18.8.0. pandas 3.0.6, pyarrow 26.0.0, Python 3.12.12.

Results

Median of 7 runs, in milliseconds:

operationgo-dataframepandasratio
write Parquet248.7170.71.46x slower
read Parquet40.129.31.37x slower
write IPC file21.033.50.63x (faster)
read IPC file14.75.52.7x slower
Raw runs (milliseconds)

Each line: write Parquet, read Parquet, write IPC, read IPC.

go 1 248.7 36.8 19.5 11.4
py 1 214.3 56.8 34.1 4.8
go 2 240.0 36.8 17.9 12.9
py 2 151.9 26.7 29.6 4.7
go 3 233.2 36.1 21.0 15.6
py 3 168.8 26.0 32.4 5.3
go 4 247.0 40.1 23.4 14.7
py 4 181.2 30.7 34.4 6.2
go 5 291.0 48.2 26.5 16.1
py 5 176.7 30.5 37.1 9.0
go 6 307.6 40.1 20.3 10.7
py 6 170.7 29.3 33.5 5.5
go 7 278.4 46.6 24.6 15.1
py 7 161.3 27.1 33.2 5.8

Where the time goes

Parquet write. About 70% of the allocations happen in arrow-go’s dictionary encoder for byte arrays: hashing.(*BinaryMemoTable).InsertOrGet makes about one allocation per string value (1.0 M allocations per write of the 10^6-row frame). pyarrow uses the same encoding, with dictionaries on by default, but in C++. Our conversion itself shares the numeric buffers and adds one pass for strings.

Parquet read. The time is dominated by arrow-go’s column decoding, which runs in parallel across columns. Our conversion is a minority of the profile:

  • copying the validity bitmaps (with bitutil.CopyBitmap)
  • one copy of each numeric buffer into the frame’s own array
  • one string allocation per chunk for the text

IPC read. pyarrow’s to_pandas is mostly zero-copy for numeric columns, so pandas pays almost nothing to read them. We copy every column once into storage the frame owns, because go-ndarray cannot adopt foreign memory. The string column also gets its []string headers built, and then copied once more by the core’s constructor.

IPC write. This is zero-copy for numeric and datetime columns, which is why it beats pandas.

How to rerun

go test -c -o /tmp/arrowio.test ./arrowio
go test -c -o /tmp/parquetio.test ./parquetio
for i in 1 2 3 4 5 6 7; do
  /tmp/parquetio.test -test.run '^$' -test.bench . -test.benchtime 3x
  /tmp/arrowio.test -test.run '^$' -test.bench . -test.benchtime 3x
  (cd arrowio/testdata &&
    uv run --with pandas --with numpy --with pyarrow python bench_pandas.py)
done

The figures above were taken before Parquet moved to parquetio. The Parquet code itself did not change in the split, only its package.

Created on by tannevaled

Modified on by tannevaled