DEV Community

Cover image for Polars 2.0: five things that change when you upgrade, three of them silently
Ground Truth Books
Ground Truth Books

Posted on AI-assisted

Polars 2.0: five things that change when you upgrade, three of them silently

Polars 2.0.0 shipped on 6 October. Most of its breaking changes are loud: a removed argument raises and tells you what to use instead. The ones worth worrying about are quiet. The code still runs, the tests still pass, and the result is different.

I ran the same five snippets under 1.44.2 and 2.0.0. Each one is self-contained, so you can paste it and check your own environment.

1. Lazy joins no longer keep row order (silent)

Lazy queries now run on the streaming engine by default, and the streaming engine does not promise row order for operations that don't need it. A left join is one of them.

import polars as pl
n = 2_000_000
orders = pl.LazyFrame({"order_id": range(n)}).with_columns(
    customer_id=(pl.col("order_id") * 7919 % 100_000)
)
customers = pl.LazyFrame({"customer_id": range(100_000), "region": ["eu", "us"] * 50_000})
for run in range(3):
    ids = orders.join(customers, on="customer_id", how="left").collect()["order_id"]
    print(f"run {run}: first5={ids.head(5).to_list()} in_order={ids.is_sorted()}")
Enter fullscreen mode Exit fullscreen mode

1.44.2:

run 0: first5=[0, 1, 2, 3, 4] in_order=True
run 1: first5=[0, 1, 2, 3, 4] in_order=True
run 2: first5=[0, 1, 2, 3, 4] in_order=True
Enter fullscreen mode Exit fullscreen mode

2.0.0:

run 0: first5=[500015, 500044, 500052, 500101, 500115] in_order=False
run 1: first5=[625013, 625018, 625027, 625074, 625088] in_order=False
run 2: first5=[375038, 375048, 375054, 375086, 375090] in_order=False
Enter fullscreen mode Exit fullscreen mode

Every value is still correct; only the row positions move, and they move differently on every run. Anything downstream that relies on position (row numbers, zipping with another list, a positional file the next job reads) is now wrong without an error. Small test fixtures often keep their order, which is why a green test suite proves little here.

Fix: say what you need. join(..., maintain_order="left") came back in order on all three runs under 2.0.0. Don't rely on collect(engine="in-memory") alone: it kept order in this small repro, but a scan-based join in the book came back unordered under it.

2. Int64 + UInt64 is now Int128, and the value changes (silent)

import polars as pl
df = pl.DataFrame({"a": [9007199254740993], "b": [18446744073709551614]},
                  schema={"a": pl.Int64, "b": pl.UInt64})
out = df.select(total=pl.col("a") + pl.col("b"))
print(out.dtypes[0], int(out.item()))
print("exact", 9007199254740993 + 18446744073709551614)
Enter fullscreen mode Exit fullscreen mode

1.44.2:

Float64 18455751272964292608
exact 18455751272964292607
Enter fullscreen mode Exit fullscreen mode

2.0.0:

Int128 18455751272964292607
exact 18455751272964292607
Enter fullscreen mode Exit fullscreen mode

2.0 is now exact where 1.x quietly rounded through a float. That's an improvement, but the dtype changes under you, and anything downstream without 128-bit integers (a database column, Parquet readers in other tools) will notice. Add .cast(pl.Float64) if you want the old type, and snapshot your output schemas in tests so the next dtype change shows up as a one-line diff.

3. Exploding an empty list no longer emits a null row (silent)

import polars as pl
df = pl.DataFrame({"tags": [["a", "b"], [], ["c"]]})
print(df.explode("tags").height, df.explode("tags")["tags"].to_list())
Enter fullscreen mode Exit fullscreen mode

1.44.2 (with a DeprecationWarning):

4 ['a', 'b', None, 'c']
Enter fullscreen mode Exit fullscreen mode

2.0.0:

3 ['a', 'b', 'c']
Enter fullscreen mode Exit fullscreen mode

Row counts change wherever a list column has empty lists. If you counted those rows, your totals just changed. explode("tags", empty_as_null=True) restores the old rows on 2.0.0.

4. Horizontal concat now fails on uneven heights (loud, but late)

import polars as pl
daily = pl.DataFrame({"day": [1, 2, 3], "orders": [120, 98, 143]})
flags = pl.DataFrame({"fraud_flags": [2, 0]})  # upstream job failed on day 3
print(pl.concat([daily, flags], how="horizontal"))
Enter fullscreen mode Exit fullscreen mode

1.44.2 padded day 3 with null and printed a DeprecationWarning. 2.0.0:

polars.exceptions.ShapeError: cannot concat dataframes with different heights in 'strict' mode
Enter fullscreen mode Exit fullscreen mode

This one raises, but only on the day the heights differ, so it can pass your upgrade and fail in production a week later. how="horizontal_extend" keeps the padding. Before you reach for it, ask why the heights differ: in this example the padding was hiding a failed upstream job.

5. is_in no longer compares ints with floats, and the obvious fix flips the answer (loud)

import polars as pl
print(pl.Series([1]).is_in(pl.Series([1.99]).implode()).to_list())
Enter fullscreen mode Exit fullscreen mode

1.44.2 prints [False]. 2.0.0:

polars.exceptions.InvalidOperationError: 'is_in' cannot check for Int64 values in List(Float64) data.
Enter fullscreen mode Exit fullscreen mode

The error says to cast one side. Which side matters:

print("haystack -> Int64 :", pl.Series([1]).is_in(pl.Series([1.99]).cast(pl.Int64).implode()).to_list())
print("needle -> Float64:", pl.Series([1]).cast(pl.Float64).is_in(pl.Series([1.99]).implode()).to_list())
Enter fullscreen mode Exit fullscreen mode
haystack -> Int64 : [True]
needle -> Float64: [False]
Enter fullscreen mode Exit fullscreen mode

Casting the float list to Int64 truncates 1.99 to 1, so 1 now matches a value that was never in the list. Cast toward the type that holds both sides exactly, usually the float.

Find them before you upgrade

1.44.2 already warns at the explode and concat call sites, so run your test suite on 1.44.2 with python -W default first and treat the warnings as a to-do list. The two silent changes that don't warn (join order and dtypes) need tests: assert order where you depend on it, and snapshot schemas.


I wrote Migrating to Polars 2, which goes through the upgrade guide area by area the same way: each change run under 1.44.2 and 2.0.0 with the real output, plus a dual-version test harness and CI diff you can drop into your own project. The introduction and the row-order chapter are free to read: https://leanpub.com/migrating-to-polars-2

Disclosure: the book was researched and drafted with AI assistance and checked against real runs under both versions; this post's outputs are from my own runs today. Not affiliated with Polars Inc.

Top comments (0)