Check the Doorway: CSV vs Parquet on Five Million Rows
Face-off / Trend Signal

Check the Doorway: CSV vs Parquet on Five Million Rows

Our pandas vs Polars vs DuckDB race ended with a piece of advice: check the doorway first. If loading the data is most of your wait, a better file format may beat a new library.

It’s good advice. It’s also far easier to give than to measure. So we went back to the same five million orders, on the same 2-core machine, and measured it.

What’s actually behind the door

Start with what’s in the files. A CSV is text. Every number in it is spelled out in characters, every date is a string, and nothing in the file says which is which, so every tool that opens it has to read all 210 MB, split it on commas and work out the types again. Every time.

Parquet is built the other way round. It is a columnar format: it stores each column separately, compressed, with its type written into the file (ours records order_date as timestamp[us]). Our file of orders shrank from 209.5 MB as CSV to 68.2 MB as Parquet with pandas’ default settings.

The same five million orders as a 209.5 MB CSV, which is lines of text, and as a 68.2 MB Parquet file, which stores each of the seven columns separately with its type. The four columns the revenue question needs take 19.2 MB.CSV · 209.5 MBorder_id,customer_id,region,…0,188980,Plains,24,2025-04-24,5,111.621,125019,East,4,2025-12-30,4,110.86…5,000,000 lines of textPARQUET · 68.2 MB, SIZE OF EACH COLUMNint6421.4 MBint6423.8 MBstring1.9 MBint643.8 MBtimestamp6.3 MBint641.9 MBdouble9.1 MBcyan: the four columns our revenue question needs, 19.2 MB of 68.2 MB
Fig. 1 — Same five million orders, two files. The CSV is lines of text; the Parquet file is seven typed columns, compressed one by one.

That much is well known. What’s less often shown is whether it matters on a modest machine, and by how much. And there’s an awkward possibility to rule out first: that the slow part was never the format, just pandas’ own CSV reader.

The one-argument fix

First, the cheap option. pandas has a second CSV engine, PyArrow’s, and switching to it takes one argument. If that closes the gap, the whole format question is a red herring. So we tried it first.

It made things worse. With the same parse_dates as before, the PyArrow engine took 5.88 s to read the file, against 3.47 s for the default engine. We didn’t believe it either, so we took it apart in a second session (which is why the default engine shows a slightly different time below; more on that drift at the end):

pd.read_csv(f, parse_dates=['order_date'])
# 4.19 s
pd.read_csv(f, engine='pyarrow', parse_dates=['order_date'])
# 6.56 s
pd.read_csv(f, engine='pyarrow')
# 0.97 s, and order_date comes back as object
pd.read_csv(f, engine='pyarrow', dtype_backend='pyarrow')
# 0.87 s, and order_date comes back as date32[day][pyarrow]

Here’s what happens. PyArrow reads the dates as proper dates. pandas has no plain date type of its own, so they arrive as five million Python date objects, and parse_dates then has to turn all of those back into a date column. That’s where the time goes. Drop parse_dates and the read takes 0.97 s, but the dates arrive as a column of Python objects that no date method will touch. Add dtype_backend='pyarrow' and it’s quick with real dates, at the price of Arrow-backed types in every column, which some code won’t expect. So the one-argument fix is a two-argument fix with a new set of types. It works. It also isn’t a free lunch.

Changing the door

Then we did the thing the advice actually said. We wrote the orders to Parquet once with each library’s defaults, and timed every library reading the same file, the one pandas wrote:

df = pd.read_csv('orders.csv', parse_dates=['order_date'])
df.to_parquet('orders.parquet')                 # pandas defaults: snappy

orders = pd.read_parquet('orders.parquet')     # types come back with it
four   = pd.read_parquet('orders.parquet',
                         columns=['region', 'order_date', 'qty', 'unit_price'])
Reading all five million rows. pandas: CSV 3.47 s, Parquet 252 ms. Polars: CSV 978 ms, Parquet 112 ms. DuckDB: CSV 1.20 s, Parquet 311 ms.READ ALL 5,000,000 ROWS · MEDIAN OF 7pandasCSV 3.47 sParquet 252 msPolarsCSV 978 msParquet 112 msDuckDBCSV 1.20 sParquet 311 ms
Fig. 2 — Reading all five million rows, median of 7 runs after a warm-up. Faded bars are CSV, solid bars Parquet.

