Polars Python Tutorial 2026: Faster DataFrames with LazyFrames, Expressions, and pandas Migration

Polars Python Tutorial 2026: Faster DataFrames with LazyFrames, Expressions, and pandas Migration

Polars has grown 300% in downloads year-over-year and is rapidly displacing pandas as the default DataFrame library for performance-critical Python workloads. Built on Rust and Apache Arrow, it consistently delivers 5–50x speed improvements over pandas on real-world datasets. This polars python tutorial covers everything you need to be productive in 2026 — from installation through LazyFrames, expressions, and a full pandas migration guide.

1. Why Polars Beats pandas in 2026

Three architectural decisions separate Polars from pandas at a fundamental level.

Rust core with no GIL

Polars is written in Rust and releases Python's Global Interpreter Lock (GIL) during computation. Every parallelisable operation — sorting, filtering, grouping, joining — runs across all CPU cores simultaneously. pandas is single-threaded by default and cannot use multiple cores without explicit multiprocessing boilerplate.

Apache Arrow memory model

Polars stores all data in the Apache Arrow columnar format. Arrow uses contiguous memory blocks per column, which enables SIMD-accelerated CPU operations, cache-friendly access patterns for analytical queries, and zero-copy data sharing with other Arrow-native tools such as DuckDB, PyArrow, and Apache Spark. There is no serialization overhead when moving data between these systems.

Lazy evaluation and query planning

The LazyFrame API does not execute any work immediately. It builds a logical query plan and then applies optimisations before a single byte of data is read or processed:

  • Predicate pushdown — filter conditions are pushed as close to the data source as possible, skipping rows before they are loaded.
  • Projection pushdown — only columns referenced by the query are read from disk; unused columns are never loaded.
  • Common subexpression elimination — identical sub-expressions computed multiple times are evaluated once and reused.

This is the same query-planning strategy used by PostgreSQL and Apache Spark — applied to single-machine in-process DataFrame operations.

The combined result of these three decisions: 5–50x faster than pandas on typical analytical workloads with substantially lower peak RAM usage, particularly for large files.

2. Install Polars

The base installation has no system-level dependencies — no C compiler, no BLAS, no native libraries to configure:

pip install polars

For interoperability with the broader Python data ecosystem, install the extras bundle:

pip install "polars[numpy,pandas,pyarrow]"

This adds NumPy array conversion, pandas DataFrame interop (pl.from_pandas() / .to_pandas()), and PyArrow table support. None of these are required by Polars itself — they are optional bridges to other libraries.

Verify the installation:

import polars as pl
print(pl.__version__)  # e.g. 1.x.x

3. Eager vs Lazy Execution

Polars has two execution modes that serve different purposes:

ModeAPI entry pointExecutes whenBest for
Eagerpl.read_csv(), pl.DataFrame()Immediately, after each callInteractive exploration, small datasets
Lazypl.scan_csv(), df.lazy()Only when .collect() is calledProduction pipelines, large files, optimised queries

Eager works just like pandas — results are available immediately:

df = pl.read_csv("sales.csv")   # file is fully loaded right now
print(df.head())

Lazy defers all work and builds a query plan:

lf = pl.scan_csv("sales.csv")          # no I/O yet
result = (
    lf
    .filter(pl.col("revenue") > 1000)
    .group_by("region")
    .agg(pl.col("revenue").sum())
    .collect()                          # executes everything here
)

Use lazy mode whenever your data exceeds a few hundred MB, your pipeline chains multiple transformations, or you want Polars to automatically optimise the query plan before execution.

4. Core Operations with Code Examples

Read CSV and Parquet

import polars as pl

# Eager reads — entire file loaded into RAM immediately
df_csv     = pl.read_csv("data.csv")
df_parquet = pl.read_parquet("data.parquet")

# Lazy scans — no I/O until .collect() is called
lf_csv     = pl.scan_csv("data.csv")
lf_parquet = pl.scan_parquet("data.parquet")

Parquet is the preferred format for Polars. Its columnar on-disk layout mirrors Arrow's columnar memory model, so column projection pushdown is extremely effective — reading a 10-column query from a 100-column Parquet file loads only 10% of the data.

Select Columns and Filter Rows

Polars uses an expressions API — every column transformation is a composable value, not a mutating operation:

# Derive new columns with select
result = df.select([
    pl.col("name"),
    pl.col("price"),
    (pl.col("price") * pl.col("qty")).alias("total"),
    pl.col("name").str.to_uppercase().alias("name_upper"),
])

# Filter rows with boolean expressions
filtered = df.filter(
    (pl.col("qty") > 0) & (pl.col("price") < 500)
)

There are no index-based .iloc calls or chained [] operators. Every operation is explicit, composable, and parallel-safe.

GroupBy and Aggregation

summary = (
    df
    .group_by("category")
    .agg([
        pl.col("revenue").sum().alias("total_revenue"),
        pl.col("revenue").mean().alias("avg_revenue"),
        pl.col("order_id").count().alias("order_count"),
    ])
    .sort("total_revenue", descending=True)
)

group_by in Polars is automatically parallelised — groups are distributed across CPU threads and results merged. On an 8-core machine, this is typically 6–10x faster than the equivalent pandas groupby.

