News3 hours ago

Polars 2.0 Made Join Output Order Random and That Is the Change Nobody Will Notice

We installed Polars 2.0rc1 next to 1.36.1 and ran the same query on both. It got 1.7x faster and used 60% less peak memory, and joins stopped returning rows in a stable order.

The WJS Desk

Sep 4, 2026 · 6 min read

Photo by Gu Bra on Pexels

Polars tagged py-2.0.0-rc.1 on September 2. The announcement is unusually low-key for a major version: "We don't aim to make a big feature release of Polars 2.0." The point is to delete old design decisions and change defaults, not to add features.

So we installed it alongside 1.36.1, took eight things off the migration notes, and ran them against both. Six broke. One broke silently, and that one is the story.

What actually shipped

Two changes matter more than the rest of the list combined.

The first is that the streaming engine is now the default for LazyFrame work rather than the older all-data-in-memory, column-at-a-time engine. That engine still exists as a fallback for operations the streaming engine cannot handle yet. The second is a pile of removals: methods and arguments that have been deprecated across the 1.x line are now gone rather than warning.

The release notes for the RC itself are short, and the headline entry under breaking changes is "Set the default engine for SQL to the streaming engine". The bulk of the 2.0 migration surface is documented in the announcement post rather than the tag.

Install is one line, and the stable release is described as landing "in the following weeks":

pip install polars==2.0rc1

We ran the migration on both versions

We built two virtual environments on Python 3.12, one with 1.36.1 and one with 2.0rc1, and ran an identical script against each. Six of the eight patterns we tried stopped working:

What we ran1.36.12.0rc1
melt(id_vars=...)Worked, with a deprecation warningAttributeRemovedError, names the replacement
join(join_nulls=True)Worked, with a deprecation warningArgumentRemovedError, names nulls_equal
is_in, Int64 against List(Float64)Returned [True, False, False]InvalidOperationError
Horizontal concat, heights 3 and 1Silently padded with nullsShapeError
cast(pl.Date) from a stringReturned a dateInvalidOperationError, no cast path
cast(pl.Int32) from an EnumReturned [0, 1]ComputeError

We have marked the first four as wins for 2.0 because they are the same category of change: something that used to guess is now an error. The horizontal concat case is the clearest. Concatenating a 3-row frame with a 1-row frame used to hand back a column padded with two nulls. Whatever pipeline was doing that was almost certainly not intending to.

The last two rows are losses, because the replacement is not mechanical. A removed method renames cleanly. A removed cast path means finding every string column you were casting to a date and rewriting it as .str.to_date(), which is a different function with different parsing behaviour, not an alias.

Credit where it is due: the new AttributeRemovedError and ArgumentRemovedError messages name the version the thing was deprecated in, the version it was removed in, and what to use instead. That is the difference between an afternoon and a week.

The one that does not throw

The announcement says plainly that "Row order is no longer guaranteed for joins, group_by, unpivot, ...". We wanted to know what that means in practice, so we ran the same lazy query ten times in a row on each version and counted how many distinct row orderings came back.

Operation1.36.12.0rc1
group_by on 200,000 rows10 distinct orders in 10 runs10 distinct orders in 10 runs
join on 100,000 rows1 order in 10 runs10 distinct orders in 10 runs
unpivot on 20,000 rows1 order in 10 runs1 order in 10 runs

So group_by was already unstable in 1.36.1 and nothing changed there. The new behaviour is the join: it returned rows in the same order on all ten runs under 1.36.1, and a different order on every run under 2.0rc1. No error, no warning.

We could not make unpivot vary at the size we tested, even though the notes name it. That is not evidence it is stable, only that we did not hit it, and depending on it would be a bad idea.

A removal breaks your build. A row order change breaks your output.

If you write joined results to a file and diff them against yesterday's, or feed them into anything that expects a stable order, that is the migration item to hunt for. Nothing in the toolchain will point at it.

The 5x is real, and it is not about speed

The announcement says "In aggregate we expect the streaming engine to be easily 5x faster". We could not reproduce that, and the reason is more interesting than the number.

Our query joins a 20 million row, 224 MB Parquet file of events against a 500,000 row dimension table, groups by two columns, aggregates three ways, and sorts. Five runs each, on an Apple M4 Pro with 51 GB of RAM:

Measure1.36.12.0rc1
Median wall time, 5 runs0.220s0.127s
Fastest run0.190s0.123s
Peak resident memory1.56 GB0.62 GB

That is 1.7x on wall time, not 5x. But peak memory dropped by 60%, and that is where the streaming engine's claim actually lives. Our 224 MB of data fits in 51 GB of RAM many times over, so the old engine was never under the pressure the new one is designed to relieve. On a machine where the data does not fit, the comparison is not 1.7x against 0.22s, it is finishing against swapping. We do not have a box small enough to demonstrate that honestly, so we are reporting the memory number and leaving the 5x attributed to the maintainers.

What the thread argued about

The Hacker News thread on the announcement reached 247 points and 72 comments. The sharpest objection was not about the removals at all.

"Is there a reason besides performance that maintain_order=False by default? I ask because polars is used in many scientific data analysis pipelines, and non-deterministic behaviour is a well-documented source of bugs in scientific computing."

That is trombonechamp, and our join measurement is exactly the thing being described. The reply from hopfenspergerj is the defence: "It's standard sql behavior, users always specify the ordering they want as part of the query." Both are right. SQL has never promised order without ORDER BY, and a lot of working Polars code was written against an engine that happened to give it anyway.

arn3n asked whether a streaming engine would be slower because streaming implies sequential processing, and Polars maintainer orlp answered that the name is historical: "Streaming here has a different meaning than perhaps what you're used to", contrasting it with the old all-in-memory, column-at-a-time model rather than with online processing.

The dissent worth keeping came from dist-epoch, who noted Polars is one of very few Python libraries where the minor release notes have to be read every time, because things get deprecated or changed often. And luciana1u summarised the mood: "every major version of polars is a reminder that the API you finally memorized was always just a suggestion."

Our read

This is a good major version. Four of the six things that broke on us were operations that previously guessed at what we meant, and a library that turns guesses into errors on a version boundary is doing the right thing at the right time.

The part we would push back on is the framing. Calling 2.0 a boring release is accurate for the removals, because the removals announce themselves. It undersells the default engine swap, which changes observable output for code that still compiles, still runs, still passes its tests, and now emits rows in a different order every time. That is not a boring change. It is just a quiet one.

If you run Polars in anything that produces a file someone else consumes, install the RC in a scratch environment this week and diff the output rather than the exit code. The removals will find you on their own.

Share

Polars 2.0rc1 on our 20M row query: 1.7x faster, not the 5x claimed, but 60% less peak memory. And joins stopped returning rows in a stable order. #Polars #Python #DataEngineering

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading