<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged spark</title>
<link>https://coderlegion.com/tag/spark</link>
<description>Powered by Question2Answer</description>
<item>
<title>DuckDB 2.0 vs Polars vs Spark on One Machine: 150M Rows Under a 4.5 GiB Cap</title>
<link>https://coderlegion.com/32051/duckdb-2-0-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-4-5-gib-cap</link>
<description>&lt;p&gt;Originally published on DEV.to: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/duckdb-20-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-45-gib-cap-h38&quot;&gt;https://dev.to/firfircelik/duckdb-20-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-45-gib-cap-h38&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;DuckDB 2.0.0 shipped on October 21, 2026 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;announcement&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/duckdb/2.0.0/&quot;&gt;PyPI&lt;/a&gt;). The next morning I ran the same single-machine benchmark I had already run on DuckDB 1.5.6: three engines, one 8 vCPU VM, Parquet in and Parquet out, a hard memory cap, and four queries that look like real data engineering work.&lt;/p&gt;
&lt;p&gt;The thesis I started with, and that the numbers support:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DuckDB beats Spark-on-one-box on memory-bound DE work.&lt;/strong&gt; It finished every run, at every size, 5 to 12 times faster than local PySpark at 150M rows, and it still finished the heavy queries with a 1 GB memory limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polars hits a wall on window dedup.&lt;/strong&gt; At 150M rows the &lt;code&gt;ROW_NUMBER()&lt;/code&gt; style &quot;latest row per customer&quot; query never finished under the cap. Not at 4.5 GiB, not with an out-of-core budget, not at 9 GiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polars is not slow.&lt;/strong&gt; On filters, joins and a high-cardinality group by it ties DuckDB or beats it, when it fits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything below is measured on that one box. Where I interpret or give an opinion, I say so.&lt;/p&gt;
&lt;h3 id=&quot;the-contenders&quot;&gt;The contenders&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Model &lt;/td&gt;&lt;td&gt;In-process SQL engine &lt;/td&gt;&lt;td&gt;In-process DataFrame engine, lazy + streaming &lt;/td&gt;&lt;td&gt;JVM engine, &lt;code&gt;local[8]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How I drove it &lt;/td&gt;&lt;td&gt;&lt;code&gt;COPY (SQL) TO 'out.parquet'&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;LazyFrame.sink_parquet()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;spark.sql(...).write.parquet()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Memory knob &lt;/td&gt;&lt;td&gt;&lt;code&gt;SET memory_limit = '3GB'&lt;/code&gt; &lt;/td&gt;&lt;td&gt;default (plus one run with an out-of-core budget) &lt;/td&gt;&lt;td&gt;&lt;code&gt;spark.driver.memory = 3g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Threads &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;8 (&lt;code&gt;POLARS_MAX_THREADS&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Polars 2.0.0 was published on PyPI on &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/polars/2.0.0/&quot;&gt;October 6, 2026&lt;/a&gt;, PySpark 4.2.0 on &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/pyspark/4.2.0/&quot;&gt;July 14, 2026&lt;/a&gt;. DuckDB 2.0.0 wheels landed on PyPI on October 21, the same day as the &lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/release_calendar&quot;&gt;release calendar&lt;/a&gt; entry.&lt;/p&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hardware and versions&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;VM: 8 vCPU Intel Xeon, 15 GiB RAM, no swap, Linux 6.12. It is a shared box, so other processes were running. Every run waited until at least 5 GiB was free and CPU busy was under 30% over a 3 second window.&lt;/li&gt;
&lt;li&gt;Python 3.13.5, OpenJDK 21.0.12, pyarrow 26.0.0.&lt;/li&gt;
&lt;li&gt;DuckDB 2.0.0, Polars 2.0.0, PySpark 4.2.0. The &quot;before&quot; numbers are DuckDB 1.5.6 on the same box and data, run on October 19.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Data.&lt;/strong&gt; A seeded synthetic star schema written with pyarrow: a &lt;code&gt;sales&lt;/code&gt; fact table and a 50,000-row &lt;code&gt;products&lt;/code&gt; dimension. Three sizes: 10M rows (213 MB), 50M (1.1 GB) and 150M (3.2 GB of snappy Parquet, 30 files of 5M rows). About 10 orders per customer, so 150M rows means roughly 15M customers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Queries.&lt;/strong&gt; The same SQL for DuckDB and Spark, the same logic as Polars expressions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;q1_filter_agg&lt;/code&gt;: filter on a timestamp and status, group by store and status.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q2_join_agg&lt;/code&gt;: fact-to-dimension join, group by category and brand.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q3_window_latest&lt;/code&gt;: latest order per customer with &lt;code&gt;ROW_NUMBER()&lt;/code&gt;. The classic dedup and SCD pattern.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q4_highcard_agg&lt;/code&gt;: one row per customer (about 15M groups at 150M).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- q3_window_latest
SELECT customer_id, order_id, ts, qty * price AS amount
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
                                 ORDER BY ts DESC, order_id DESC) AS rn
    FROM sales
) t
WHERE rn = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Method.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every run is a fresh process: interpreter start, import, session, read Parquet, compute, write Parquet. So no warm caches inside the engine, and Spark pays its JVM start every time.&lt;/li&gt;
&lt;li&gt;Engines run strictly one at a time. The engine order rotates per repetition so no engine always goes first.&lt;/li&gt;
&lt;li&gt;5 repetitions at 10M, 3 at 50M and 150M. I report &lt;strong&gt;medians&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A watchdog samples the RSS of the whole process tree every 50 ms and kills the run if it goes above &lt;strong&gt;4.5 GiB&lt;/strong&gt;. That is the cap. It is deliberately tighter than the box, because &quot;fits in a small container&quot; is the question I care about.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wall&lt;/strong&gt; is the whole process. &lt;strong&gt;Query&lt;/strong&gt; is read, compute and write only. The difference is setup.&lt;/li&gt;
&lt;li&gt;Correctness: a validation script compares row counts and rounded checksums of every output. Where an engine produced output, all three engines agreed, for every query and size.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;results-at-150m-rows&quot;&gt;Results at 150M rows&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filter + aggregate &lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.87 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;0.96 s (3/3) &lt;/td&gt;&lt;td&gt;10.77 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Join + aggregate &lt;/td&gt;&lt;td&gt;1.47 s (3/3) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;1.46 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;16.10 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;&lt;strong&gt;7.92 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;killed (0/3) &lt;/td&gt;&lt;td&gt;46.29 s (2/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;7.91 s (3/3) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;4.21 s&lt;/strong&gt; (1/3) &lt;/td&gt;&lt;td&gt;39.70 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Median wall seconds, runs that finished under the 4.5 GiB cap in brackets.&lt;/p&gt;
&lt;p&gt;Peak RSS (median of finished runs) tells the other half:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filter + aggregate &lt;/td&gt;&lt;td&gt;0.77 GiB &lt;/td&gt;&lt;td&gt;0.94 GiB &lt;/td&gt;&lt;td&gt;1.85 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Join + aggregate &lt;/td&gt;&lt;td&gt;0.42 GiB &lt;/td&gt;&lt;td&gt;1.96 GiB &lt;/td&gt;&lt;td&gt;1.61 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;3.01 GiB &lt;/td&gt;&lt;td&gt;above 4.5 (killed) &lt;/td&gt;&lt;td&gt;3.74 GiB, one run hit 7.51&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;3.01 GiB &lt;/td&gt;&lt;td&gt;4.49 GiB &lt;/td&gt;&lt;td&gt;4.40 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three things stand out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spark on one box pays twice.&lt;/strong&gt; About 5 seconds of every Spark run is setup (median 5.0 to 5.8 s, JVM plus session) before it reads a byte. Then the query itself is slower: query-only time was 7.4x DuckDB's on the filter, 7.9x on the join, 5.2x on the window dedup and 4.3x on the group by. In wall time that is 5x to 12x. And &lt;code&gt;spark.driver.memory = 3g&lt;/code&gt; did not keep the process at 3 GiB: one window dedup run reached 7.51 GiB of process-tree RSS and was killed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Polars is fast right up to the cap.&lt;/strong&gt; It tied DuckDB on filter and join and was the fastest engine on the high-cardinality group by (4.21 s against 7.91 s). But only 1 of 3 runs finished: that one peaked at 4.49 GiB, and the other two crossed the cap at 4.50 and 4.55 GiB. Fast and close to the edge is a fine place for a notebook and a bad place for a scheduled job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The window dedup is where Polars stops.&lt;/strong&gt; At 150M rows it was killed in all 3 runs, within a few seconds of starting, at 4.52 to 4.66 GiB.&lt;/p&gt;
&lt;h3 id=&quot;polars-and-the-window-wall&quot;&gt;Polars and the window wall&lt;/h3&gt;
&lt;p&gt;I gave Polars two more chances on the 150M window dedup:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Polars 2.0.0, 150M window dedup &lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Default settings, 4.5 GiB cap (Oct 22) &lt;/td&gt;&lt;td&gt;0/3, killed at 4.52 to 4.66 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;POLARS_OOC_MEMORY_BUDGET_MB=3072&lt;/code&gt;, 4.5 GiB cap (Oct 22) &lt;/td&gt;&lt;td&gt;0/3, killed at 4.57 to 4.66 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Default settings, &lt;strong&gt;9 GiB&lt;/strong&gt; cap (Oct 19, same Polars 2.0.0) &lt;/td&gt;&lt;td&gt;0/3, killed at 9.04 to 9.54 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The out-of-core budget variable is one I found in the Polars 2.0.0 source, not a documented knob, so treat that row as &quot;I tried&quot;, not as guidance. The same variable did help the group by: with it, Polars finished 3/3 at 4.67 s and 4.18 to 4.30 GiB.&lt;/p&gt;
&lt;p&gt;At smaller sizes the dedup works, but the shape is already visible:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Window dedup &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;10M rows &lt;/td&gt;&lt;td&gt;0.60 s, 0.65 GiB &lt;/td&gt;&lt;td&gt;1.83 s, 1.20 GiB &lt;/td&gt;&lt;td&gt;12.74 s, 2.94 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;50M rows &lt;/td&gt;&lt;td&gt;2.33 s, 2.19 GiB &lt;/td&gt;&lt;td&gt;10.36 s, 4.46 GiB &lt;/td&gt;&lt;td&gt;29.06 s, 3.77 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;150M rows &lt;/td&gt;&lt;td&gt;7.92 s, 3.01 GiB &lt;/td&gt;&lt;td&gt;killed &lt;/td&gt;&lt;td&gt;46.29 s, 3.74 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;At 50M Polars already used 4.46 GiB for a query DuckDB did in 2.19 GiB, and took 4.4x longer.&lt;/p&gt;
&lt;p&gt;This is the Polars expression I used. It is the direct translation of the SQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import polars as pl

amount = pl.col(&quot;qty&quot;).cast(pl.Float64) * pl.col(&quot;price&quot;)
rn = pl.int_range(pl.len()).over(
    &quot;customer_id&quot;, order_by=[&quot;ts&quot;, &quot;order_id&quot;], descending=True
)
(
    pl.scan_parquet(&quot;data/sales_150m/*.parquet&quot;)
    .filter(rn == 0)
    .select(&quot;customer_id&quot;, &quot;order_id&quot;, &quot;ts&quot;, amount.alias(&quot;amount&quot;))
    .sink_parquet(&quot;out/polars/150m_q3_window_latest.parquet&quot;)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fair caveat: I did not test other Polars formulations of the same dedup (a sort followed by &lt;code&gt;unique(keep=&quot;first&quot;)&lt;/code&gt;, or a &lt;code&gt;group_by().agg(...)&lt;/code&gt; with &lt;code&gt;sort_by().last()&lt;/code&gt;). One of them may stream better. The point stands for the common case, though: the first thing a team writes is the window, and that one did not fit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update (Oct 22):&lt;/strong&gt; after this went out, Victor Cid (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Vleyked&quot;&gt;@Vleyked&lt;/a&gt;) tested exactly that in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/1&quot;&gt;engine-bench PR #1&lt;/a&gt;. His &lt;code&gt;sort&lt;/code&gt; + &lt;code&gt;unique(keep=&quot;first&quot;)&lt;/code&gt; version drops the in-memory window fallback from the plan and finished 3/3 at 150M rows under the same 4.5 GiB cap, peaking at 2.50 to 2.74 GiB, but only with &lt;code&gt;POLARS_OOC_MEMORY_BUDGET_MB=1024&lt;/code&gt;. With default settings or a 3072 MB budget it still went over the cap. The price is speed: a 90.58 s median against 17.56 s for DuckDB on the same 4 vCPU runner, so roughly 5x slower. His output matched DuckDB row for row (14,999,347 rows). So Polars can do this dedup under the cap if you rewrite it and tune the spill budget, and with a smarter query it gets close to DuckDB (see below). His runs were on different hardware, so they are not in the tables above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second update:&lt;/strong&gt; in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/2&quot;&gt;engine-bench PR #2&lt;/a&gt; he filtered each customer down to their max timestamp first and only sorted those rows to break ties. That brought the median down to 14.23 s at 150M rows with default Polars settings, peaking at 4.04 GiB under the same cap, with output matching DuckDB row for row. On his runner DuckDB's reference was a single 18.50 s run, so I'm not calling that a repeated cross-engine win. The real lesson is that the first way you write a Polars dedup can decide whether it finishes at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third update (Oct 23):&lt;/strong&gt; Ritchie Vink, the creator of Polars, pointed out that only DuckDB was told about the memory limit, so Polars did not know it had to stay under 4 GiB, and suggested setting POLARS_OOC_MEMORY_BUDGET_MB for a fairer comparison. I reran every Polars query at 150M rows on the same box under the same 4.5 GiB cap with that budget, next to a DuckDB rerun in the same session: the high-cardinality group by finished 3/3 at 6.17 s and 3.46 GiB with a 3072 MB budget, against 9.56 s for DuckDB (the box was busier than on Oct 22 and DuckDB ran slower too, so compare within this session, not with the tables above; the Oct 22 run with the same budget gave 4.67 s). The window dedup with over(order_by) still went over the cap in every run at 3072, 1024 and 512 MB, Victor's sort plus unique version fit only at 1024 MB (31.28 s, 2.29 GiB), and the max-timestamp rewrite finished 3/3 on default settings (5.61 s, 4.02 GiB). Ritchie says Polars main now spills the group by correctly and that this ships in the next release, which was not on PyPI yet when I ran this, so I will rerun when it ships. Raw results and output checks are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/3&quot;&gt;engine-bench PR #3&lt;/a&gt; and in results/polars_ooc_budget_2026-10-23 on the polars-ooc-budget-2026-10-23 branch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fourth update (Oct 26):&lt;/strong&gt; after the third update I reported the window dedup to Polars as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pola-rs/polars/issues/30270&quot;&gt;pola-rs/polars#30270&lt;/a&gt;: the over(order_by) window inside filter() ignored POLARS_OOC_MEMORY_BUDGET_MB and ran in an in-memory fallback node. Ritchie Vink fixed it (#30272, #30282 and #30280), and the fixes shipped in Polars 2.1.0. I reran the 150M benchmark on the official 2.1.0 wheel on the same box, with the same harness and the same 4.5 GiB cap, next to a DuckDB rerun in the same session. With a budget set, the window dedup now finishes 3/3 at every budget I tried: 18.78 s and 3.34 GiB at 3072 MB, 20.11 s and 1.29 GiB at 1024 MB, 43.01 s and 2.15 GiB at 512 MB. On 2.0.0 it failed at all three. Memory now follows the budget closely: at 512 MB, Victor's sort plus unique version peaked at 0.69 GiB (73.85 s) and the high-cardinality group by at 0.74 GiB (9.43 s). DuckDB still ran the window dedup faster in the same session, 8.07 s, about 2.3x quicker than Polars at 3072 MB, and the max-timestamp rewrite remains the fastest Polars dedup (5.12 s on default settings). Every finished output matched DuckDB row for row (14,999,347 rows). Two caveats. Without a budget, the window dedup and sort plus unique still went over the cap. My cap is a process watchdog, not a cgroup limit, so the new cgroup-aware default in 2.1.0 had nothing to detect and is untested here; set the budget explicitly. One sort plus unique run at 3072 MB took 285.64 s for no clear reason, so I report medians of 3 runs. Raw results, plans and output checks: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/4&quot;&gt;engine-bench PR #4&lt;/a&gt; and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/blob/polars-main-window-fix-2026-10-24/results/polars_2.1.0_2026-10-25/README.md&quot;&gt;2.1.0 results README&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;duckdb-under-pressure-memory-limit-1-gb&quot;&gt;DuckDB under pressure: memory_limit = 1 GB&lt;/h3&gt;
&lt;p&gt;The interesting DuckDB question is not &quot;how fast with room to spare&quot; but &quot;what happens when the box is small&quot;. Same 150M rows, &lt;code&gt;memory_limit&lt;/code&gt; cut to 1 GB:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;150M, &lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/th&gt;&lt;th&gt;Wall (median) &lt;/th&gt;&lt;th&gt;Peak RSS &lt;/th&gt;&lt;th&gt;Finished&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;14.97 s &lt;/td&gt;&lt;td&gt;1.12 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;13.94 s &lt;/td&gt;&lt;td&gt;1.08 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup, &lt;code&gt;preserve_insertion_order = false&lt;/code&gt; &lt;/td&gt;&lt;td&gt;14.94 s &lt;/td&gt;&lt;td&gt;1.12 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Group by, &lt;code&gt;preserve_insertion_order = false&lt;/code&gt; &lt;/td&gt;&lt;td&gt;13.88 s &lt;/td&gt;&lt;td&gt;1.08 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With a third of the memory, DuckDB spilled to disk and still beat Spark's unconstrained run by about 3x on both queries, at a quarter to a third of Spark's peak RSS. The DuckDB docs list window functions with &lt;code&gt;PARTITION BY ... ORDER BY&lt;/code&gt; among the blocking operators it can process larger than memory (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/guides/performance/how_to_tune_workloads&quot;&gt;How to Tune Workloads&lt;/a&gt;). This run is that sentence, measured.&lt;/p&gt;
&lt;p&gt;The setup is three lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import duckdb

con = duckdb.connect()
con.execute(&quot;SET memory_limit = '1GB'&quot;)
con.execute(&quot;SET threads = 8&quot;)
con.execute(&quot;SET temp_directory = '/mnt/fast-local-disk/duckdb_tmp'&quot;)  # where spills go
con.execute(&quot;&quot;&quot;
    COPY (
        SELECT customer_id, order_id, ts, qty * price AS amount
        FROM (
            SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
                                         ORDER BY ts DESC, order_id DESC) AS rn
            FROM read_parquet('data/sales_150m/*.parquet')
        )
        WHERE rn = 1
    ) TO 'out/latest_per_customer.parquet' (FORMAT parquet)
&quot;&quot;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;duckdb-1-5-6-vs-2-0-0&quot;&gt;DuckDB 1.5.6 vs 2.0.0&lt;/h3&gt;
&lt;p&gt;Same box, same data, same scripts. 1.5.6 ran on October 19, 2.0.0 on October 22. Median wall seconds at 150M:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Setting &lt;/th&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;1.5.6 &lt;/th&gt;&lt;th&gt;2.0.0 &lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 3GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;9.09 &lt;/td&gt;&lt;td&gt;7.92 &lt;/td&gt;&lt;td&gt;-13%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 3GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;9.61 &lt;/td&gt;&lt;td&gt;7.91 &lt;/td&gt;&lt;td&gt;-18%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;17.79 &lt;/td&gt;&lt;td&gt;14.97 &lt;/td&gt;&lt;td&gt;-16%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;22.06 &lt;/td&gt;&lt;td&gt;13.94 &lt;/td&gt;&lt;td&gt;-37%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1GB, no insertion order &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;14.85 &lt;/td&gt;&lt;td&gt;14.94 &lt;/td&gt;&lt;td&gt;+1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1GB, no insertion order &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;15.15 &lt;/td&gt;&lt;td&gt;13.88 &lt;/td&gt;&lt;td&gt;-8%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The useful finding is the last four rows together. On 1.5.6, &lt;code&gt;SET preserve_insertion_order = false&lt;/code&gt; was worth 16% on the dedup and 31% on the group by under a 1 GB limit. On 2.0.0 the default already lands where the tuned setting did, and the flag changes almost nothing for these two queries. If you carry that setting in job configs as a performance trick, re-measure after upgrading. It still matters for what it was designed for: the docs recommend it when large imports or exports run out of memory, and it lets DuckDB reorder rows without an &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Not everything improved. The 50M window dedup was slower on 2.0.0 (1.94 s to 2.33 s), and peak RSS for the light queries went up (150M filter: 0.26 to 0.77 GiB, join: 0.23 to 0.42 GiB), while the heavy queries went down (window dedup: 3.47 to 3.01 GiB). I have not investigated why. The two runs were also three days apart on a shared box, so treat differences under about 10 to 15% as noise. The 1 GB group by drop is far outside that.&lt;/p&gt;
&lt;p&gt;The release itself is mostly a feature release: Quack and &lt;code&gt;CONNECT&lt;/code&gt; for client-server use, asynchronous I/O, a first-class &lt;code&gt;VARIANT&lt;/code&gt;, triggers, a PEG parser by default (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;announcement&lt;/a&gt;). It is an LTS line supported until at least October 2027. One migration note matters for pipelines: database files written by 2.0 cannot be read by versions before 2.0. My benchmark only reads and writes Parquet, so it never touched that.&lt;/p&gt;
&lt;h3 id=&quot;where-spark-still-wins&quot;&gt;Where Spark still wins&lt;/h3&gt;
&lt;p&gt;None of this says &quot;replace Spark&quot;. It says Spark on one box is the wrong comparison for small and medium jobs. Spark still wins when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The data does not fit one node's disk and memory, or the deadline does not fit one node's cores.&lt;/strong&gt; This test stops at 3.2 GB of Parquet on purpose. DuckDB spills well, but one machine is still one machine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The platform is built around it.&lt;/strong&gt; On Databricks and Fabric, Spark is where Unity Catalog or OneLake governance, Delta writes with concurrent writers, lineage, job orchestration and streaming already live. A DuckDB step outside that boundary is a step you have to secure, monitor and explain.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cluster is already running.&lt;/strong&gt; The 5 second JVM start I measured per run disappears in a warm interactive cluster or a long job. The query-only gap (4x to 8x here) does not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The team is a Spark team.&lt;/strong&gt; One engine everyone can debug at 02:00 is worth more than a faster engine one person understands.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My opinion as a Lead DE on Azure, Databricks and Fabric: the line is not the engine, it is the size of the job. Most jobs I see are tens of millions of rows dressed up as big data and started on a cluster. For those (file-to-file transforms before landing, validation and reconciliation checks, small dimension builds, CI tests for SQL logic) a single-node engine with a memory limit is the cheaper and more predictable choice. Anything that writes governed Delta tables stays on the platform.&lt;/p&gt;
&lt;h3 id=&quot;failure-modes-i-hit-so-you-don-t-have-to&quot;&gt;Failure modes I hit, so you don't have to&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Engine memory settings are not process memory.&lt;/strong&gt; Spark's 3g driver heap still produced a 7.51 GiB process. DuckDB's whole process stayed close to its &lt;code&gt;memory_limit&lt;/code&gt;: about 3.0 GiB peak at a 3 GB limit, 1.1 GiB at 1 GB. Size containers on measured RSS, not on the setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Streaming&quot; does not mean bounded.&lt;/strong&gt; Polars streamed the filter, join and group by fine. The window dedup with an &lt;code&gt;over(order_by=...)&lt;/code&gt; did not stay bounded, even with 9 GiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Close to the cap is a failure that hasn't happened yet.&lt;/strong&gt; Polars' group by finished in 1 of 3 runs, peaking at 4.49 GiB against a 4.5 GiB cap. Same query, same data, same version. Schedule that and it fails some nights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared hosts lie.&lt;/strong&gt; One Spark run at 10M was killed because free memory on the whole box fell under my 1 GiB safety floor while Spark itself was at 1.6 GiB. That was the neighbours, not Spark. Gate each run on free memory and CPU, and record both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate outputs, not just timings.&lt;/strong&gt; Every engine produced the same row counts and checksums where it finished. Without that check, a fast wrong answer looks like a win.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h3&gt;
&lt;p&gt;Everything is public at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench&quot;&gt;firfircelik/engine-bench&lt;/a&gt;: scripts, raw results, pinned versions and the query plans. Here are the parts that matter. The data generator is seeded (&lt;code&gt;SEED = 20261019&lt;/code&gt;) and writes 5M-row chunks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;rng = np.random.default_rng([SEED, chunk_index])
t = pa.table({
    &quot;order_id&quot;: np.arange(lo, lo + n, dtype=np.int64),
    &quot;ts&quot;: pa.array(start + rng.integers(0, 270 * 24 * 3600, n), pa.timestamp(&quot;s&quot;)),
    &quot;customer_id&quot;: rng.integers(0, rows // 10, n, dtype=np.int32),
    &quot;product_id&quot;: rng.integers(0, 50_000, n, dtype=np.int32),
    &quot;store_id&quot;: rng.integers(0, 500, n, dtype=np.int16),
    &quot;qty&quot;: rng.integers(1, 10, n, dtype=np.int16),
    &quot;price&quot;: np.round(rng.uniform(1.0, 500.0, n), 2),
    &quot;status&quot;: rng.choice(STATUSES, n, p=[0.55, 0.20, 0.12, 0.08, 0.05]),
})
pq.write_table(t, f&quot;sales_{rows // 1_000_000}m/part-{chunk_index:04d}.parquet&quot;,
               row_group_size=1_000_000, compression=&quot;snappy&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spark session, the only non-default settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;spark = (SparkSession.builder.master(&quot;local[8]&quot;)
         .config(&quot;spark.driver.memory&quot;, &quot;3g&quot;)
         .config(&quot;spark.local.dir&quot;, TMP)
         .config(&quot;spark.ui.enabled&quot;, &quot;false&quot;)
         .config(&quot;spark.sql.session.timeZone&quot;, &quot;UTC&quot;)
         .getOrCreate())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the watchdog, which is the part I would copy into any benchmark of your own. It measures the whole process tree, because JVMs and thread pools do not show up in the parent's RSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import psutil, subprocess, time

GIB = 1024 ** 3

def tree_rss(p):
    total = 0
    for q in [p] + p.children(recursive=True):
        try:
            total += q.memory_info().rss
        except psutil.NoSuchProcess:
            pass
    return total

proc = subprocess.Popen(cmd)
p, peak = psutil.Process(proc.pid), 0
while proc.poll() is None:
    rss = tree_rss(p)
    peak = max(peak, rss)
    if rss &amp;gt; 4.5 * GIB:
        for q in p.children(recursive=True) + [p]:
            q.kill()
        break
    time.sleep(0.05)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h3&gt;
&lt;p&gt;On one 8 vCPU machine with a 4.5 GiB cap, DuckDB 2.0.0 was the only engine that finished every run of every query at every size, and it did the heavy ones with 1 GB. Polars matched or beat it on the light queries and the group by, and could not finish the window dedup at 150M even with 9 GiB. Local Spark finished almost everything, 5x to 12x slower, and its memory setting did not bound its memory.&lt;/p&gt;
&lt;p&gt;If your job fits on one machine, measure it on one machine before you start a cluster for it.&lt;/p&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;Announcing DuckDB 2.0.0 LTS &quot;Cinnamon Teal&quot;&lt;/a&gt;, DuckDB team, October 21, 2026&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/release_calendar&quot;&gt;DuckDB release calendar&lt;/a&gt; (2.0.0 LTS on 2026-10-21, 1.5.6 on 2026-09-28)&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/duckdb/2.0.0/&quot;&gt;duckdb 2.0.0 on PyPI&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/polars/2.0.0/&quot;&gt;polars 2.0.0 on PyPI&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/pyspark/4.2.0/&quot;&gt;pyspark 4.2.0 on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/guides/performance/how_to_tune_workloads&quot;&gt;DuckDB: How to Tune Workloads&lt;/a&gt; (larger-than-memory operators, &lt;code&gt;preserve_insertion_order&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/sql/dialect/order_preservation&quot;&gt;DuckDB: Order Preservation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;All timings and memory numbers: my own runs on October 19 (DuckDB 1.5.6, and the 9 GiB Polars and Spark runs) and October 22, 2026 (DuckDB 2.0.0, Polars 2.0.0, PySpark 4.2.0).&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32051/duckdb-2-0-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-4-5-gib-cap</guid>
<pubDate>Thu, 22 Oct 2026 08:52:00 +0000</pubDate>
</item>
<item>
<title>Fabric Runtime 2.0 is becoming the default. Here is what actually breaks.</title>
<link>https://coderlegion.com/30475/fabric-runtime-2-0-is-becoming-the-default-here-is-what-actually-breaks</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/fabric-runtime-20-is-becoming-the-default-here-is-what-actually-breaks-12i9&quot;&gt;DEV.to&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;fabric-runtime-2-0-is-becoming-the-default-here-is-what-actually-breaks-&quot;&gt;Fabric Runtime 2.0 is becoming the default. Here is what actually breaks.&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Late September 2026 is when Fabric Runtime 2.0 stops being an opt-in experiment and becomes the path of least resistance for new workspaces and environment items. If you treat that as a release note, you will discover the breakages in production.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I work as a Lead Data Engineer on Azure, Fabric, Databricks, and Spark. This is the migration checklist I wish teams ran &lt;em&gt;before&lt;/em&gt; the default flipped under them.&lt;/p&gt;
&lt;p&gt;Docs to keep open while you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/fabric/data-engineering/runtime-2-0&quot;&gt;Fabric Runtime 2.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/fabric/data-engineering/native-execution-engine-overview&quot;&gt;Native Execution Engine overview&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;1-hook-the-default-is-the-migration&quot;&gt;1) Hook: the default &lt;em&gt;is&lt;/em&gt; the migration&lt;/h3&gt;
&lt;p&gt;Microsoft's Runtime 2.0 page is blunt: GA and ready for production, but &lt;strong&gt;not&lt;/strong&gt; the default yet. You opt in. The stated plan is to make Runtime 2.0 the default UX selection and the default for &lt;strong&gt;new workspaces and new environment items&lt;/strong&gt; in late September 2026.&lt;/p&gt;
&lt;p&gt;Today is 28 September 2026. That window is now.&lt;/p&gt;
&lt;p&gt;Existing workspaces, jobs, and Environment items stay on whatever you configured. The trap is quieter. Every new workspace for a pilot, every Environment someone creates because &quot;defaults are fine,&quot; starts landing on Spark 4.1 / Delta 4.2 / Python 3.13 once the plan executes.&lt;/p&gt;
&lt;p&gt;Treat the flip as an incident rehearsal date. Pin what you care about. Validate 2.0 on a scoped Environment. Do not discover Python wheel breakage through a failed Monday job.&lt;/p&gt;
&lt;p&gt;Runtime 1.3 still has runway. Secondary writeups put Microsoft's LTS window for 1.3 roughly from early October 2026 through March 2027. Verify against current lifecycle docs before you commit a program date. LTS means supported, not &quot;ignore migration.&quot;&lt;/p&gt;
&lt;h3 id=&quot;2-wrong-assumption-ga-means-notebooks-just-run&quot;&gt;2) Wrong assumption: GA means notebooks just run&lt;/h3&gt;
&lt;p&gt;Runtime 1.3 to Runtime 2.0 is not a patch bump. It is a stack jump on nearly every component:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Component &lt;/th&gt;&lt;th&gt;Runtime 1.3 (typical) &lt;/th&gt;&lt;th&gt;Runtime 2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Apache Spark &lt;/td&gt;&lt;td&gt;3.5 &lt;/td&gt;&lt;td&gt;4.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Delta Lake &lt;/td&gt;&lt;td&gt;3.2 &lt;/td&gt;&lt;td&gt;4.2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Python &lt;/td&gt;&lt;td&gt;3.11 &lt;/td&gt;&lt;td&gt;3.13&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Java &lt;/td&gt;&lt;td&gt;11 &lt;/td&gt;&lt;td&gt;21&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Scala &lt;/td&gt;&lt;td&gt;2.12 &lt;/td&gt;&lt;td&gt;2.13&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OS &lt;/td&gt;&lt;td&gt;(prior Fabric OS) &lt;/td&gt;&lt;td&gt;Azure Linux 3.0 (Mariner 3.0)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Industry writeups also call out Delta protocol defaults moving higher (reader/writer versions, Deletion Vectors in the 2.0 path). Treat protocol as a first-class migration risk, not a footnote. See Microsoft's Delta Lake table format interoperability guidance for what cross-experience readers can actually open.&lt;/p&gt;
&lt;p&gt;Practical fallout I care about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python 3.11 -&amp;gt; 3.13.&lt;/strong&gt; Environment-level libraries may migrate automatically. Validation does not. Custom wheels and pinned packages are your problem. Session-scoped &lt;code&gt;%pip install&lt;/code&gt; and notebook Resources are a different blast radius than Environment items.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scala 2.12 -&amp;gt; 2.13 / Java 11 -&amp;gt; 21.&lt;/strong&gt; JARs and connectors that &quot;worked forever&quot; on 1.3 deserve a dual-run, not hope.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SparkR is deprecated in Spark 4.x&lt;/strong&gt; and may be removed later. If you still have SparkR notebooks, the clock is already running.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GA means Microsoft ships it for production use. It does not mean your Environment's library graph survives the jump without you watching.&lt;/p&gt;
&lt;h3 id=&quot;3-wrong-assumption-nee-free-6x&quot;&gt;3) Wrong assumption: NEE = free 6x&lt;/h3&gt;
&lt;p&gt;Runtime 2.0 includes the Native Execution Engine (NEE). Microsoft's docs describe it as a vectorized path based on &lt;strong&gt;Apache Gluten&lt;/strong&gt; (offload middle layer) and &lt;strong&gt;Velox&lt;/strong&gt; (C++ engine from Meta). Supported operators leave the JVM and run columnar with SIMD-style acceleration on Parquet, Delta, and (now) CSV. Microsoft cites &lt;strong&gt;up to six times faster&lt;/strong&gt; vs open-source Spark on TPC-DS. That is a vendor benchmark on a specific workload family. It is a ceiling story, not your median job.&lt;/p&gt;
&lt;p&gt;How you enable it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Environment -&amp;gt; Spark compute -&amp;gt; &lt;strong&gt;Acceleration&lt;/strong&gt; -&amp;gt; enable native execution engine (preferred UX path), or&lt;/li&gt;
&lt;li&gt;Spark conf: &lt;code&gt;spark.native.enabled&lt;/code&gt; (also usable per notebook / SJD via &lt;code&gt;%%configure&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hard limits from the docs that matter in production:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Structured streaming is not supported.&lt;/strong&gt; Use streaming? Plan for JVM (or keep NEE off for those jobs).&lt;/li&gt;
&lt;li&gt;Unsupported ops &lt;strong&gt;fall back&lt;/strong&gt; to classic Spark. Fallback is automatic. Your job keeps running. Your SLA may not.&lt;/li&gt;
&lt;li&gt;Every boundary between native columnar and JVM row-oriented execution pays a &lt;strong&gt;conversion tax&lt;/strong&gt;. That is the part marketing slides skip.&lt;/li&gt;
&lt;li&gt;Fabric Spark Advisor can surface fallback alerts in notebook cell output. Use them. Do not ignore green &quot;job succeeded&quot; when Advisor is screaming about offload misses.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NEE also preserves AQE, cost-based rewrites, column pruning, and predicate pushdown when operators &lt;em&gt;do&lt;/em&gt; offload. Good. That does not help if half the plan is bouncing in and out of Velox.&lt;/p&gt;
&lt;p&gt;Blunt take: a vectorized engine that falls back on half your plan is not acceleration. It is a tax you opted into because a dashboard said &quot;up to 6x.&quot;&lt;/p&gt;
&lt;h3 id=&quot;4-how-to-see-the-truth-in-the-plan&quot;&gt;4) How to see the truth in the plan&lt;/h3&gt;
&lt;p&gt;Do not argue with anecdotes. Read the plan.&lt;/p&gt;
&lt;p&gt;In Spark UI / History Server, look for node names ending in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*Transformer&lt;/code&gt; (for example &lt;code&gt;ProjectExecTransformer&lt;/code&gt;, &lt;code&gt;BroadcastHashJoinExecTransformer&lt;/code&gt;, &lt;code&gt;RollUpHashAggregateTransformer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;*NativeFileScan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;VeloxColumnarToRowExec&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those suffixes mean native offload happened for that node. Light-blue JVM nodes next to green native nodes (Microsoft's Gluten SQL / DataFrame tab uses that color language) are your fallback map. Every &lt;code&gt;VeloxColumnarToRowExec&lt;/code&gt; (and the reverse path) is a place where you paid for format conversion.&lt;/p&gt;
&lt;p&gt;Also useful: &lt;code&gt;df.explain()&lt;/code&gt;, Fabric Spark Advisor fallback alerts, and the Gluten SQL / DataFrame tab's native-vs-JVM node counts.&lt;/p&gt;
&lt;p&gt;Surgical disable for one cell (unsupported op, correctness edge, or A/B):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;spark.conf.set('spark.native.enabled', 'false')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Re-enable afterward if later cells should use NEE again. Spark runs cells in order. Conf sticks.&lt;/p&gt;
&lt;h3 id=&quot;5-delta-4-x-trap-for-multi-workload-tables&quot;&gt;5) Delta 4.x trap for multi-workload tables&lt;/h3&gt;
&lt;p&gt;This is the silent one.&lt;/p&gt;
&lt;p&gt;Microsoft is explicit: &lt;strong&gt;Delta Lake 4.2-specific features are experimental and only work on Spark experiences&lt;/strong&gt; (notebooks and Spark Job Definitions). If the same Delta tables must be used across multiple Fabric workloads, &lt;strong&gt;do not enable those features&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Why it hurts: SQL analytics endpoint, Power BI Direct Lake, and other non-Spark readers do not all speak the same protocol / feature set. Protocol upgrades (higher reader/writer versions, Deletion Vectors, experimental checkpoints) are effectively one-way doors. Recreate the table is the recovery path, not &quot;uncheck the box.&quot; Shared bronze/silver tables that feed Spark &lt;em&gt;and&lt;/em&gt; Direct Lake &lt;em&gt;and&lt;/em&gt; SQL endpoint are exactly where someone enables a shiny 4.x feature on a Friday and breaks Monday's semantic model.&lt;/p&gt;
&lt;p&gt;Rule I use: &lt;strong&gt;default to the most constrained reader in the estate.&lt;/strong&gt; If Direct Lake or the SQL endpoint must read it, keep features inside what those readers support. Save experimental Delta 4.x toys for Spark-only sandboxes.&lt;/p&gt;
&lt;h3 id=&quot;6-practical-migration-pattern&quot;&gt;6) Practical migration pattern&lt;/h3&gt;
&lt;p&gt;This is the runbook shape I actually use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Inventory&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Workspaces and Environment items: which runtime are they on?&lt;/li&gt;
&lt;li&gt;Jobs that create &lt;em&gt;new&lt;/em&gt; Environments without pinning&lt;/li&gt;
&lt;li&gt;Libraries: Environment wheels vs session &lt;code&gt;%pip&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tables read by more than one Fabric experience&lt;/li&gt;
&lt;li&gt;Streaming jobs (NEE: leave off or isolate)&lt;/li&gt;
&lt;li&gt;SparkR notebooks (debt clock)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;2. Baseline on 1.3&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Duration, shuffle bytes, spill, failure rate, data quality checks&lt;/li&gt;
&lt;li&gt;Capture plans for critical queries &lt;em&gt;before&lt;/em&gt; you change anything&lt;/li&gt;
&lt;li&gt;Note current Delta protocol / table features on shared tables&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3. Dual-run on 2.0&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scoped Environment item first (surgical), not whole workspace&lt;/li&gt;
&lt;li&gt;Same input snapshot, same checks, compare plans and SLA&lt;/li&gt;
&lt;li&gt;Turn NEE on only after the JVM path is green on 2.0&lt;/li&gt;
&lt;li&gt;Kill vanity actions that force row materialization (yes, that includes habit &lt;code&gt;.show()&lt;/code&gt; in hot paths during timing runs)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;4. Rollback in the runbook before you need it&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Runtime: pin Environment / workspace back to 1.3 while LTS covers you&lt;/li&gt;
&lt;li&gt;NEE only:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;spark.conf.set('spark.native.enabled', 'false')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put both in the on-call doc. A rollback you invent at 02:00 is not a rollback.&lt;/p&gt;
&lt;h3 id=&quot;7-ci-cd-angle-pin-the-runtime-like-a-toolchain&quot;&gt;7) CI/CD angle: pin the runtime like a toolchain&lt;/h3&gt;
&lt;p&gt;You would not let &lt;code&gt;python&lt;/code&gt; float to whatever the image shipped last Tuesday without a lockfile. Treat Fabric runtime the same way.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pin runtime version on Environment items that production notebooks and SJDs attach to&lt;/li&gt;
&lt;li&gt;Fail the pipeline if an Environment publishes without an explicit runtime&lt;/li&gt;
&lt;li&gt;Gate promotion: 1.3 baseline metrics vs 2.0 dual-run metrics must be compared, not vibes&lt;/li&gt;
&lt;li&gt;Separate &quot;NEE enabled&quot; as its own promotion flag. Runtime upgrade and native offload are two changes. Ship them as two changes.&lt;/li&gt;
&lt;li&gt;Use Fabric runtime release channels (preview) to rehearse upcoming defaults on custom pools. Do not park production there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If CI deploys notebook JSON and never asserts runtime + Acceleration settings, you are continuous-delivering entropy.&lt;/p&gt;
&lt;h3 id=&quot;8-close-benchmarks-sell-the-ceiling-production-pays-the-median&quot;&gt;8) Close: benchmarks sell the ceiling, production pays the median&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Workload type &lt;/th&gt;&lt;th&gt;Enable NEE? &lt;/th&gt;&lt;th&gt;Watch for...&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Large Parquet/Delta batch ETL, heavy agg/join &lt;/td&gt;&lt;td&gt;Yes, after dual-run &lt;/td&gt;&lt;td&gt;Fallback density, conversion boundaries, Advisor alerts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Interactive lakehouse SQL / notebook exploration &lt;/td&gt;&lt;td&gt;Often yes &lt;/td&gt;&lt;td&gt;Date type mismatches on filters, wide UDF usage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Structured streaming &lt;/td&gt;&lt;td&gt;No (unsupported) &lt;/td&gt;&lt;td&gt;Accidental enable via Environment inheritance&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JSON/XML heavy paths &lt;/td&gt;&lt;td&gt;Limited / often no &lt;/td&gt;&lt;td&gt;Format falls back to JVM; measure before celebrating&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Shared Delta tables (Spark + SQL endpoint / Direct Lake) &lt;/td&gt;&lt;td&gt;NEE optional; &lt;strong&gt;Delta 4.x experimental features: no&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Protocol / feature lockout of non-Spark readers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SparkR or exotic Scala libs &lt;/td&gt;&lt;td&gt;Maybe later &lt;/td&gt;&lt;td&gt;Language deprecation and binary incompat first&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pin Runtime 1.3 on anything you cannot afford to surprise&lt;/strong&gt;, then opt into 2.0 on a scoped Environment before the default teaches your newest workspace the hard way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat NEE as a plan shape problem&lt;/strong&gt;, not a toggle. &lt;code&gt;*Transformer&lt;/code&gt; and &lt;code&gt;VeloxColumnarToRowExec&lt;/code&gt; tell the truth; &quot;up to 6x&quot; does not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never enable experimental Delta 4.x features on multi-experience tables.&lt;/strong&gt; Shared tables inherit the weakest reader's constraints.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Benchmarks sell the ceiling. Production pays the median. Migrate like you believe that.&lt;/p&gt;
&lt;p&gt;What is your scarier unpaid bill right now: Python 3.13 libraries, NEE fallback thrash, or a Delta feature someone enabled on a table Direct Lake still needs to read?&lt;/p&gt;
&lt;h3 id=&quot;sources-1&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/fabric/data-engineering/runtime-2-0&quot;&gt;Runtime 2.0 in Fabric (Microsoft Learn)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/fabric/data-engineering/native-execution-engine-overview&quot;&gt;Native execution engine for Fabric Data Engineering (Microsoft Learn)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Context (not a substitute for the docs): &lt;a rel=&quot;nofollow&quot; href=&quot;https://rajanieshkaushikk.com/2026/09/23/fabric-runtime-2-0-two-breakages/&quot;&gt;Fabric Runtime 2.0 two breakages (Rajaniesh Kaushikk, 2026-09-23)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30475/fabric-runtime-2-0-is-becoming-the-default-here-is-what-actually-breaks</guid>
<pubDate>Fri, 09 Oct 2026 13:20:30 +0000</pubDate>
</item>
<item>
<title>Cost &amp; Performance Comparison of Data Orchestration on Azure; Data Analysis of Data!</title>
<link>https://coderlegion.com/10949/cost-performance-comparison-of-data-orchestration-on-azure-data-analysis-of-data</link>
<description>&lt;h3 id=&quot;new-project-cost-amp-performance-comparison-of-data-orchestration-on-azure&quot;&gt;New Project: Cost &amp;amp; Performance Comparison of Data Orchestration on Azure&lt;/h3&gt;
&lt;p&gt;I recently completed an experimental study comparing runtime and cost efficiency across three common data engineering orchestration approaches on Azure:&lt;/p&gt;
&lt;p&gt;• Databricks Notebook Clusters &lt;br&gt;
• Azure Data Factory → Databricks &lt;br&gt;
• Databricks Workflows (multi-notebook pipelines)&lt;/p&gt;
&lt;p&gt;Instead of theoretical claims, I used measured execution times, a fixed cluster configuration (Standard_D4s_v3), and then scaled the results to 1 TB of data and 100 hours of processing using clearly stated assumptions.&lt;/p&gt;
&lt;p&gt;Key findings:&lt;/p&gt;
&lt;p&gt;✅ For small datasets, orchestration overhead dominates (ADF &amp;amp; Workflows are slower)&lt;br&gt;
✅ At TB-scale, all approaches converge in runtime — architecture matters more than speed&lt;br&gt;
✅ Cost differences are marginal at scale, but orchestration choices matter for long-running workloads&lt;br&gt;
✅ Databricks Workflows are ideal for Spark-heavy pipelines&lt;br&gt;
✅ ADF still makes sense when orchestrating multiple Azure services&lt;/p&gt;
&lt;p&gt;This project helped me better understand where architectural decisions actually matter — beyond “ADF vs Databricks” debates.&lt;/p&gt;
&lt;p&gt; GitHub repo with experimental results, tables, and screenshots:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/usman19zafar/Data-Engineering-Cost-Performance-Comparison&quot;&gt;https://github.com/usman19zafar/Data-Engineering-Cost-Performance-Comparison&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Happy to hear thoughts from other data engineers — especially how you decide orchestration strategy in production systems.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10949/cost-performance-comparison-of-data-orchestration-on-azure-data-analysis-of-data</guid>
<pubDate>Tue, 03 Feb 2026 03:58:35 +0000</pubDate>
</item>
<item>
<title>Corrupted‑to‑Gold</title>
<link>https://coderlegion.com/10836/corrupted-to-gold</link>
<description>&lt;p&gt;Corrupted‑to‑Gold: MDM Foundations&lt;/p&gt;
&lt;p&gt;This build takes intentionally corrupted CSV files and transforms them into clean, trusted, analytics‑ready Delta tables. The pipeline demonstrates real‑world data engineering constraints: inconsistent delimiters, malformed dates, broken encodings, duplicate entities, and multi‑format ingestion.&lt;/p&gt;
&lt;p&gt;The solution includes:&lt;/p&gt;
&lt;p&gt;✔ RAW → BRONZE ingestion&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Normalized delimiters&lt;pre&gt;&lt;code&gt;   Removed non‑ASCII noise
   Safe splitting logic
   Header detection
   Schema validation
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;✔ SILVER cleansing&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email, phone, country, and status normalization&lt;pre&gt;&lt;code&gt;   Multi‑format date parsing
   Null handling and type correction
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;✔ Survivorship scoring (MDM‑light)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email validity&lt;pre&gt;&lt;code&gt;  Phone completeness
  Date reliability
  Status correctness
  Row‑level scoring + best‑record selection
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;✔ GOLD outputs&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clean, deduped, resequenced customer entities&lt;pre&gt;&lt;code&gt;   Delta‑optimized storage
   Ready for downstream MDM and ADF Mapping Data Flows
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This project sets the foundation for the full MDM pipeline (matching, clustering, survivorship, and golden‑record creation) and the upcoming ADF MDF suite demonstrating schema drift, parameterization, joins, window functions, branching, SCD‑light, partitioning, and KPI generation.&lt;/p&gt;
&lt;p&gt;If you want the full code, architecture, or notebooks, the repo is here:&lt;/p&gt;
&lt;p&gt;github.com/usman19zafar/Project_corrupted-to-gold-data-journey&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10836/corrupted-to-gold</guid>
<pubDate>Sat, 31 Jan 2026 17:35:50 +0000</pubDate>
</item>
</channel>
</rss>