Join Two DataFrames

orders    = pl.read_csv("orders.csv")
customers = pl.read_csv("customers.csv")

joined = orders.join(
    customers,
    on="customer_id",
    how="left",   # options: inner | left | right | full | semi | anti | cross
)

Polars uses a hash-join algorithm by default, which is O(n) in the number of rows. For large datasets this is substantially faster than the O(n log n) sort-merge join used by many SQL databases and pandas.

5. LazyFrame Workflow

The canonical production pattern in Polars is: scan → transform chain → collect.

result = (
    pl.scan_csv("transactions_*.csv")           # glob multiple files at once
    .filter(pl.col("status") == "complete")
    .with_columns(
        (pl.col("amount") * 1.1).alias("amount_with_tax")
    )
    .group_by("merchant_id")
    .agg(pl.col("amount_with_tax").sum())
    .sort("amount_with_tax", descending=True)
    .limit(100)
    .collect()                                  # all optimisations applied here
)

Query Plan Visualisation with .explain()

Before collecting, print the optimised query plan to understand exactly what Polars will execute:

lf = (
    pl.scan_csv("data.csv")
    .filter(pl.col("price") > 100)
    .select(["id", "price", "category"])
)

print(lf.explain())
# FILTER [(col("price")) > (100)]
#   CSV SCAN data.csv
#   PROJECT 3/12 COLUMNS   <-- only 3 of 12 columns are read from disk

Notice PROJECT 3/12 COLUMNS — Polars has automatically pushed the column selection down to the CSV reader. Even though the CSV file has 12 columns, only 3 are ever read from disk.

Process Files Larger Than RAM

For datasets that exceed available memory, pass streaming=True to .collect():

# A 50 GB Parquet dataset on a 16 GB machine
result = (
    pl.scan_parquet("/data/large_dataset/*.parquet")
    .filter(pl.col("year") == 2025)
    .group_by("country")
    .agg(pl.col("sales").sum())
    .collect(streaming=True)    # batched, memory-bounded execution
)

Streaming mode processes data in chunks, so peak RAM usage is proportional to the batch size rather than the full dataset size. Most query operations — filtering, aggregation, joins — are supported in streaming mode.

6. Polars Expressions: The Columnar API

Expressions are first-class objects in Polars. You can store them in variables, pass them to functions, and build complex logic from small composable pieces.

String Expressions

result = df.with_columns([
    pl.col("name").str.to_uppercase().alias("name_upper"),
    pl.col("email").str.split("@").list.last().alias("domain"),
    pl.col("description").str.strip_chars().alias("description_clean"),
    pl.col("code").str.extract(r"^([A-Z]{3})", group_index=1).alias("prefix"),
])

Filtered Aggregations

Expressions can embed their own filter conditions — no need to pre-filter the DataFrame:

positive_revenue = (
    pl.col("price")
    .filter(pl.col("qty") > 0)
    .sum()
    .alias("positive_revenue")
)

result = df.select([positive_revenue])

Conditional Logic

grade = (
    pl.when(pl.col("score") >= 90).then(pl.lit("A"))
      .when(pl.col("score") >= 75).then(pl.lit("B"))
      .when(pl.col("score") >= 60).then(pl.lit("C"))
      .otherwise(pl.lit("F"))
      .alias("grade")
)

df = df.with_columns([grade])

Composing Expressions

Because expressions are values, you can build reusable helpers:

def revenue_with_tax(rate: float):
    return (pl.col("price") * pl.col("qty") * (1 + rate)).alias("revenue_with_tax")

result = df.select([
    pl.col("product_id"),
    revenue_with_tax(0.10),   # 10% tax
])

This pattern is the Polars equivalent of writing reusable pandas transform functions, but it is fully parallelised and composable with lazy query planning.

7. Migrate from pandas

Common pandas Patterns and Their Polars Equivalents

pandasPolars
df["col"]df["col"] or df.select(pl.col("col"))
df[df["x"] > 5]df.filter(pl.col("x") > 5)
df.assign(c=df["a"] + df["b"])df.with_columns((pl.col("a") + pl.col("b")).alias("c"))
df.groupby("k")["v"].sum()df.group_by("k").agg(pl.col("v").sum())
df.merge(other, on="id")df.join(other, on="id", how="inner")
df.sort_values("col")df.sort("col")
df.rename(columns={"a": "b"})df.rename({"a": "b"})
df.dropna()df.drop_nulls()
df.fillna(0)df.fill_null(0)
df["s"].str.contains("x")pl.col("s").str.contains("x")
df.apply(fn)df.map_elements(fn) (avoid; use expressions instead)

The most important mindset shift: Polars DataFrames are immutable. Every operation returns a new DataFrame; there is no in-place mutation:

# pandas (mutates df)
df["new_col"] = df["a"] * 2

# Polars (returns new DataFrame)
df = df.with_columns((pl.col("a") * 2).alias("new_col"))

Convert Between pandas and Polars

import pandas as pd
import polars as pl

# pandas → Polars
pandas_df = pd.read_csv("data.csv")
polars_df = pl.from_pandas(pandas_df)

