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()}")
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
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
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)
1.44.2:
Float64 18455751272964292608
exact 18455751272964292607
2.0.0:
Int128 18455751272964292607
exact 18455751272964292607
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())
1.44.2 (with a DeprecationWarning):
4 ['a', 'b', None, 'c']
2.0.0:
3 ['a', 'b', 'c']
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"))
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
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())
1.44.2 prints [False]. 2.0.0:
polars.exceptions.InvalidOperationError: 'is_in' cannot check for Int64 values in List(Float64) data.
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())
haystack -> Int64 : [True]
needle -> Float64: [False]
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)