Here’s the number that sets the tone. pandas read the CSV in 3.47 s and the Parquet file in 252 ms, about 14 times faster. Polars went from 978 ms to 112 ms, and DuckDB from 1.20 s to 311 ms. The slowest Parquet read, 311 ms, beat the fastest CSV read, 978 ms. Every answer was checked: the same 5,000,000 rows, the same revenue total of 1,067,372,417.25, the same date range and the same customer ids, in every case.

Most questions don’t need every column, though. Our revenue question uses four of the seven, and this is where the two formats really part company. Asked for four columns, pandas took 3.58 s from the CSV, no quicker than reading all seven, because a CSV reader still has to read every byte to find the commas. From Parquet it took 124 ms, and Polars 38 ms. Those four columns are 19.2 MB of the 68.2 MB file; the other three were simply never read.

TaskpandasPolarsDuckDB
All 7 columns, CSV3.47 s978 ms1.20 s
All 7 columns, Parquet252 ms112 ms311 ms
4 columns, CSV3.58 s789 ms1.02 s
4 columns, Parquet124 ms38 ms185 ms
Whole job, CSV3.91 s1.37 s1.24 s
Whole job, Parquet1.05 s596 ms388 ms

The whole job

Reading is only the doorway, though. The question that matters is the whole job: open the file, then total revenue by region and month. In the race, most of pandas’ time on a job like that went on the opening. So we ran the full job six ways, each library from each file, and checked that all six produced identical 192 region-by-month totals, compared to two decimal places.

pandas on Parquet beat Polars and DuckDB on CSV.

The whole job, read plus revenue by region and month, fastest first: DuckDB on Parquet 388 ms; Polars on Parquet 596 ms; pandas on Parquet 1.05 s; DuckDB on CSV 1.24 s; Polars on CSV 1.37 s; pandas on CSV 3.91 s. pandas reading Parquet finishes ahead of Polars and DuckDB reading the CSV.READ + REVENUE BY REGION AND MONTHDuckDB · Parquet388 msPolars · Parquet596 mspandas · Parquet1.05 sDuckDB · CSV1.24 sPolars · CSV1.37 spandas · CSV3.91 s
Fig. 3 — Read plus revenue by region and month, fastest first. pandas from Parquet took 1.05 s; Polars and DuckDB from the CSV took 1.37 s and 1.24 s.

Read that again: the slowest library, on the better file, finished ahead of both faster libraries on the worse file. Polars and DuckDB on Parquet were faster still, so this is no case for pandas over the other two. It’s a case for looking at the door before you move house. On this job, on this machine, the file format was worth more than the library.

And the conversion pays for itself almost at once. Reading the CSV and writing Parquet took pandas about 4.39 s, Polars about 1.63 s, and DuckDB 1.69 s for one COPY statement that does both. For all three, the time saved on later reads had covered the conversion by the second read.

If you only take one habit from this
  • Convert once, read many times. If a CSV gets read more than once, write it to Parquet the first time.
  • Ask for the columns you need. With Parquet, columns=[...] skips the rest of the file. With CSV it saves almost nothing.
  • Let the types travel. Parquet remembers that a date is a date, so nobody has to pass parse_dates again.
  • Keep the CSV for people. It is still the format anyone can open. Parquet is for the machines in between.

One wrinkle we didn’t expect: the three libraries write different Parquet files from the same data. Polars’ default compression, zstd, made the smallest file, 48.6 MB, against 68.2 MB for pandas and 69.3 MB for DuckDB, which both default to snappy. Which file read fastest depended on the reader, so we timed everything on the pandas-written file and kept the rest in the results file.

As for the caveats: after the warm-up run the files sit in the operating system’s memory, so these numbers time parsing and decoding, not the disk; on a slow disk the smaller file should help even more. The data is synthetic and random, which gives compression little to work with. And this cloud machine drifts: the same pandas CSV read took 4.19 s in a later session, 21% slower than in the main run. The medians are steady within a run, but treat the third digit with suspicion. We used pandas 3.0.5, Polars 2.0.0 and DuckDB 2.0.0 with PyArrow 26.0.0 on Python 3.13.16, each read in a fresh process, 7 timed runs after one warm-up.

The door you haven’t checked

Which leaves an awkward thought. The cheapest speed-up in this whole series didn’t involve learning a new library. It was one line that writes a different kind of file, run once. Meanwhile our own datasets library, the one in the box above, holds 399 files without a single Parquet among them. With none bigger than 230 KB, that is still the right call.

At what size does it stop being one?

References

On LucidPlexus
Elsewhere

Comments

No comments yet. Be the first to share your thoughts.