Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
Filter by Categories
About Article
AI Tools
Analyze Data
Archive
Best Practices
Better Outputs
Blog
Code Optimization
Code Quality
Command Line
Course
Daily tips
Dashboard
Data Analysis & Manipulation
Data Engineer
Data Visualization
DataFrame
Delta Lake
DevOps
DuckDB
Environment Management
Feature Engineer
Git
Jupyter Notebook
LLM
LLM Tools
Machine Learning
Machine Learning & AI
Machine Learning Tools
Manage Data
MLOps
Natural Language Processing
Newsletter Archive
NumPy
Pandas
Polars
PySpark
Python Helpers
Python Tips
Python Utilities
Scrape Data
SQL
Testing
Time Series
Tools
Visualization
Visualization & Reporting
Workflow & Automation
Workflow Automation

What’s New in Polars 2.1: Faster Group-Bys, More SQL, Encrypted Parquet

What’s New in Polars 2.1: Faster Group-Bys, More SQL, Encrypted Parquet

Table of Contents

Introduction

Polars 2.0 changed how your existing code behaves. The streaming engine became the default, joins stopped keeping row order, and types got stricter. But it had few new features.

Polars 2.1 came out on October 25, 2026, three weeks later. This time, the release is all about new features.

This article continues the Polars 2.0 post and covers the three new features I find most useful, plus a few smaller ones.

  • Run group-bys faster and with less memory
  • Use common SQL functions that Polars SQL didn’t support before
  • Encrypt sensitive columns in a Parquet file
  • Smaller additions like full joins in join_where, approx_quantile, and masked NumPy arrays

💻 Get the Code: The scripts are on GitHub.

Stay Current with CodeCut

Easy-to-digest articles on Python, AI, and open-source tools. Delivered twice a week.

Run Group-Bys Faster

Polars 1.x holds all groups in RAM at once, so a big group-by needs a lot of memory.

Polars 2.1 splits the groups into partitions instead. It keeps one partition in RAM and stores the others on disk until Polars is ready to process them.

 Polars 1.x                          Polars 2.1, low memory

 RAM ┌────────────────────────┐      RAM  ┌──────────┐
     │ all groups             │           │ part 1   │ <- one partition at a time
     │ ██████████████████████ │           └──────────┘
     └────────────────────────┘      disk ┌──────────┬──────────┬──────────┐
                                          │ part 2   │ part 3   │ part 4   │
                                          └──────────┴──────────┴──────────┘

To see the difference on real data, group 10 months of taxi trips by route and hour. With 33 million trips and 12 million groups, this query needs a lot of memory.

months = [f"data/yellow_tripdata_2024-{m:02d}.parquet" for m in range(1, 11)]
trips = pl.scan_parquet(months)

routes_by_hour = (
    trips.group_by(
        "PULocationID",
        "DOLocationID",
        pl.col("tpep_pickup_datetime").dt.truncate("1h").alias("pickup_hour"),
    )
    .agg(
        pl.len().alias("trips"),
        pl.col("fare_amount").mean().alias("avg_fare"),
    )
    .collect()
)

Here is the same code on Polars 1.44.2 and 2.1.0, as the median of 3 runs.

 peak memory                                                        time
 1.44.2         ████████████████████████████████████████ 4,453 MB   0.75 s
 2.1.0          █████████████████████                    2,389 MB   0.34 s

The same code runs twice as fast in 2.1 and uses about half the memory.

Run Warehouse SQL Directly

Many SQL features common in warehouses like Snowflake or BigQuery were not supported in Polars 1.x. You had to rewrite those queries as Polars expressions, or run them in another engine.

Polars 2.1 adds many of these features. Here are three of them on the taxi data.

Read Several Files at Once

In Polars 1.x, read_parquet in SQL took one string, either a path or a glob. In Polars 2.1, it also takes a list of paths, so you can pick exactly which files to read.

three_months = pl.sql(
    """
    SELECT COUNT(*) AS trips
    FROM read_parquet([
        'data/yellow_tripdata_2024-01.parquet',
        'data/yellow_tripdata_2024-02.parquet',
        'data/yellow_tripdata_2024-03.parquet'
    ])
    """,
    eager=True,
)
three_months
Output
┌─────────┐
│ trips   │
│ ---     │
│ i64     │
╞═════════╡
│ 9554778 │
└─────────┘

Group Rows by Hour with DATE_TRUNC

DATE_TRUNC rounds a timestamp down to a unit such as the hour or the day, which is how you count events per hour in SQL. Polars 2.1 now supports it.