# Polars → pandas
back_to_pandas = polars_df.to_pandas()

Use .to_pandas() at the boundary when a downstream library (matplotlib, scikit-learn, legacy code) requires a pandas DataFrame. For new code, stay in Polars as long as possible — the conversion is cheap but not free.

Avoid map_elements — it is an escape hatch for calling arbitrary Python per row, which bypasses Rust and loses all parallelism. Almost every apply use case in pandas has a native vectorised Polars expression equivalent:

# Slow: per-row Python call
df.with_columns(pl.col("text").map_elements(lambda s: s.upper()))

# Fast: vectorised Rust operation
df.with_columns(pl.col("text").str.to_uppercase())

8. Performance Benchmark: pandas vs Polars (1M Rows)

Benchmark environment: 8-core CPU, 32 GB RAM, NVMe SSD, Python 3.12, pandas 2.2, Polars 1.x. All timings are wall-clock seconds, lower is better. Dataset: 1 million rows, mixed integer/string/float columns.

OperationpandasPolars EagerPolars LazySpeedup (Lazy)
Read CSV (1M rows, 10 cols)1.8 s0.38 s~5x
Read Parquet (1M rows)0.9 s0.12 s~7.5x
GroupBy + sum (5 groups)0.42 s0.03 s0.025 s~17x
Inner join (1M × 500K)3.2 s0.19 s0.15 s~21x
Filter + select + sort0.55 s0.04 s0.028 s~20x
String contains filter1.1 s0.09 s0.07 s~16x

The lazy API consistently outperforms eager mode because the query optimiser eliminates work before execution. On IO-heavy pipelines with wide tables (many columns), the gap grows further because projection pushdown skips columns at the file reader level.

Results vary by hardware, data shape, and column cardinality, but order-of-magnitude differences in aggregation and join performance are reproducible across real-world workloads.

9. Polars vs DuckDB: When to Use Each

Both Polars and DuckDB are fast, Arrow-native, Rust-based, and capable of processing datasets larger than RAM. The choice comes down to interface preference and integration requirements:

DimensionPolarsDuckDB
Primary interfacePython expressions APISQL
Best forPython pipelines, ML feature engineering, DataFrame transformationsAd-hoc analytics, SQL-first workflows, BI tooling
Out-of-core executioncollect(streaming=True)Native, always
External database connectionsNoYes (PostgreSQL, MySQL, S3, etc.)
Ecosystem fitpandas replacement in Python codeSQL analytics engine embedded in Python
Interop.to_arrow() / pl.from_arrow()duckdb.sql(...).pl() returns a Polars DataFrame

They are genuinely complementary. DuckDB can query a Polars DataFrame directly in memory via the Arrow interface, so many teams use both in the same pipeline: Polars for DataFrame manipulation and ML feature engineering, DuckDB for SQL-style analytics on the same data with zero serialisation overhead.

import duckdb, polars as pl

df = pl.read_parquet("sales.parquet")

# DuckDB queries the Polars DataFrame in-memory via Arrow
summary = duckdb.sql("""
    SELECT region, SUM(revenue) AS total
    FROM df
    WHERE revenue > 100
    GROUP BY region
    ORDER BY total DESC
""").pl()   # returns a Polars DataFrame

10. FAQ

Q: Is Polars production-ready in 2026? Yes. Polars reached a stable 1.0 API in 2024 and is used in production at companies processing petabyte-scale datasets. The API surface has been stable since 1.0, with breaking changes announced well in advance.

Q: Does Polars support GPU acceleration? An experimental GPU engine (NVIDIA cuDF integration) is available in the lazy API. Pass engine="gpu" to .collect() on a machine with a compatible CUDA GPU. The feature is opt-in and not yet stable for all operations.

Q: Can I use Polars in Jupyter notebooks? Yes. Polars DataFrames render as styled HTML tables in Jupyter and VS Code notebooks with no extra configuration.

Q: How does Polars handle missing data? Polars uses Arrow null (a bitmask, not a sentinel value) for missing data across all types. NaN is a distinct IEEE 754 floating-point value and is not treated as missing. Use is_null() / fill_null() for missing data and is_nan() / fill_nan() for IEEE NaN. This eliminates an entire class of subtle bugs that are common in pandas, where NaN and None are often conflated.

Q: What Python version does Polars require? Python 3.9 or later. Python 3.12+ is recommended for best performance.

Q: How do I read a file that is larger than my available RAM? Use scan_csv() or scan_parquet() and call .collect(streaming=True), or write directly to disk with .sink_parquet() / .sink_csv() without collecting into memory at all.

Q: Does Polars work with AWS S3, GCS, and Azure Blob? Yes, via pip install "polars[fsspec]". Once cloud credentials are configured in your environment, pl.scan_parquet("s3://bucket/prefix/*.parquet") works out of the box.

Q: What is the polars vs pandas 2026 bottom line? For new Python data engineering projects, Polars is the better default choice: faster, more memory-efficient, and architecturally cleaner. Pandas remains valuable at the boundary with libraries that require it, and for exploratory work where its extensive ecosystem of integrations matters more than raw performance.

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro