Skip to content

BuildNot yet confirmed elsewhere1 publisher2 min readPublished

Polars 2.0's streaming default changes left-join row order from run to run

Polars 2.0.0 runs lazy queries on the streaming engine by default, and a left join on that engine no longer keeps row order. A dev.to author's repro shows the change raising no error and leaving every value correct.

The Engineer · Build desk

How we use AISend a correction

A new first order_id on each 2.0.0 run First order_id printed by the left-join repro under Polars 1.44.2 and 2.0.0; the repro passes no maintain_order argument.

The repro left-joins 2,000,000 order rows to 100,000 customer rows. Under Polars 1.44.2 all three runs began at order_id 0. Under 2.0.0 run 0 began at order_id 500015, and runs 1 and 2 at 625013 and 375038.

A new first order_id on each 2.0.0 run
FigureValueNoteClaim
Order rows left-joined in the repro2,000,000 rows4
First order_id on 1.44.20 all three runs5
First order_id on 2.0.0, run 0500,015 order_idruns 1 and 2: 625013 and 3750386

What happened

  • Polars 2.0.0 shipped on 6 October, and most of its breaking changes are loud: a removed argument raises and names its replacement.
  • A dev.to author ran five self-contained snippets under 1.44.2 and 2.0.0 and counts three silent changes among the five.
  • On 1.44.2 the 2-million-row join was in order on all three runs; on 2.0.0 it was out of order on all three, and the first order_id differed each run (500015, 625013, 375038).
  • Adding an Int64 to a UInt64 now returns an exact Int128, where 1.44.2 rounded through Float64.
  • Exploding a list column no longer emits a null row for an empty list, so row counts drop unless you pass empty_as_null=True.

Why it matters

  • exposure Row numbers, zipped lists and positional files read by the next job are the exposed consumers: values stay correct, positions move, and nothing raises.
  • constraint A green suite on small fixtures does not clear the upgrade. The check has to run the join at scale and more than once, because positions moved differently on every run.
  • cost Downstream systems without 128-bit integers, such as a database column or another tool's Parquet reader, absorb the Int128 change. Casting to Float64 restores the old type, and that type was off by 1 at this size.
  • decision Choosing horizontal_extend to keep the old padding means deciding whether to hide a failed upstream job. In the post's example the padding did.

The cause is a stated ordering rule. According to the post, the streaming engine does not promise row order for operations that do not need it, and a left join is one of them [3]. The repro joins 2,000,000 orders to 100,000 customers on customer_id and passes no maintain_order argument [4]. Under 1.44.2 that code returned sorted output anyway on every run [5].

The fix is one keyword. Passing join(..., maintain_order="left") returned rows in order on all three runs under 2.0.0 [17]. The shortcut is collect(engine="in-memory") on its own. The author says it kept order in the small repro, but a scan-based join in the book came back unordered under it [18].

Treat the repro as a claim about one workload: synthetic integer keys, three runs, 2 million rows. The post does not describe the hardware, give a size at which reordering starts, or cite Polars release notes. It says only that small test fixtures often keep their order [16]. In our view the change reaches a pipeline only when something downstream reads row position and nothing sorts after the join. A job that re-sorts on a key, or looks rows up by key, would not see it.

The other changes differ in how long they stay hidden. The integer add now returns Int128 and is exact. On 1.44.2 it returned Float64 18455751272964292608 against an exact 18455751272964292607, an error of 1 [8][14].

For explode, the post's three-row input gave 4 rows on 1.44.2 and 3 on 2.0.0, a difference of 1. Calling explode("tags", empty_as_null=True) restores the old rows [10][15].

Horizontal concat raises on unequal heights, but only on a day the heights differ, so it can pass an upgrade and fail in production a week later [11]. The is_in check of an Int64 value against a List(Float64) raises too, and the error tells you to cast one side [12]. The side matters. Casting the float list to Int64 truncates 1.99 to 1 and returns True, while casting the needle to Float64 returns False [12].

What to watch

  • Whether Polars documentation or release notes list which other lazy operations lose order under the streaming default; the post names only left join as one of them.
  • Whether anyone reproduces the scan-based join that came back unordered under collect(engine="in-memory"); the book case is not published in the post.
  • Whether other tools' Parquet readers and database drivers accept Int128 output, or whether teams end up casting back to Float64.

Clarity's read

What the record supports and how the coverage leans. The claims behind it follow.

Reality

Evidence62
Adoption
Insufficient
Hype gap+8
Incentives40
Confidence60
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [1]

    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.

    ReportedSupportedSource: dev.to post 'Polars 2.0: five things that change when you upgrade, three of them silently'View cited source
  2. [2]

    Lazy queries in Polars 2.0.0 now run on the streaming engine by default.

    ReportedSupportedSource: dev.to post on Polars 2.0 upgrade changesView cited source
  3. [3]

    The streaming engine does not promise row order for operations that don't need it, and a left join is one of them.

    ReportedSupportedSource: dev.to post on Polars 2.0 upgrade changesView cited source

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · October 12, 2026

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

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Entities

Loading related stories