The query below rounds each pickup time down to the hour, for pickups in zone 12 on January 2.

trips = pl.scan_parquet("data/taxi.parquet")

pl.sql(
    """
    SELECT
        tpep_pickup_datetime,
        DATE_TRUNC('hour', tpep_pickup_datetime) AS pickup_hour
    FROM trips
    WHERE PULocationID = 12
      AND CAST(tpep_pickup_datetime AS DATE) = '2024-01-02'
    ORDER BY tpep_pickup_datetime
    LIMIT 4
    """,
    eager=True,
)
Output
┌──────────────────────┬─────────────────────┐
│ tpep_pickup_datetime ┆ pickup_hour         │
│ ---                  ┆ ---                 │
│ datetime[μs]         ┆ datetime[μs]        │
╞══════════════════════╪═════════════════════╡
│ 2024-01-02 09:31:54  ┆ 2024-01-02 09:00:00 │
│ 2024-01-02 10:04:05  ┆ 2024-01-02 10:00:00 │
│ 2024-01-02 10:06:16  ┆ 2024-01-02 10:00:00 │
│ 2024-01-02 10:28:21  ┆ 2024-01-02 10:00:00 │
└──────────────────────┴─────────────────────┘

Fill Missing Hours with generate_series

generate_series creates one row per step between a start and an end, such as every hour of a day. Polars 2.1 now supports it.

The query below creates one row for each hour from 6 AM to 9 AM on January 2.

pl.sql(
    """
    SELECT *
    FROM generate_series(
        TIMESTAMP '2024-01-02 06:00:00',
        TIMESTAMP '2024-01-02 09:00:00',
        INTERVAL '1 hour'
    ) AS t(pickup_hour)
    """,
    eager=True,
)
Output
┌─────────────────────┐
│ pickup_hour         │
│ ---                 │
│ datetime[μs]        │
╞═════════════════════╡
│ 2024-01-02 06:00:00 │
│ 2024-01-02 07:00:00 │
│ 2024-01-02 08:00:00 │
│ 2024-01-02 09:00:00 │
└─────────────────────┘

2.1 also adds ASOF JOIN, WITH RECURSIVE, CREATE TEMP TABLE, REGEXP_REPLACE, and correlated subqueries in JOIN ... ON. See the release notes for the full list.

Encrypt Sensitive Columns

Sometimes only certain people should see some columns. For example, in the taxi data, only the finance team should see fares and tips, while everyone can see the rest.

Polars 2.1 allows you to encrypt chosen columns in a Parquet file with a specific key.

 trips_encrypted.parquet
 ┌───────────────────────────────────────────────────────┐
 │ pickup time │ zone │ distance │ fare 🔒 │ tip 🔒      │
 └───────────────────────────────────────────────────────┘
   readable by anyone              needs payment_key

To encrypt sensitive columns, use ParquetEncryptionProperties. It takes two kinds of keys:

  • The footer key protects the file from being tampered with.
  • The column keys lock the sensitive columns, so only people with the key can read them.
trips = pl.read_parquet("data/taxi.parquet")

# Define keys
footer_key = b"footer-key-16byt"  # keys must be 16 or 32 bytes
payment_key = b"payment-key-16by"

# Define sensitive columns
payment_columns = {"fare_amount": payment_key, "tip_amount": payment_key}

# Encrypt the file
encryption = pl.ParquetEncryptionProperties(
    footer_key=footer_key,
    column_keys=payment_columns,
    plaintext_footer=True,  # let readers without keys read the unencrypted columns
)
trips.write_parquet("data/trips_encrypted.parquet", encryption=encryption)

Without the keys, you can only read the unencrypted columns.

pl.read_parquet(
    "data/trips_encrypted.parquet", columns=["PULocationID", "trip_distance"]
).head(3)
Output
┌──────────────┬───────────────┐
│ PULocationID ┆ trip_distance │
│ ---          ┆ ---           │
│ i32          ┆ f64           │
╞══════════════╪═══════════════╡
│ 186          ┆ 1.72          │
│ 140          ┆ 1.8           │
│ 236          ┆ 4.7           │
└──────────────┴───────────────┘

Passing the keys with ParquetDecryptionProperties allows you to read the sensitive columns.

decryption = pl.ParquetDecryptionProperties(
    footer_key=footer_key, column_keys=payment_columns
)
pl.read_parquet(
    "data/trips_encrypted.parquet",
    columns=["fare_amount", "tip_amount"],
    decryption=decryption,
).head(3)
Output
┌─────────────┬────────────┐
│ fare_amount ┆ tip_amount │
│ ---         ┆ ---        │
│ f64         ┆ f64        │
╞═════════════╪════════════╡
│ 17.7        ┆ 0.0        │
│ 10.0        ┆ 3.75       │
│ 23.3        ┆ 3.0        │
└─────────────┴────────────┘

Other Additions in 2.1

Here are a few smaller additions worth knowing about.

Full Joins in join_where

join_where joins two tables on conditions like “a value falls within a range”. In Polars 2.1, you can pass how="full" to keep the rows that don’t match on either side.

trips = pl.DataFrame({"pickup_hour": [8, 13]})
rush = pl.DataFrame({"period": ["morning", "evening"], "start": [7, 17], "end": [9, 19]})

joined = trips.join_where(
    rush, pl.col("pickup_hour").is_between("start", "end"), how="full"
)
Output
┌─────────────┬─────────┬───────┬──────┐
│ pickup_hour ┆ period  ┆ start ┆ end  │
│ ---         ┆ ---     ┆ ---   ┆ ---  │
│ i64         ┆ str     ┆ i64   ┆ i64  │
╞═════════════╪═════════╪═══════╪══════╡
│ 8           ┆ morning ┆ 7     ┆ 9    │  <- in trips and rush
│ 13          ┆ null    ┆ null  ┆ null │  <- only in trips
│ null        ┆ evening ┆ 17    ┆ 19   │  <- only in rush
└─────────────┴─────────┴───────┴──────┘

Estimate Quantiles with approx_quantile

An exact quantile, such as the median, needs every value in the column in memory to find the one in the middle. On large data, that costs time and memory.

approx_quantile estimates the quantile from a sample of the values instead. It runs faster and uses less memory, at the cost of a small error, which is usually good enough.

 fares: 14  7  30  12  9  15  8  13  11

 quantile
   1. keep all 9 fares         14  7  30  12  9  15  8  13  11
   2. sort                      7  8  9  11  12  13  14  15  30
   3. take the middle           7  8  9  11 [12] 13  14  15  30   -> 12

 approx_quantile
   1. sort                      7  8  9  11  12  13  14  15  30
   2. keep every other value    7     9      12      14      30
   3. take the middle           7     9     [12]     14      30   -> ≈ 12

Let’s compare the performance of the two approaches on 33 million fares, using the median of 3 runs.

months = [f"data/yellow_tripdata_2024-{m:02d}.parquet" for m in range(1, 11)]
fares = pl.scan_parquet(months)

fares.select(
    pl.col("fare_amount").quantile(0.5).alias("exact_median"),
    pl.col("fare_amount").approx_quantile(0.5).alias("approx_median"),
).collect()
Output
┌──────────────┬───────────────┐
│ exact_median ┆ approx_median │
│ ---          ┆ ---           │
│ f64          ┆ f64           │
╞══════════════╪═══════════════╡
│ 13.5         ┆ 13.5          │
└──────────────┴───────────────┘

The estimate matches the exact median. Better yet, it is 2x faster and uses 38% less memory.

 peak memory
 quantile         ██████████████████████████████ 681 MB
 approx_quantile  ███████████████████            422 MB

 time
 quantile         ██████████████████████████████ 0.09 s
 approx_quantile  █████████████                  0.04 s

Convert Masked NumPy Arrays

A NumPy masked array pairs the data with a list of True/False flags, where True marks a value as missing. Polars 2.1 reads the mask and turns each hidden value into a null.

import numpy as np

masked_tips = np.ma.array([3.75, 0.0, 3.0], mask=[False, True, False])
tips = pl.Series("tip_amount", masked_tips)

print(tips.to_list())
Output
[3.75, None, 3.0]

Final Thoughts

What I like about Polars 2.1 is that most of the gains need no code changes. The same group-by runs twice as fast as in Polars 1.x with about half the memory, and you can write more of your usual SQL without workarounds.

Joins can’t spill to disk yet, but the Polars team plans to add it. Until then, a very large join needs enough RAM, or an engine like DuckDB.

References

  • Polars 2.0 release post (Polars, 2026): spilling to disk starts at about 80% of RAM, with a 64 GB default disk budget.

Related Tutorials

Stay Current with CodeCut

Easy-to-digest articles on Python, AI, and open-source tools. Delivered twice a week.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top

Work with Khuyen Tran

Work with Khuyen Tran