pandas vs Polars vs Dask — In-Depth Comparison (2025)
Versions: pandas 3.0 · Polars 2.1 · Dask 2025.4
Choosing between the three is not about which is “best” — it is about matching the tool to the data size, machine scope, and ecosystem constraints of your problem.
Quick Decision Matrix
Data size Machine scope Pandas compat needed? → Pick
──────────────────────────────────────────────────────────────
< 1 GB single yes pandas 3.0
1–50 GB single no Polars (lazy + streaming)
1–50 GB single yes Dask (local threads)
> 50 GB single no Polars streaming / DuckDB
> 50 GB cluster either Dask distributed
Any size single SQL-first DuckDB
GPU workload single/cluster either RAPIDS cuDF / dask-cudf
1. Architecture & Execution Model
| pandas | Polars | Dask | |
|---|---|---|---|
| Execution | Eager only | Eager (DataFrame) + Lazy (LazyFrame) |
Lazy only (.compute() triggers) |
| Parallelism | Single-threaded (GIL-bound) | Multi-threaded (Rust work-stealing pool) | Multi-threaded local or distributed cluster |
| Query optimizer | None | Full IR optimizer (predicate pushdown, join reordering, CSE, cardinality-aware) | dask-expr: projection + predicate pushdown, task fusion — no cost-based |
| Streaming / OOC | No | Yes — chunked streaming engine, spills to disk at 80% RAM | Partition-based; each partition must fit in RAM |
| Distributed | No | Experimental RemoteEngine (Polars 2.0) |
Yes — dask.distributed with scheduler + workers |
| Core language | Python + NumPy (C extensions) | Rust (SIMD-optimized) | Python orchestration + pandas per partition |
Mental model:
pandas: [data in RAM] → execute now → result
Polars: [build expression tree]
→ optimizer rewrites (predicate pushdown, join reorder, CSE)
→ execute (in-memory or streaming chunks)
Dask: [build task DAG]
→ dask-expr rewrites (projection/predicate pushdown, task fusion)
→ scheduler executes DAG on workers
The key architectural difference: Polars’ optimizer is a relational query planner (like a SQL engine). Dask’s optimizer works at the task-graph level — shallower, no cost-based decisions.
2. Memory Model
pandas 3.0
- NumPy arrays per column; Python objects for mixed/string types (28+ bytes each)
- Copy-on-Write is now default — views share a buffer; copy only on first write. Eliminates
SettingWithCopyWarning strdtype backed by PyArrow if installed, otherwise NumPyobjectdtype_backend="pyarrow"opts into fully Arrow-backed columns- Hard limit: entire DataFrame must fit in RAM
Polars
- Native Apache Arrow columnar format throughout — no NumPy
SeriesareChunkedArray;rechunk()called automatically when contiguity is needed- Zero-copy interop with PyArrow, DuckDB, Arrow-backed pandas via C Data Interface
- No row index — intentional; eliminates alignment bugs and index memory overhead
- OOC spilling default since Polars 2.0: at 80% RAM usage, data spills to disk. Polars 2.1 added compression and prefetching for spilled chunks
Dask
- Each partition is a pandas DataFrame — inherits all pandas memory characteristics
- No global in-memory data structure; data lives across worker processes/threads
- 128 MB per partition is the conventional target
- Too small → scheduler overhead dominates
- Too large → OOM risk
- Spilling to disk requires
dask.distributedwith memory limits configured on workers
3. Performance Benchmarks
TPC-H PDS-H (AWS c7a.24xlarge — 96 vCPUs / 192 GB RAM, 22 queries)
| SF-10 (~10 GB) | SF-100 (~100 GB) | |
|---|---|---|
| Polars streaming | 3.89 s | 23.94 s |
| Polars in-memory | 9.68 s | 152.27 s |
| DuckDB | ~3 s | ~19.5 s |
| Dask | 46.02 s | 548.52 s |
| pandas | 365.71 s | OOM |
Speedup ratios at SF-10:
- Polars streaming is ~12× faster than Dask
- Polars streaming is ~94× faster than pandas
- Dask is ~8× faster than pandas (parallelism across partitions)
Why the gaps exist
pandas → Polars (~94×):
Single-threaded + NumPy vs SIMD + 96-core work-stealing.
On a 96-core machine, theoretical max is ~96× — benchmark shows ~94×
(I/O bottleneck dominates at streaming level).
Dask → Polars (~12×):
Python task scheduling overhead (~1–5 ms per task).
Each partition runs single-threaded pandas internally.
At SF-10: scheduling overhead dominates, not the compute itself.
pandas → Dask (~8×):
Dask wins by parallelising across partitions.
But each partition still runs single-threaded pandas — no SIMD.
4. API Comparison
Filter
# pandas
df[df["age"] > 30]
df.query("age > 30")
# Polars eager
df.filter(pl.col("age") > 30)
# Polars lazy — pushed into file scan by optimizer
lf.filter(pl.col("age") > 30)
# Dask (lazy, mirrors pandas)
ddf[ddf["age"] > 30]
GroupBy + Aggregation
# pandas
df.groupby("dept").agg(mean_sal=("salary", "mean"), max_sal=("salary", "max"))
# Polars — expressions are first-class, composable, type-safe
df.group_by("dept").agg([
pl.col("salary").mean().alias("mean_sal"),
pl.col("salary").max().alias("max_sal"),
])
# Dask (lazy, pandas-like)
ddf.groupby("dept")["salary"].mean().compute()
Join
# pandas
pd.merge(left, right, on="id", how="left")
# Polars — also has join_where() for non-equi joins
left.join(right, on="id", how="left")
# Dask — large-large joins trigger a full shuffle (expensive)
dd.merge(left_ddf, right_ddf, on="id", how="left")
Apply / UDF
# pandas — row-wise, slow (Python loop)
df.apply(lambda row: f(row["a"], row["b"]), axis=1)
# Polars — avoid map_elements; use native expressions instead
# BAD (breaks SIMD/parallelism):
df.with_columns(
pl.struct(["a","b"]).map_elements(lambda r: r["a"] + r["b"], return_dtype=pl.Int64)
)
# GOOD (native expression — optimizer can parallelize):
df.with_columns((pl.col("a") + pl.col("b")).alias("c"))
# Dask — map_partitions applies any pandas function across partitions
ddf.map_partitions(lambda df: df.assign(c=df["a"] + df["b"]))
Window Functions
# pandas
df["rank"] = df.groupby("dept")["salary"].rank(method="dense")
# Polars — .over() is cleaner; no groupby object needed
df.with_columns(
pl.col("salary").rank("dense").over("dept").alias("rank")
)
# Dask — not directly supported; requires map_partitions + careful partitioning
5. Lazy Evaluation Deep-Dive
Polars LazyFrame — relational query planner
lf = pl.scan_csv("data.csv") # no I/O yet
result = (
lf
.filter(pl.col("age") > 30) # pushed into scan: only matching rows read
.select(["name","age","dept","salary"]) # only these columns read from disk
.group_by("dept")
.agg(pl.col("salary").mean())
.sort("salary", descending=True)
)
print(result.explain()) # print the optimized logical plan
result.collect() # execution starts here (in-memory)
result.collect(streaming=True) # chunked execution if data > memory budget
Optimizer passes (applied in order):
| Pass | What it does |
|---|---|
| Predicate pushdown | Moves filter to scan time — CSV/Parquet only reads matching rows |
| Projection pushdown | Only reads columns that are referenced downstream |
| Join reordering | Cost-based swap of join sides using cardinality estimates (Polars 2.0+) |
| Common subexpression elimination (CSE) | Deduplicates repeated sub-expressions in the plan |
| Streaming plan selection | Auto-switches to chunked execution if memory budget would be exceeded |
Dask task graph — task-level DAG
ddf = dd.read_csv("data-*.csv") # lazy; one graph node per file glob match
result = ddf[ddf["age"] > 30].groupby("dept")["salary"].mean()
result.visualize() # render the DAG as a PNG (requires graphviz)
result.compute() # scheduler executes the full DAG
dask-expr (default since 2024) adds an expression tree above the task graph enabling:
- Projection and predicate pushdown for Parquet
- Adjacent-map task fusion
What it does NOT do: join reordering, cardinality estimation, cost-based planning.
Since Dask 2025.4.0, graph optimization runs on the distributed scheduler for multi-collection queries, reducing client-side serialization overhead.
6. Dask Schedulers
Local schedulers
| Scheduler | Best for |
|---|---|
synchronous |
Debugging — serial execution, full Python stack traces |
threads (default for DataFrame) |
I/O-heavy work; NumPy/pandas release the GIL in C extensions |
processes |
CPU-heavy pure Python; bypasses GIL via separate processes |
import dask
dask.config.set(scheduler='synchronous') # debug: serial, clear errors
dask.config.set(scheduler='threads') # default for DataFrames
dask.config.set(scheduler='processes') # process pool, bypasses GIL
dask.distributed — cluster scheduler
from dask.distributed import Client
client = Client() # local cluster (spawns worker processes)
client = Client("scheduler-host:8786") # connect to a remote cluster
result.compute() # automatically routes to the distributed scheduler
Architecture:
- Scheduler process: single coordinator; tracks task graph, worker state, future dependencies
- Worker processes: execute tasks, cache intermediates in local memory
- Dashboard: live Bokeh UI at
:8787— task stream, memory pressure, network bandwidth - Adaptive scaling: integrates with Kubernetes, YARN, SLURM via
client.adapt(minimum=2, maximum=100)
Local threaded vs distributed: use local threads for data that fits on one machine. Switch to distributed when: (1) data exceeds single-machine RAM, (2) fault tolerance is required, or (3) scaling to a cluster.
7. Interoperability
All three libraries now share Apache Arrow as a common memory format. When both sides use Arrow-backed storage, conversions are zero-copy (no data duplication).
# pandas → Polars (zero-copy if pandas uses Arrow backend)
pl_df = pl.from_pandas(pd_df)
pl_df = pl.from_pandas(pd_df, use_pyarrow_extension_array=True) # zero-copy
# Polars → pandas
pd_df = pl_df.to_pandas(use_pyarrow_extension_array=True) # zero-copy
# pandas ↔ Dask
ddf = dd.from_pandas(pd_df, npartitions=4)
pd_df = ddf.compute()
# Any ↔ Arrow (zero-copy)
arrow_table = pl_df.to_arrow()
pl_df = pl.from_arrow(arrow_table)
# pandas 3.0 ↔ Arrow (new PyCapsule interface — no pyarrow import needed)
df = pd.DataFrame.from_arrow(arrow_table)
# DuckDB ↔ Polars (zero-copy, no intermediate copy)
import duckdb
result = duckdb.sql("SELECT * FROM pl_df WHERE age > 30").pl() # returns Polars df
result = duckdb.sql("SELECT * FROM pd_df WHERE age > 30").df() # returns pandas df
Polars → Dask (no direct path yet)
# Go through Arrow → pandas → Dask
arrow_table = pl_df.to_arrow()
pd_df = arrow_table.to_pandas(types_mapper=pd.ArrowDtype) # Arrow-backed pandas
ddf = dd.from_pandas(pd_df, npartitions=4)
8. Key Limitations
pandas
| Limitation | Notes |
|--|--|
| Single-threaded | No parallelism even with Arrow backend |
| Must fit in RAM | No streaming, no spill |
| apply() is slow | Python-level row iteration; no SIMD possible |
| Index complexity | Largely solved by CoW default in 3.0, but multi-index still tricky |
Polars
| Limitation | Notes |
|--|--|
| No distributed compute | RemoteEngine is experimental — not production-ready |
| No row index | Intentional; requires mindset shift from pandas |
| map_elements breaks the engine | Python UDFs disable SIMD and parallelism; prefer native expressions |
| Smaller ecosystem | Less sklearn / statsmodels / plotly native integration |
| Faster release cadence | More breaking changes between versions than pandas |
Dask
| Limitation | Notes |
|--|--|
| Scheduler overhead | ~1–5 ms per task; thousands of tasks = seconds of overhead |
| No cost-based optimizer | No join reordering, no cardinality estimates |
| Partial pandas coverage | pivot_table, some multi-index ops, swaplevel not fully supported |
| Large-large joins are expensive | Full shuffle across workers required |
| Debugging is hard | Errors surface at .compute() with graph-node stack traces, not user-code lines |
| Slower than pandas on small data | Task overhead dominates below ~10k rows |
9. Ecosystem Integrations
| pandas | Polars | Dask | |
|---|---|---|---|
| scikit-learn | Native | Via .to_pandas() |
Dask-ML (parallel sklearn) |
| XGBoost / LightGBM | Native | Native (XGB 1.7+, LGBM 4.0+) | Native Dask callbacks |
| PyTorch / TF | Via numpy | Via numpy (zero-copy Arrow) | Via numpy per partition |
| SQL | Via DuckDB | Built-in SQLContext + DuckDB |
FugueSQL / BlazingSQL |
| Parquet | pyarrow / fastparquet | Native (fastest reader) | pyarrow |
| Delta Lake | deltalake pkg |
deltalake pkg |
dask-deltatable |
| Iceberg | read_iceberg() (pandas 3.0) |
Native (Polars 2.0+) | dask-iceberg |
| GPU (RAPIDS) | cuDF (pandas API on GPU) | Polars → Arrow → cuDF | dask-cudf |
| Monitoring | None | explain(), profile(), show_graph() |
Bokeh dashboard (:8787), OpenTelemetry |
SQL support comparison
# Polars built-in SQL (full engine: CTEs, window funcs, subqueries)
ctx = pl.SQLContext({"sales": df})
result = ctx.execute("""
SELECT dept, AVG(salary) AS avg_sal
FROM sales
WHERE age > 30
GROUP BY dept
ORDER BY avg_sal DESC
""")
# DuckDB on any of the three (recommended for SQL-heavy work — fastest in class)
import duckdb
duckdb.sql("SELECT dept, AVG(salary) FROM df GROUP BY dept") # pandas df
duckdb.sql("SELECT dept, AVG(salary) FROM pl_df GROUP BY dept") # polars df
duckdb.sql("SELECT dept, AVG(salary) FROM parquet_scan('*.parquet') GROUP BY dept")
Streaming / real-time
- pandas: No streaming. Use with Kafka via
kafka-pythonmanually. - Polars: Streaming engine processes files in chunks — not event-driven. Use
collect(streaming=True)orscan_*functions. - Dask: Batch-oriented. For true event streaming use Bytewax, Faust, or Kafka + Flink.
streamzexists but is not actively maintained.
10. File Format Support
| Format | pandas | Polars | Dask |
|---|---|---|---|
| Parquet | ✓ (pyarrow/fastparquet) | ✓ native — fastest | ✓ (pyarrow) |
| CSV | ✓ | ✓ multithreaded reader | ✓ glob patterns |
| Arrow IPC / Feather | ✓ | ✓ native | ✓ |
| Delta Lake | deltalake pkg |
deltalake pkg |
dask-deltatable |
| Apache Iceberg | read_iceberg() (pandas 3.0) |
✓ Polars 2.0+, schema evolution | dask-iceberg |
| JSON | ✓ | ✓ | ✓ |
| GeoParquet | Via geopandas | Experimental (Polars 2.1) | Via geopandas |
Summary
| pandas 3.0 | Polars 2.1 | Dask 2025.4 | |
|---|---|---|---|
| Speed | Baseline | 10–94× faster | 8× faster than pandas |
| Memory | Must fit in RAM (CoW helps) | Native Arrow + OOC spill | Partitioned; spill needs distributed |
| Lazy eval | No | Yes — full query planner | Yes — task graph + dask-expr |
| Distributed | No | Experimental | Production-ready |
| API familiarity | Most familiar | Different but clean | pandas-compatible |
| Ecosystem | Best | Growing | Good (sklearn, XGBoost) |
| Best for | EDA, ML pipelines, small data | Single-machine ETL / analytics | Cluster-scale pandas work |
The one-line summary:
- pandas when ecosystem compatibility matters more than speed
- Polars when speed matters and you are on a single machine
- Dask when the data genuinely does not fit on one machine