Data Analysis with Python (pandas, polars, DuckDB) Module 14 – Performance and scale
pandas, polars or DuckDB: a measured guide
Choose between pandas, polars and DuckDB with measured numbers: where the data lives, six everyday tasks, mixing engines through Arrow and a team default.
What you will learn
- Compare pandas, polars and DuckDB on speed, memory, syntax and ecosystem for typical tasks
- Read a benchmark critically, checking hardware, versions, data shape, warm-up and what is timed
- Combine engines in one workflow through Arrow and DuckDB replacement scans
- Choose a team default and write down the reasons and the exceptions
Before you start
What you’ll learn · Before you start
What you will learn
- Compare pandas, polars and DuckDB on speed, memory, syntax and ecosystem for typical tasks
- Read a benchmark critically, checking hardware, versions, data shape, warm-up and what is timed
- Combine engines in one workflow through Arrow and DuckDB replacement scans
- Choose a team default and write down the reasons and the exceptions
Before you start
On this page
For the original million order lines, choose by where the data lives and what happens to it next. pandas is the default for data that fits comfortably in memory and for work that ends in charts, statistics or models, because most Python data libraries accept its DataFrames. polars is a faster DataFrame engine for pipelines of many steps: it can parallelise supported operators, plans lazy queries and can stream supported larger-than-memory workloads subject to operator and state limits. DuckDB is an SQL database that runs inside your Python process: it queries CSV and Parquet files directly, joins and aggregates data bigger than memory, and suits anyone who thinks in SQL. A data hand-off contract: Requirements for values, keys, types, missingness, metadata and result ownership when data moves between libraries or representations. states what must survive conversion. All three exchange data through Apache Arrow, so one workflow can use each where it is strongest. Measured on the same 1,000,000 order lines, the fastest engine changed with where the data lived, so treat any single ranking with suspicion.
Engine defaults include indexing and missing-value rules
| Criterion | pandas | polars | DuckDB |
|---|---|---|---|
| What it is | A DataFrame library with a row index | A DataFrame library built on Arrow memory | An SQL database inside the Python process |
| How you write it | Methods on DataFrames and Series | Expressions, run at once or as a lazy query | SQL, or its Python relational API |
| Cores it uses | Many operations use one thread | A thread pool for supported work | Configured parallel workers for supported work |
| Data bigger than memory | By hand, in chunks | A streaming engine for lazy queries | Spills to disk within a memory limit |
| Missing values | NaN, or pd.NA in nullable dtypes | null, which is not the same as NaN | NULL, which is not the same as NaN |
| Strongest at | Working with plotting, statistics and modelling libraries | Long transformation pipelines | Files, joins and people who write SQL |
The table hides two differences that change code, not just speed. pandas aligns rows by their index, so adding two Series matches labels; polars and DuckDB have no index and work by position or by explicit joins. pandas treats NaN as missing, while polars and DuckDB keep NaN as an ordinary floating-point value and use null for missing data, so a file holding both can give each engine a different answer. The Polars guide describes pandas as largely single-threaded and polars as built for many cores on one machine, and DuckDB uses as many threads as the machine has cores unless told otherwise.
Each engine also has a case where it is the wrong first choice. pandas is the wrong start for a file several times larger than memory, because it loads whole tables before it computes anything. polars is a poor fit when every step ends in a library that accepts only pandas, since each hand-over costs a conversion. DuckDB is awkward for logic written row by row or around an index, and a loop of small queries over a DataFrame pays the cost of taking the DataFrame in on every call.
Distinguish index alignment, explicit keys and missing-value conversion before comparing engine outputs.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- pandas operations can align values by row labels. A port needs to make that identity explicit.
- The demonstrated polars and SQL operations use columns or row positions rather than a pandas index.
- A native NaN can remain a value in polars/SQL, while the default pandas conversion can produce null. Inspect the constructed input and conversion method.
Checkpoint
Check: Engine defaults include indexing and missing-value rules
Measure where the same data is read from
# Writes the seeded order lines this lesson's examples use (never downloaded): lines.parquet and lines.csv with
# line_id, order_date, country (40 values), product (4,000 codes), quantity and price, and products.parquet with
# each product's category.
import numpy as np
import pyarrow as pa
import pyarrow.csv as pacsv
import pyarrow.parquet as pq
CATEGORIES = ["grocery", "home", "toys", "books", "garden", "beauty", "sport", "kitchen", "office", "pets", "music", "travel"]
def make_lines(rows=1_000_000, seed=3, csv=False):
rng = np.random.default_rng(seed)
products = np.array([f"P{i:04d}" for i in range(4000)])
lines = pa.table(
{
"line_id": np.arange(1, rows + 1),
"order_date": pa.array((np.datetime64("2025-04-01") + rng.integers(0, 365, rows).astype("timedelta64[D]")).astype("datetime64[us]")),
"country": pa.array(np.array([f"Country {i:02d}" for i in range(40)])[rng.integers(0, 40, rows)]),
"product": pa.array(products[rng.integers(0, 4000, rows)]),
"quantity": rng.integers(1, 20, rows),
"price": rng.integers(50, 5000, rows) / 100,
}
)
pq.write_table(lines, "lines.parquet")
if csv:
pacsv.write_csv(lines, "lines.csv")
pq.write_table(pa.table({"product": products, "category": np.array(CATEGORIES)[rng.integers(0, 12, 4000)]}), "products.parquet") # Revenue by country from 1,000,000 order lines, five ways: each engine on its own in-memory table, each reading
# the Parquet file, and DuckDB querying the pandas DataFrame. One warm-up run, then the median of 5 runs.
import math
import os
import statistics
import time
import duckdb
import pandas as pd
import polars as pl
from make_lines import make_lines
make_lines()
pandas_df = pd.read_parquet("lines.parquet")
polars_df = pl.read_parquet("lines.parquet")
con = duckdb.connect()
con.execute("SET enable_progress_bar = false")
SQL = "SELECT country, sum(quantity * price) AS revenue FROM {} GROUP BY country"
ways = {
"pandas, its own DataFrame": lambda: pandas_df.assign(revenue=pandas_df["quantity"] * pandas_df["price"])
.groupby("country")["revenue"].sum(),
"polars, its own DataFrame": lambda: polars_df.group_by("country").agg((pl.col("quantity") * pl.col("price")).sum().alias("revenue")),
"polars, reading Parquet": lambda: pl.scan_parquet("lines.parquet").group_by("country")
.agg((pl.col("quantity") * pl.col("price")).sum().alias("revenue")).collect(),
"DuckDB, reading Parquet": lambda: con.execute(SQL.format("'lines.parquet'")).df(),
"DuckDB, the pandas DataFrame": lambda: con.execute(SQL.format("pandas_df")).df(),
}
def median_ms(func):
func() # warm-up, not timed
times = []
for _ in range(5):
start = time.perf_counter()
func()
times.append(time.perf_counter() - start)
return statistics.median(times) * 1000
def as_dict(result):
if isinstance(result, pd.Series):
return result.to_dict()
if isinstance(result, pl.DataFrame):
return dict(zip(result["country"].to_list(), result["revenue"].to_list()))
return dict(zip(result["country"], result["revenue"]))
def same(a, b): # the same countries, and revenues equal up to rounding in the last digits
return a.keys() == b.keys() and all(math.isclose(a[k], b[k], rel_tol=1e-9) for k in a)
print(f"pandas {pd.__version__}, polars {pl.__version__}, DuckDB {duckdb.__version__}, {os.cpu_count()} CPU cores, {len(pandas_df):,} lines")
answers = []
for label, func in ways.items():
print(f"{label:<30}{median_ms(func):>8.1f} ms")
answers.append(as_dict(func()))
print("all five agree on revenue per country:", all(same(a, answers[0]) for a in answers)) Output
pandas 3.0.6, polars 1.44.2, DuckDB 1.5.6, 18 CPU cores, 1,000,000 lines pandas, its own DataFrame 27.5 ms polars, its own DataFrame 12.6 ms polars, reading Parquet 26.8 ms DuckDB, reading Parquet 16.9 ms DuckDB, the pandas DataFrame 85.3 ms all five agree on revenue per country: True
This output changes from run to run: the times change from run to run and from one computer to another
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_data_lives.py
All five ways return the same revenue for every country, and the slowest took about seven times as long as the
quickest. polars on its own DataFrame was the quickest, using every core for the group-by. pandas on its own
DataFrame took about twice as long, still only tens of milliseconds. Reading straight from the Parquet file, DuckDB
was quicker than polars. The slowest way was DuckDB querying the pandas DataFrame, the very data pandas had summed
in a third of the time: before DuckDB can group the rows, it has to take in the frame’s columns, and pandas 3’s
str columns are expensive to take in. The query was the same each time; what changed was where the data lived.
Keep all five original million-row paths and compare their boundary work before using a speed ranking.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original million-row fixture is unchanged across all five measured paths.
- One path starts from an engine's own frame; another reads Parquet. They include different representation and transfer work.
- The original pandas-to-DuckDB path has a larger measured boundary cost on these pins. The ranking is evidence of that workload rather than a universal engine rule.
Checkpoint
Check: Measure where the same data is read from
A decision record joins workload, evidence and consumers
An engine choice includes the path into and out of the calculation. The application may need an Arrow table, a plotting frame or a scalar count; those are different timed consumers. State the required keys, types and missingness before choosing a hand-off path, then measure that same path on the relevant data. A convenient API can justify a choice even when a microbenchmark ranks its arithmetic differently.
Which engine for which situation
Text description of the diagram
The diagram starts from one question: where does the data live, and what happens to it next? Three answers lead to three engines.
- Data in files or a database, larger than memory, or questions asked in SQL: DuckDB.
- A Python pipeline of many steps on one machine, where speed matters: polars.
- Charts, statistics and models from libraries that expect pandas: pandas.
All three hand data to each other through Apache Arrow, one columnar format that each of them reads.
Four ideas carry the rest of the lesson:
- Apache Arrow is a columnar memory format that pandas, polars and DuckDB all read, so data can move between them without being rebuilt value by value.
- Replacement scans let DuckDB query a pandas or polars DataFrame, or an Arrow table, by the name of its Python variable.
- A benchmark harness runs the same tasks in each engine, discards a warm-up run and reports the median of several runs, next to the versions and the hardware.
- A team default is the engine new work starts in, with a short written list of the cases that justify another.
Follow the retained diagram from input location to the result consumer and its decision evidence.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The retained decision diagram starts from data location and the operations the application needs.
- A small result may be handed to a downstream library. The consumer's required format belongs in the timed task.
- A default should record assumptions, measured evidence and exceptions. Arrow enables compatible transfer but not universal zero-copy or unchanged metadata.
Checkpoint
Check: A decision record joins workload, evidence and consumers
Six everyday tasks measured, and how to read any benchmark Pro: opens with a Pro, Premium or Ultimate pass
A single number never settles an engine choice. Six everyday tasks are measured here in all three engines on the same data, and three common benchmark traps are reproduced with numbers.
In this part:
- A harness checks results before interpreting timings
- Imports and materialisation belong to the timed contract
2 diagrams · 2 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
One workflow, three engines: what crosses Arrow intact and what changes Pro: opens with a Pro, Premium or Ultimate pass
The recorded Arrow hand-overs cost little here, but supported types, conversions and result size still decide the cost. A three-engine workflow is timed step by step here, and the values and types that change on the way are reproduced.
In this part:
- A hand-off preserves a declared value and type contract
- Native NaN, null and index metadata are separate
2 diagrams · 2 runnable examples (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A team default, decided: eight scenarios and a decision record Pro: opens with a Pro, Premium or Ultimate pass
Most teams do best with one default engine and a short list of exceptions, written down with their reasons. Eight common scenarios are decided here with this lesson’s measurements, followed by a decision record to adapt.
In this part:
- A scenario recommendation includes its resource assumptions
- Write the default together with evidence and exceptions
- Recheck the same fixture after a library change
3 diagrams · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise the preserved performance contracts
Practice that adapts to you
Practise this lesson
7 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher · Warm-up
What is Apache Arrow, and why does it matter when one workflow uses pandas, polars and DuckDB?
Apache Arrow is a standard columnar memory format: each column is kept as contiguous buffers of values, with a separate bitmap that marks which values are null, laid out the same way in every program that uses it. polars keeps its data in Arrow memory, DuckDB can read and return Arrow tables, and pandas can store columns in Arrow and convert to and from it. When data moves from one engine to the next as Arrow, columns can often be shared or copied in bulk instead of being rebuilt value by value through Python objects, which is where conversion time usually goes. A shared format is not a shared meaning, though: NaN and null still behave differently in pandas than in polars and DuckDB, so a workflow that mixes engines decides at each boundary what NaN means.
-
Mid to senior
A colleague's notebook shows polars running 10 times faster than pandas on "our workload" and proposes moving every report to polars. What do you check before agreeing, and how would you run a fair comparison?
-
Mid to senior
After DuckDB is added to a pandas 3 pipeline, a query on a 1,000,000-row DataFrame takes about 0.2 seconds per call instead of milliseconds, inside a loop that runs 2,000 times. What is happening, and how do you fix it?
-
Senior to staff
Your analytics team of eight writes pandas notebooks, and data volumes have grown tenfold in a year. Propose a default engine and a policy for exceptions, and say how you would know after six months whether the change worked.
Key takeaways
- pandas for data that fits and work that ends in other Python libraries, polars for fast pipelines on one machine, DuckDB for files, SQL and data bigger than memory.
- The fastest engine depends on where the data lives: the same group-by was quickest in polars on its own frame and slowest when DuckDB had to take in a pandas DataFrame.
- pandas treats NaN as missing; polars and DuckDB keep NaN as a value and use null for missing data.
- Arrow is the common format between the three.
Terms in this lesson
- data hand-off contract
- Requirements for values, keys, types, missingness, metadata and result ownership when data moves between libraries or representations.
- engine decision record
- A written choice with workload context, evidence, assumptions, exceptions and triggers for reassessment.
- result materialisation
- Producing the requested result representation from a computation, such as a count, typed table, DataFrame or row tuples.
- result row shape
- The number and meaning of cells in a result row, checked alongside row counts and values when comparing outputs.
References
- Comparison with other tools (Polars user guide) (Polars)
- Coming from pandas (Polars user guide) (Polars)
- Missing data (Polars user guide) (Polars)
- Categorical data and enums (Polars user guide) (Polars)
- polars.from_arrow (Polars)
- polars.DataFrame.to_pandas (Polars)
- Data ingestion: DataFrames and Arrow objects (DuckDB Python client) (DuckDB Foundation)
- Conversion between DuckDB and Python (DuckDB) (DuckDB Foundation)
- Numeric types: NaN and infinity (DuckDB) (DuckDB Foundation)
- Benchmarks (DuckDB guides) (DuckDB Foundation)
- Configuration overview: threads (DuckDB) (DuckDB Foundation)
- pandas integration (Apache Arrow Python) (The Apache Software Foundation)
- Arrow columnar format (The Apache Software Foundation)
- Scaling to large datasets (pandas user guide) (pandas development team (NumFOCUS))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress