<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged databricks</title>
<link>https://coderlegion.com/tag/databricks</link>
<description>Powered by Question2Answer</description>
<item>
<title>Auto CDF Moved the CDC Tax From Writes to Reads: Delta CDF vs Auto CDF vs Spark 4.2 CHANGES</title>
<link>https://coderlegion.com/32478/auto-cdf-moved-the-cdc-tax-from-writes-to-reads-delta-cdf-vs-auto-cdf-vs-spark-4-2-changes</link>
<description>&lt;p&gt;Originally published on DEV.to: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/auto-cdf-moved-the-cdc-tax-from-writes-to-reads-delta-cdf-vs-auto-cdf-vs-spark-42-changes-9bb&quot;&gt;https://dev.to/firfircelik/auto-cdf-moved-the-cdc-tax-from-writes-to-reads-delta-cdf-vs-auto-cdf-vs-spark-42-changes-9bb&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;On September 1, 2026, Databricks made automatic change data feed (Auto CDF) generally available. The release note says it &quot;removes write-time overhead and makes &lt;code&gt;MERGE&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; operations about 15% faster on tables that you query for changes.&quot; Seven weeks earlier, Apache Spark 4.2.0 (July 14) added a SQL &lt;code&gt;CHANGES&lt;/code&gt; clause, so reading row-level changes is now a first-class part of the engine, not a Delta-only option.&lt;/p&gt;
&lt;p&gt;Most of the takes I have seen stop at &quot;free CDC, faster MERGE, turn it on.&quot; My thesis is narrower:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto CDF does not remove the cost of change data. It moves it from the writer, who pays once per commit, to every reader, who pays on every read.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Its prerequisite, row tracking, has a write-time cost of its own&lt;/strong&gt; that nobody puts on the slide.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spark 4.2's &lt;code&gt;CHANGES&lt;/code&gt; is a contract, not an implementation.&lt;/strong&gt; Whether it works depends on your connector, and on open-source Delta 4.4.1 it does not work out of the box.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the real question is not &quot;legacy or auto.&quot; It is: how many times is each commit read, and how much data does each commit rewrite?&lt;/p&gt;
&lt;h3 id=&quot;three-ways-to-get-the-changes&quot;&gt;Three ways to get the changes&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Legacy CDF (write time) &lt;/th&gt;&lt;th&gt;Auto CDF (read time) &lt;/th&gt;&lt;th&gt;Spark 4.2 &lt;code&gt;CHANGES&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Where the work happens &lt;/td&gt;&lt;td&gt;Writer writes change files next to the data &lt;/td&gt;&lt;td&gt;Reader computes changes from row tracking metadata &lt;/td&gt;&lt;td&gt;Connector supplies a changelog, Spark post-processes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Opt in &lt;/td&gt;&lt;td&gt;&lt;code&gt;delta.enableChangeDataFeed = true&lt;/code&gt; per table &lt;/td&gt;&lt;td&gt;Automatic when requirements are met &lt;/td&gt;&lt;td&gt;Catalog must implement &lt;code&gt;loadChangelog&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Who can read it &lt;/td&gt;&lt;td&gt;Any Delta reader that implements the &lt;code&gt;cdc&lt;/code&gt; action of the protocol &lt;/td&gt;&lt;td&gt;Databricks readers only (DBR 19+) &lt;/td&gt;&lt;td&gt;Any Spark 4.2 client, if the connector supports it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Formats &lt;/td&gt;&lt;td&gt;Delta only &lt;/td&gt;&lt;td&gt;Delta with row tracking, Iceberg v3 &lt;/td&gt;&lt;td&gt;Whatever the connector exposes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Read API &lt;/td&gt;&lt;td&gt;&lt;code&gt;readChangeFeed&lt;/code&gt;, &lt;code&gt;table_changes()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Same APIs &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT ... CHANGES FROM VERSION&lt;/code&gt;, &lt;code&gt;.changes()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;FACT (Databricks docs): legacy CDF &quot;materializes changes during table writes&quot;, Auto CDF &quot;computes changes during table reads using row lineage metadata&quot;, and the two cannot be used together on one table. Migration is a single statement: &lt;code&gt;ALTER TABLE t UNSET TBLPROPERTIES ('delta.enableChangeDataFeed')&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-mechanism-in-two-paragraphs&quot;&gt;The mechanism, in two paragraphs&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Legacy CDF.&lt;/strong&gt; The Delta protocol has a &lt;code&gt;cdc&lt;/code&gt; action. On &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; and &lt;code&gt;MERGE&lt;/code&gt; the writer puts the changed rows, with &lt;code&gt;_change_type&lt;/code&gt;, into files under &lt;code&gt;_change_data/&lt;/code&gt;. The protocol tells readers to use those files when they exist and skip the &lt;code&gt;add&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; actions for that version. Insert-only commits and full partition deletes produce no change files, because the log already says what happened. So the reader of a commit touches roughly the changed rows, nothing more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auto CDF.&lt;/strong&gt; Row tracking gives each row a stable row ID and a row commit version. The Delta protocol explains how: for inserts, the IDs are derived from a &lt;code&gt;baseRowId&lt;/code&gt; per file plus the row position, which costs almost nothing. But when a writer copies a row into a new file (the &quot;copy&quot; in copy-on-write), it must preserve the stable ID, so it &lt;em&gt;materializes&lt;/em&gt; the row ID and commit version into hidden columns in the data file. At read time, the engine compares row IDs and commit versions across the files a commit removed and added to work out which rows were inserted, updated or deleted. That is real computation over real files, and it happens on every read.&lt;/p&gt;
&lt;h3 id=&quot;what-i-measured&quot;&gt;What I measured&lt;/h3&gt;
&lt;p&gt;I cannot run Databricks' Auto CDF outside Databricks, and I am not going to pretend otherwise. What I can run is open-source Delta Lake 4.4.1 on Spark 4.2.0, which has both halves of the mechanism: legacy CDF, and row tracking with &lt;code&gt;_metadata.row_id&lt;/code&gt; and &lt;code&gt;_metadata.row_commit_version&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 10M-row Delta table (about 507 MB of Parquet), deletion vectors off, so &lt;code&gt;MERGE&lt;/code&gt; is pure copy-on-write.&lt;/li&gt;
&lt;li&gt;Each round: a &lt;code&gt;MERGE&lt;/code&gt; that updates about 100,000 rows (1%, keys hashed across the whole id range) and inserts 10,000 new ones. 5 rounds per variant, medians reported, after a discarded warm-up.&lt;/li&gt;
&lt;li&gt;Three variants: plain, legacy CDF, row tracking only.&lt;/li&gt;
&lt;li&gt;Read side: read the changes of the last commit. For legacy CDF that is &lt;code&gt;readChangeFeed&lt;/code&gt; for one version. For row tracking it is a reconstruction I wrote: read the files the commit removed (at version v-1) and added (at version v), and join on &lt;code&gt;row_id&lt;/code&gt; and &lt;code&gt;row_commit_version&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I checked the reconstruction against legacy CDF on a table with both features enabled: for a &lt;code&gt;MERGE&lt;/code&gt; (200 inserts, 1,986 pre-images, 1,986 post-images) and for a &lt;code&gt;DELETE&lt;/code&gt; (100 deletes) the counts matched exactly.&lt;/p&gt;
&lt;p&gt;The core of the reconstruction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;after = (spark.read.format(&quot;delta&quot;).option(&quot;versionAsOf&quot;, v).load(path)
         .select(&quot;*&quot;, F.col(&quot;_metadata.row_id&quot;).alias(&quot;rid&quot;),
                 F.col(&quot;_metadata.row_commit_version&quot;).alias(&quot;rcv&quot;),
                 F.col(&quot;_metadata.file_path&quot;).alias(&quot;fp&quot;))
         .where(F.col(&quot;fp&quot;).isin(added_files)))
changed = after.where(F.col(&quot;rcv&quot;) == v)          # inserts + post-images
# pre-images: rows in removed files whose rid appears in `changed`
# deletes:    rows in removed files whose rid appears nowhere in `after`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Results, 10M rows, scattered 1% upsert, median of 5 rounds:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Variant &lt;/th&gt;&lt;th&gt;MERGE time &lt;/th&gt;&lt;th&gt;Bytes written per MERGE &lt;/th&gt;&lt;th&gt;Change files per MERGE &lt;/th&gt;&lt;th&gt;Read one commit's changes&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Plain &lt;/td&gt;&lt;td&gt;6.03 s (4.75 to 7.69) &lt;/td&gt;&lt;td&gt;505.8 MB &lt;/td&gt;&lt;td&gt;none &lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Legacy CDF &lt;/td&gt;&lt;td&gt;6.52 s (6.19 to 7.10) &lt;/td&gt;&lt;td&gt;506.0 MB &lt;/td&gt;&lt;td&gt;10.8 MB &lt;/td&gt;&lt;td&gt;0.21 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Row tracking only &lt;/td&gt;&lt;td&gt;6.16 s (5.92 to 7.69) &lt;/td&gt;&lt;td&gt;547.6 MB &lt;/td&gt;&lt;td&gt;none &lt;/td&gt;&lt;td&gt;4.73 s (my reconstruction)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A second, partial run of the plain and legacy CDF variants gave 5.64 s and 6.89 s for the &lt;code&gt;MERGE&lt;/code&gt;, and 0.18 s for the CDF read.&lt;/p&gt;
&lt;h4 id=&quot;what-the-numbers-say-and-what-they-do-not&quot;&gt;What the numbers say, and what they do not&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;1. The write tax of legacy CDF is real but small here, and smaller than my noise.&lt;/strong&gt; Legacy CDF was slower in both runs (6.52 vs 6.03 s, and 6.89 vs 5.64 s), but the spread inside one variant (4.75 to 7.69 s) is wider than the gap. Databricks measured about 15% on its runtime; my numbers are compatible with that and cannot confirm it. The byte count is solid: 10.8 MB of change files per &lt;code&gt;MERGE&lt;/code&gt;, about 2% of what the &lt;code&gt;MERGE&lt;/code&gt; rewrote anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Row tracking wrote 8% more data on every rewrite.&lt;/strong&gt; 547.6 MB versus 505.8 MB per &lt;code&gt;MERGE&lt;/code&gt;, because every copied row now carries its stable row ID and commit version in hidden columns. That is about four times the size of the CDF change files in this workload. This is the part of the &quot;free&quot; read-time CDF that you pay at write time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Reading changes at read time costs what the commit rewrote, not what it changed.&lt;/strong&gt; The 1% scattered upsert touched every file, so the commit removed and re-added the whole table. Legacy CDF read 10.8 MB of change files in 0.21 s. My reconstruction had to scan both sides of a 500 MB rewrite to find 100,000 changed rows: 4.73 s, about 22 times slower. On a smaller test (200,000 rows, 8 files) the same scattered upsert rewrote 8 of 8 files, while an upsert clustered on the newest ids rewrote 1 of 8. The layout of your updates decides how expensive read-time CDF is.&lt;/p&gt;
&lt;p&gt;What I cannot claim: that Databricks' implementation is as slow as mine. It is very likely smarter, and with deletion vectors a &lt;code&gt;MERGE&lt;/code&gt; writes only the changed rows to new files and marks the old ones, which shrinks the set of rows a reader has to compare. My test is the copy-on-write case, which is the worst case for read-time CDF. The mechanism still holds: the read-time cost scales with what a commit touched, and you pay it once per reader per read.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;CHANGES&lt;/code&gt; on open-source Delta 4.4.1 fails by default.&lt;/strong&gt; With the stock &lt;code&gt;DeltaCatalog&lt;/code&gt;, all three of &lt;code&gt;SELECT * FROM t CHANGES FROM VERSION 1&lt;/code&gt;, the path-based form, and &lt;code&gt;spark.read.changes(&quot;t&quot;)&lt;/code&gt; returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[UNSUPPORTED_FEATURE.CHANGE_DATA_CAPTURE] The feature is not supported:
Catalog spark_catalog does not support Change Data Capture (CDC). SQLSTATE: 0A000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That matches the source. Delta PR #6794 (merged May 2026) adds catalog-driven read-time CDF behind an internal flag that is off by default, requires row tracking, and leaves the streaming side of the new path as a follow-up. I did not test the flag; it is internal and I would not run production on it. Spark 4.2 defines the shape of change data. Your connector decides whether you get any.&lt;/p&gt;
&lt;h3 id=&quot;gotchas-to-check-before-you-run-unset&quot;&gt;Gotchas to check before you run UNSET&lt;/h3&gt;
&lt;p&gt;All FACT, from the Databricks and Spark docs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto CDF is not supported on tables with row filters or column masks.&lt;/strong&gt; If your sensitive tables are governed with ABAC or masks, they are exactly the ones that cannot move.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only Databricks readers can query Auto CDF on Delta&lt;/strong&gt;, and external Iceberg clients cannot query it on Iceberg v3. Legacy CDF lives in the open Delta protocol. If anything outside Databricks reads your change feed, check before migrating.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs Databricks Runtime 19 or above&lt;/strong&gt; and a table registered in Unity Catalog with row tracking enabled (or Iceberg v3).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enabling row tracking on an existing table is not free.&lt;/strong&gt; It assigns IDs to every existing row, can create several new table versions, and concurrent writers fail with &lt;code&gt;MetadataChangedException&lt;/code&gt; while it runs. Pause streaming writers first. It also upgrades the writer protocol, so older external Delta clients that do not support those features can no longer write to the table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-statement transactions:&lt;/strong&gt; Auto CDF is not supported if the source table was modified during the transaction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;_metadata.row_id&lt;/code&gt; and &lt;code&gt;row_commit_version&lt;/code&gt; cannot be read while reading the change feed.&lt;/strong&gt; If your downstream logic wants both, plan for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spark 4.2 streaming &lt;code&gt;netChanges&lt;/code&gt; holds back the latest commit.&lt;/strong&gt; Rows touched only in the latest commit are not emitted until a later commit moves the watermark, and streaming net changes can differ from batch net changes over the same range. Fine for a pipeline, surprising for a low-latency consumer.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;decision-table&quot;&gt;Decision table&lt;/h3&gt;
&lt;p&gt;OPINION, built on the mechanism and my numbers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Your table looks like &lt;/th&gt;&lt;th&gt;Better choice &lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Write-heavy, changes read rarely or by one pipeline &lt;/td&gt;&lt;td&gt;Auto CDF &lt;/td&gt;&lt;td&gt;You stop paying on every write for a feed few people read&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;One commit read by many consumers, often &lt;/td&gt;&lt;td&gt;Legacy CDF, or Auto CDF plus one stream that lands changes in a table &lt;/td&gt;&lt;td&gt;Read-time cost multiplies by readers times reads&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Scattered updates on a copy-on-write table &lt;/td&gt;&lt;td&gt;Legacy CDF, or enable deletion vectors and measure &lt;/td&gt;&lt;td&gt;Each commit rewrites most files, so read-time diffing is expensive&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Row filters or column masks &lt;/td&gt;&lt;td&gt;Legacy CDF &lt;/td&gt;&lt;td&gt;Auto CDF is not supported there&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Change feed read outside Databricks &lt;/td&gt;&lt;td&gt;Legacy CDF &lt;/td&gt;&lt;td&gt;Auto CDF is Databricks-readers only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Iceberg v3 tables on Databricks &lt;/td&gt;&lt;td&gt;Auto CDF &lt;/td&gt;&lt;td&gt;Legacy CDF does not support Iceberg&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;what-i-would-do-this-week&quot;&gt;What I would do this week&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Inventory who reads each change feed.&lt;/strong&gt; Count consumers and their trigger intervals per table. That count is the multiplier on read-time cost, and nobody has it written down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Look at the update layout.&lt;/strong&gt; In table history, compare &lt;code&gt;numTargetRowsUpdated&lt;/code&gt; against files rewritten for your &lt;code&gt;MERGE&lt;/code&gt; operations. A 1% update that rewrites 100% of files is a bad candidate for read-time CDF.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate the boring tables first:&lt;/strong&gt; write-heavy, one reader, no masks, nothing outside Databricks. Keep legacy CDF on the hot, many-reader tables until you have measured both on your own runtime.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you fan out, fan out once.&lt;/strong&gt; One stream reads the change feed and writes a changes table; the other consumers read that. The Databricks docs already show this archive pattern for permanent history.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Auto CDF is a good default for most tables, and a quiet bill for the ones read by five pipelines. CDC is still a cost. The release moved it to the reader, and the bill now goes up with every pipeline that reads the feed.&lt;/p&gt;
&lt;h3 id=&quot;hardware-amp-versions&quot;&gt;Hardware &amp;amp; versions&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;Box: 8 vCPU Intel Xeon VM, 16 GB RAM, Linux 6.12, OpenJDK 21.0.12.&lt;br&gt;
PySpark 4.2.0, delta-spark 4.4.1 (&lt;code&gt;delta-spark_4.2_2.13&lt;/code&gt;), &lt;code&gt;local[8]&lt;/code&gt;, driver memory 8 GB (5 GB in the partial second run), 16 shuffle partitions.&lt;br&gt;
10M rows, 5 columns, about 507 MB Parquet, deletion vectors off. 5 rounds per variant, medians, warm-up discarded.&lt;br&gt;
Same box also hosts another long-running benchmark of mine. During the main run its runner was paused waiting for an idle host, but its services stayed in memory. The partial second run overlapped that benchmark's active phase, so I quote it only as a cross-check. Treat timings as indicative. The byte counts and the correctness check are deterministic.&lt;br&gt;
Measured October 26, 2026. Script, raw logs and pinned versions: &lt;a rel=&quot;nofollow&quot; href=&quot;https://gist.github.com/firfircelik/3ceab1b9b73b422293c0bdc76a690edb&quot;&gt;cdf_bench gist&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Databricks September 2026 release notes, &quot;Automatic change data feed is now generally available&quot; (Sep 1, 2026): &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/release-notes/product/2026/september&quot;&gt;https://docs.databricks.com/aws/en/release-notes/product/2026/september&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Databricks docs, Use change data feed (Auto CDF requirements, migration, limitations): &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/delta/delta-change-data-feed&quot;&gt;https://docs.databricks.com/aws/en/delta/delta-change-data-feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Azure Databricks docs, same page: &lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/azure/databricks/delta/delta-change-data-feed&quot;&gt;https://learn.microsoft.com/en-us/azure/databricks/delta/delta-change-data-feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Databricks docs, Row tracking: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/delta/row-tracking&quot;&gt;https://docs.databricks.com/aws/en/delta/row-tracking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Delta Lake protocol v4.4.1 (Add CDC File, Row Tracking, materialized Row IDs): &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/delta-io/delta/blob/v4.4.1/PROTOCOL.md&quot;&gt;https://github.com/delta-io/delta/blob/v4.4.1/PROTOCOL.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Delta Lake docs, Change data feed: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.delta.io/delta-change-data-feed/&quot;&gt;https://docs.delta.io/delta-change-data-feed/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Delta Lake docs, Row tracking: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.delta.io/delta-row-tracking/&quot;&gt;https://docs.delta.io/delta-row-tracking/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Delta Lake 4.4.0 release (Spark 4.2 support): &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/delta-io/delta/releases/tag/v4.4.0&quot;&gt;https://github.com/delta-io/delta/releases/tag/v4.4.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Delta PR #6794, Add Changelog support: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/delta-io/delta/pull/6794&quot;&gt;https://github.com/delta-io/delta/pull/6794&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Apache Spark 4.2.0 release notes (Change Data Capture, SPARK-55668): &lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/releases/spark-release-4-2-0.html&quot;&gt;https://spark.apache.org/releases/spark-release-4-2-0.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Apache Spark news, Spark 4.2.0 released (July 14, 2026): &lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/news/&quot;&gt;https://spark.apache.org/news/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spark PR, SPARK-55948 DSv2 CDC connector API and SQL CHANGES clause: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/apache/spark/pull/54738&quot;&gt;https://github.com/apache/spark/pull/54738&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spark 4.2.0 API docs, &lt;code&gt;Changelog&lt;/code&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/docs/4.2.0/api/scala/org/apache/spark/sql/connector/catalog/Changelog.html&quot;&gt;https://spark.apache.org/docs/4.2.0/api/scala/org/apache/spark/sql/connector/catalog/Changelog.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spark 4.2.0 API docs, &lt;code&gt;DataStreamReader.changes&lt;/code&gt; (streaming netChanges behavior): &lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/docs/4.2.0/api/scala/org/apache/spark/sql/streaming/DataStreamReader.html&quot;&gt;https://spark.apache.org/docs/4.2.0/api/scala/org/apache/spark/sql/streaming/DataStreamReader.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;My measurements: OSS Delta 4.4.1 on Spark 4.2.0, October 26, 2026 (setup above).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I post shorter breakdowns daily on LinkedIn.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32478/auto-cdf-moved-the-cdc-tax-from-writes-to-reads-delta-cdf-vs-auto-cdf-vs-spark-4-2-changes</guid>
<pubDate>Mon, 26 Oct 2026 07:12:17 +0000</pubDate>
</item>
<item>
<title>Two PySpark contracts Staff DEs learn on a bad Tuesday: foreachBatch restarts and applyInPandas skew</title>
<link>https://coderlegion.com/30476/two-pyspark-contracts-staff-des-learn-tuesday-foreachbatch-restarts-applyinpandas-skew</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/two-pyspark-contracts-staff-des-learn-on-a-bad-tuesday-foreachbatch-restarts-and-applyinpandas-skew-39pa&quot;&gt;DEV.to&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Your streaming MERGE looked fine in the notebook. Your &lt;code&gt;applyInPandas&lt;/code&gt; transform looked elegant. Then a job restarted and silver row counts jumped, or a hot customer key OOMed a worker while the rest of the cluster sat idle.&lt;/p&gt;
&lt;p&gt;Neither bug is exotic. Both are documented contracts that happy-path snippets skip.&lt;/p&gt;
&lt;p&gt;This article covers two production surfaces Staff and Lead data engineers own on Databricks / PySpark:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Streaming upserts via &lt;code&gt;foreachBatch&lt;/code&gt;&lt;/strong&gt;: at-least-once writes, watermark dedupe state, Delta &lt;code&gt;MERGE&lt;/code&gt; idempotency (logical, not &lt;code&gt;txnAppId&lt;/code&gt;), empty batches, and why you must fully consume a batch DataFrame.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pandas Function APIs&lt;/strong&gt;: &lt;code&gt;groupBy().applyInPandas()&lt;/code&gt; loads an entire group into memory and does &lt;strong&gt;not&lt;/strong&gt; honor &lt;code&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/code&gt;; &lt;code&gt;mapInPandas&lt;/code&gt; / &lt;code&gt;mapInArrow&lt;/code&gt; do.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I will mark &lt;strong&gt;FACT&lt;/strong&gt; (from primary docs) vs &lt;strong&gt;interpretation&lt;/strong&gt; (engineering judgment). No invented latency percentages. No &quot;exactly-once foreachBatch&quot; fairy tales.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Primary sources:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/foreach&quot;&gt;Databricks foreachBatch&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.delta.io/latest/delta-update.html&quot;&gt;Delta MERGE / streaming upsert&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/watermarks&quot;&gt;watermarks&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/read-state&quot;&gt;state store reader&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/delta-lake&quot;&gt;idempotent Delta writes&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/pandas/pandas-function-apis&quot;&gt;pandas function APIs&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html&quot;&gt;Apache Arrow in PySpark&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-1-design-for-the-restart-foreachbatch-watermarks-and-merge&quot;&gt;Part 1. Design for the restart: &lt;code&gt;foreachBatch&lt;/code&gt;, watermarks, and MERGE&lt;/h3&gt;
&lt;h4 id=&quot;contract-1-foreachbatch-is-at-least-once&quot;&gt;Contract 1: &lt;code&gt;foreachBatch&lt;/code&gt; is at-least-once&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks):&lt;/strong&gt; &lt;code&gt;foreachBatch()&lt;/code&gt; provides only at-least-once write guarantees. You can use the &lt;code&gt;batchId&lt;/code&gt; to deduplicate and reason toward exactly-once yourself. You still own end-to-end semantics.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Interpretation:&lt;/strong&gt; A green micro-batch in the UI is not a guarantee that a retry will be a no-op. Treat every handler as if Spark will call it again with the same &lt;code&gt;batch_id&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;APP_ID = &quot;silver-orders-upsert-v1&quot;  # change if you reset the checkpoint


def process_batch(batch_df, batch_id: int) -&amp;gt; None:
    # FACT: Delta sources can deliver empty micro-batches (e.g. OPTIMIZE with
    # nothing to process, or predicate pruning). Code must tolerate them.
    if batch_df.isEmpty():
        return

    valid = batch_df.filter(&quot;order_amount &amp;gt; 0 AND customer_id IS NOT NULL&quot;)
    invalid = batch_df.filter(
        &quot;NOT (order_amount &amp;gt; 0 AND customer_id IS NOT NULL)&quot;
    )

    # FACT: txnAppId + txnVersion on DataFrameWriter make appends idempotent
    # for retries of the same batch_id. Binding txnVersion to batch_id is the
    # documented pattern.
    (
        valid.write.format(&quot;delta&quot;)
        .mode(&quot;append&quot;)
        .option(&quot;txnAppId&quot;, APP_ID)
        .option(&quot;txnVersion&quot;, batch_id)
        .saveAsTable(&quot;main.silver.orders&quot;)
    )

    if not invalid.isEmpty():
        (
            invalid.write.format(&quot;delta&quot;)
            .mode(&quot;append&quot;)
            .option(&quot;txnAppId&quot;, APP_ID)
            .option(&quot;txnVersion&quot;, batch_id)
            .saveAsTable(&quot;main.silver.orders_dlq&quot;)
        )


(
    spark.readStream.table(&quot;main.bronze.raw_orders&quot;)
    .writeStream.foreachBatch(process_batch)
    .option(&quot;checkpointLocation&quot;, &quot;/Volumes/main/ops/checkpoints/orders&quot;)
    .start()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks Delta streaming):&lt;/strong&gt; If you delete the streaming checkpoint and restart with a new checkpoint, you must provide a &lt;strong&gt;different&lt;/strong&gt; &lt;code&gt;txnAppId&lt;/code&gt;. New checkpoints start at batch ID &lt;code&gt;0&lt;/code&gt;; reusing the old app id can make Delta skip writes you intended to redo.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks):&lt;/strong&gt; Prefer letting the query fail and letting Lakeflow Jobs / Airflow retry the batch, rather than building silent local retry loops that can duplicate or drop data.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;contract-2-watermark-dedupe-has-a-state-lease-and-you-must-finish-the-batch-&quot;&gt;Contract 2: watermark dedupe has a state lease (and you must finish the batch)&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks watermarks):&lt;/strong&gt; &lt;code&gt;dropDuplicatesWithinWatermark&lt;/code&gt; always deduplicates events that arrive within the watermark delay. Events outside the delay &lt;strong&gt;might&lt;/strong&gt; still be deduped, but that is &lt;strong&gt;not guaranteed&lt;/strong&gt;. To guarantee dropping all duplicates, set the watermark delay greater than the maximum timestamp distance between duplicate events.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks foreachBatch):&lt;/strong&gt; When you use stateful operators such as &lt;code&gt;dropDuplicatesWithinWatermark&lt;/code&gt;, each &lt;code&gt;foreachBatch&lt;/code&gt; iteration must &lt;strong&gt;completely consume&lt;/strong&gt; the batch DataFrame. If you only peek (&lt;code&gt;show(2)&lt;/code&gt;, take a few rows) and leave rows unread, the streaming query can fail on the next batch.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from pyspark.sql.functions import col

deduped = (
    spark.readStream.table(&quot;main.bronze.events&quot;)
    .withWatermark(&quot;event_time&quot;, &quot;10 hours&quot;)  # &amp;gt; max duplicate lag (FACT guidance)
    .dropDuplicatesWithinWatermark([&quot;event_id&quot;])
)


def bad_handler(batch_df, batch_id: int) -&amp;gt; None:
    # Anti-pattern: partial consume after a stateful operator
    batch_df.show(2)
    # remaining rows never consumed -&amp;gt; next micro-batch can fail


def good_handler(batch_df, batch_id: int) -&amp;gt; None:
    if batch_df.isEmpty():
        return
    # Full action that consumes the plan (write, count, foreach, etc.)
    (
        batch_df.write.format(&quot;delta&quot;)
        .mode(&quot;append&quot;)
        .option(&quot;txnAppId&quot;, &quot;events-dedupe-v1&quot;)
        .option(&quot;txnVersion&quot;, batch_id)
        .saveAsTable(&quot;main.silver.events&quot;)
    )


(
    deduped.writeStream.foreachBatch(good_handler)
    .option(&quot;checkpointLocation&quot;, &quot;/Volumes/main/ops/checkpoints/events&quot;)
    .start()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Debugging tip (FACT):&lt;/strong&gt; You can inspect Structured Streaming state with the &lt;code&gt;statestore&lt;/code&gt; reader. Metadata can show an operator named &lt;code&gt;dedupeWithinWatermark&lt;/code&gt;; query that &lt;code&gt;operatorId&lt;/code&gt; when you need to see what the lease still holds. See &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/read-state&quot;&gt;Read Structured Streaming state information&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;contract-3-merge-inside-foreachbatch-is-a-logical-idempotency-problem&quot;&gt;Contract 3: MERGE inside &lt;code&gt;foreachBatch&lt;/code&gt; is a &lt;em&gt;logical&lt;/em&gt; idempotency problem&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;FACT (Delta Lake):&lt;/strong&gt; You must use &lt;code&gt;foreachBatch&lt;/code&gt; for Delta Lake merge operations from Structured Streaming. Restarts can apply the same batch multiple times, so &lt;strong&gt;your merge statement inside &lt;code&gt;foreachBatch&lt;/code&gt; must be idempotent&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks / Delta writer options):&lt;/strong&gt; &lt;code&gt;txnAppId&lt;/code&gt; and &lt;code&gt;txnVersion&lt;/code&gt; are &lt;code&gt;DataFrameWriter&lt;/code&gt; options for idempotent &lt;strong&gt;writes&lt;/strong&gt; (append / save). Do &lt;strong&gt;not&lt;/strong&gt; assume they wrap &lt;code&gt;DeltaTable.merge(...)&lt;/code&gt;. Design MERGE so re-running the same micro-batch yields the same table state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FACT (Delta):&lt;/strong&gt; A merge can fail if multiple source rows match the same target row (ambiguous update). For CDC, preprocess to keep the &lt;strong&gt;latest change per key&lt;/strong&gt; before merging.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FACT (Delta):&lt;/strong&gt; MERGE often reads the source multiple times. That can inflate reported streaming input rates. Cache the micro-batch DataFrame before merge and unpersist after if this becomes a bottleneck.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from delta.tables import DeltaTable
from pyspark.sql import functions as F

target = DeltaTable.forName(spark, &quot;main.silver.customers&quot;)


def upsert_cdc(batch_df, batch_id: int) -&amp;gt; None:
    if batch_df.isEmpty():
        return

    # Collapse to latest change per business key (FACT: avoid multi-match MERGE)
    latest = (
        batch_df.select(
            &quot;customer_id&quot;,
            F.struct(&quot;event_time&quot;, &quot;payload&quot;, &quot;deleted&quot;).alias(&quot;chg&quot;),
        )
        .groupBy(&quot;customer_id&quot;)
        .agg(F.max(&quot;chg&quot;).alias(&quot;chg&quot;))
        .select(&quot;customer_id&quot;, &quot;chg.*&quot;)
    )

    # FACT: MERGE may scan the source more than once -&amp;gt; cache the batch
    latest = latest.cache()
    try:
        (
            target.alias(&quot;t&quot;)
            .merge(latest.alias(&quot;s&quot;), &quot;t.customer_id = s.customer_id&quot;)
            .whenMatchedDelete(condition=&quot;s.deleted = true&quot;)
            .whenMatchedUpdate(
                set={&quot;payload&quot;: &quot;s.payload&quot;, &quot;updated_at&quot;: &quot;s.event_time&quot;}
            )
            .whenNotMatchedInsert(
                condition=&quot;s.deleted = false&quot;,
                values={
                    &quot;customer_id&quot;: &quot;s.customer_id&quot;,
                    &quot;payload&quot;: &quot;s.payload&quot;,
                    &quot;updated_at&quot;: &quot;s.event_time&quot;,
                },
            )
            .execute()
        )
    finally:
        latest.unpersist()


(
    spark.readStream.table(&quot;main.bronze.customer_cdc&quot;)
    .writeStream.foreachBatch(upsert_cdc)
    .option(&quot;checkpointLocation&quot;, &quot;/Volumes/main/ops/checkpoints/customer_cdc&quot;)
    .start()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Interpretation:&lt;/strong&gt; &quot;Idempotent MERGE&quot; here means latest-wins on a stable key (and deletes keyed the same way), not &quot;Spark will skip the merge via txn metadata.&quot; If the same CDC events replay, the table should converge to the same rows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Managed alternative (context, not a product pitch):&lt;/strong&gt; Databricks processing-guarantees docs note that managed Delta-to-Delta flows and AUTO CDC can give stronger defaults; a custom &lt;code&gt;foreachBatch&lt;/code&gt; edge is still at-least-once unless you add explicit idempotent write or dedupe logic. Use AUTO CDC when it fits; when you hand-roll MERGE, you own the restart contract above.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;part-1-checklist-print-this-next-to-the-pr-&quot;&gt;Part 1 checklist (print this next to the PR)&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Checkpoint &lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Empty-batch safe &lt;/td&gt;&lt;td&gt;Delta sources can emit empty micro-batches&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Full consume after stateful ops &lt;/td&gt;&lt;td&gt;Partial &lt;code&gt;show&lt;/code&gt;/&lt;code&gt;take&lt;/code&gt; can fail the next batch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Watermark &amp;gt; max duplicate lag &lt;/td&gt;&lt;td&gt;Out-of-watermark dedupe is not guaranteed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Append path uses &lt;code&gt;txnAppId&lt;/code&gt; + &lt;code&gt;txnVersion=batch_id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Writer-level idempotency on retry&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New checkpoint =&amp;gt; new &lt;code&gt;txnAppId&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Else batch 0 collisions skip writes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MERGE is latest-per-key / safe to re-run &lt;/td&gt;&lt;td&gt;txn options do not wrap &lt;code&gt;DeltaTable.merge&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache around MERGE when metrics / cost hurt &lt;/td&gt;&lt;td&gt;MERGE multi-pass over the micro-batch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fail fast to the orchestrator &lt;/td&gt;&lt;td&gt;Local retry loops are how you invent exactly-once bugs&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-2-arrow-is-not-a-free-pass-applyinpandas-vs-mapinpandas-mapinarrow&quot;&gt;Part 2. Arrow is not a free pass: &lt;code&gt;applyInPandas&lt;/code&gt; vs &lt;code&gt;mapInPandas&lt;/code&gt; / &lt;code&gt;mapInArrow&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Python DE craft is not only streaming. The other Tuesday failure mode is &quot;we used pandas because Arrow is fast&quot; and then a skewed group killed the executor.&lt;/p&gt;
&lt;h4 id=&quot;three-apis-three-memory-contracts&quot;&gt;Three APIs, three memory contracts&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;FACT (Databricks pandas function APIs / Apache Spark):&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;API &lt;/th&gt;&lt;th&gt;What you write &lt;/th&gt;&lt;th&gt;Memory / batching contract&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;groupBy().applyInPandas(f, schema)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;One pandas DataFrame &lt;strong&gt;per group&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;All rows for the group load into memory&lt;/strong&gt; before &lt;code&gt;f&lt;/code&gt; runs. &lt;code&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; applied. Skew =&amp;gt; OOM risk.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;mapInPandas(f, schema)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Iterator of pandas DataFrames (batches) &lt;/td&gt;&lt;td&gt;Batch size controlled by &lt;code&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/code&gt; (default &lt;strong&gt;10,000&lt;/strong&gt; rows in Spark docs). Output length can differ from input.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;mapInArrow(f, schema)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Iterator of &lt;code&gt;pyarrow.RecordBatch&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Same Arrow batch sizing idea; stay in Arrow without pandas round-trip when you can.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Cogrouped &lt;code&gt;applyInPandas&lt;/code&gt; has the same whole-cogroup-in-memory warning.&lt;/p&gt;
&lt;h4 id=&quot;skew-turns-elegant-into-oom&quot;&gt;Skew turns &quot;elegant&quot; into OOM&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Looks clean. Under a hot customer_id it is a bomb.
def per_customer_features(pdf):
    # pdf = ALL rows for one customer_id
    pdf = pdf.sort_values(&quot;event_time&quot;)
    pdf[&quot;rolling_amt&quot;] = pdf[&quot;amount&quot;].rolling(50, min_periods=1).sum()
    return pdf


# FACT: entire group must fit in worker memory; maxRecordsPerBatch does NOT help
out = (
    events.groupBy(&quot;customer_id&quot;)
    .applyInPandas(
        per_customer_features,
        schema=&quot;customer_id string, event_time timestamp, amount double, rolling_amt double&quot;,
    )
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Interpretation:&lt;/strong&gt; Use &lt;code&gt;applyInPandas&lt;/code&gt; when the group is a true unit of work &lt;strong&gt;and&lt;/strong&gt; you can bound group size (filter, sample hot keys, pre-aggregate, salt, or split whales). Do not use it as a generic &quot;run pandas on Spark&quot; hammer.&lt;/p&gt;
&lt;h4 id=&quot;prefer-batch-maps-when-you-do-not-need-whole-group-semantics&quot;&gt;Prefer batch maps when you do not need whole-group semantics&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;spark.conf.set(&quot;spark.sql.execution.arrow.maxRecordsPerBatch&quot;, &quot;5000&quot;)


def score_batches(iterator):
    # init once per Python worker task
    model = load_light_model()  # your code; keep it picklable / available on workers
    for pdf in iterator:
        pdf = pdf.copy()
        pdf[&quot;score&quot;] = model.predict(pdf[[&quot;f1&quot;, &quot;f2&quot;]])
        yield pdf[[&quot;id&quot;, &quot;score&quot;]]


scored = events.mapInPandas(
    score_batches,
    schema=&quot;id long, score double&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or stay in Arrow when pandas is unnecessary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pyarrow as pa
import pyarrow.compute as pc


def filter_arrow(iterator):
    for batch in iterator:
        mask = pc.greater(batch.column(&quot;amount&quot;), 0)
        yield batch.filter(mask)


positive = events.mapInArrow(filter_arrow, schema=events.schema)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;decision-table-1&quot;&gt;Decision table&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Prefer &lt;/th&gt;&lt;th&gt;Watch out&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Need whole-group window / asof / custom group ML &lt;/td&gt;&lt;td&gt;&lt;code&gt;applyInPandas&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Hot keys; no Arrow batch limit; cogroup same issue&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Row/batch transform, arbitrary output length &lt;/td&gt;&lt;td&gt;&lt;code&gt;mapInPandas&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Tune &lt;code&gt;maxRecordsPerBatch&lt;/code&gt; down if wide rows&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CPU vector ops, avoid pandas copies &lt;/td&gt;&lt;td&gt;&lt;code&gt;mapInArrow&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Arrow type coverage / compute familiarity&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Series-to-series column expression &lt;/td&gt;&lt;td&gt;classic &lt;code&gt;pandas_udf&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Output length must match input for many UDF shapes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;FACT (Spark Arrow guide):&lt;/strong&gt; Lower &lt;code&gt;maxRecordsPerBatch&lt;/code&gt; when column count is large; Arrow batches can pressure JVM memory during conversion.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-the-two-themes-connect&quot;&gt;How the two themes connect&lt;/h3&gt;
&lt;p&gt;Both failures are the same Staff DE mistake: &lt;strong&gt;assuming the platform's happy path equals your failure domain.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Streaming: you assumed merge + checkpoint = exactly-once. Docs say at-least-once + &lt;em&gt;you&lt;/em&gt; make MERGE idempotent; writer txn options are a different tool.&lt;/li&gt;
&lt;li&gt;Pandas APIs: you assumed &quot;Arrow optimized&quot; = bounded memory. Docs say group maps ignore the batch limit.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Own the contract in the PR template, not in the postmortem.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;optional-diagram-for-a-cover-or-inline-figure-&quot;&gt;Optional diagram (for a cover or inline figure)&lt;/h3&gt;
&lt;p&gt;Two panels:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Restart timeline:&lt;/strong&gt; micro-batch N fails mid-MERGE -&amp;gt; orchestrator retries batch N -&amp;gt; arrows labeled &quot;append: txn skip&quot; vs &quot;MERGE: must be latest-wins safe&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skew funnel:&lt;/strong&gt; many small &lt;code&gt;customer_id&lt;/code&gt; groups sail through &lt;code&gt;applyInPandas&lt;/code&gt;; one whale group expands to &quot;all rows in RAM&quot; with a red X; side path routes whales to salted / pre-aggregated / &lt;code&gt;mapInPandas&lt;/code&gt; path.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;references&quot;&gt;References&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/foreach&quot;&gt;Use foreachBatch to write to arbitrary data sinks (Databricks)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.delta.io/latest/delta-update.html&quot;&gt;Delta Lake table deletes, updates, and merges (streaming foreachBatch section)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/watermarks&quot;&gt;Apply watermarks / dropDuplicatesWithinWatermark (Databricks)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/read-state&quot;&gt;Read Structured Streaming state information&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/structured-streaming/delta-lake&quot;&gt;Delta Lake streaming reads and writes (idempotent foreachBatch writes)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.databricks.com/aws/en/pandas/pandas-function-apis&quot;&gt;pandas function APIs (Databricks)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html&quot;&gt;Apache Arrow in PySpark&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.mapInArrow.html&quot;&gt;mapInArrow API&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I'm Firat Celik, Lead Data Engineer. I write about production data platforms, PySpark/Delta, and the boring contracts that keep pipelines honest.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30476/two-pyspark-contracts-staff-des-learn-tuesday-foreachbatch-restarts-applyinpandas-skew</guid>
<pubDate>Fri, 09 Oct 2026 13:20:49 +0000</pubDate>
</item>
<item>
<title>Azure Synapse to Databricks: A Migration Playbook That Survives Production</title>
<link>https://coderlegion.com/30474/azure-synapse-to-databricks-a-migration-playbook-that-survives-production</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/azure-synapse-databricks-a-migration-playbook-that-survives-production-nge&quot;&gt;DEV.to&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;azure-synapse-databricks-a-migration-playbook-that-survives-production&quot;&gt;Azure Synapse → Databricks: A Migration Playbook That Survives Production&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A field-style guide for Lead / Senior Data Engineers moving Dedicated SQL Pools, pipelines, and BI off Synapse without burning trust, budget, or the on-call team.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;table-of-contents&quot;&gt;Table of contents&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#1-why-this-migration-is-harder-than-the-slide-deck&quot;&gt;Why this migration is harder than the slide deck&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#2-what-you-are-actually-migrating&quot;&gt;What you are actually migrating&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#3-program-shape-discovery--waves&quot;&gt;Program shape: discovery → waves&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#4-four-parallel-workstreams&quot;&gt;Four parallel workstreams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#5-data-movement-skip-the-jdbc-tax&quot;&gt;Data movement: skip the JDBC tax&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#6-code-conversion-syntax-is-easy-semantics-bite&quot;&gt;Code conversion: syntax is easy, semantics bite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#7-dimensional-patterns-on-delta-scd2&quot;&gt;Dimensional patterns on Delta (SCD2)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#8-reconciliation--cutover&quot;&gt;Reconciliation &amp;amp; cutover&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#9-power-bi--consumption-cutover&quot;&gt;Power BI / consumption cutover&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#10-30--60--90-plan&quot;&gt;30 / 60 / 90 plan&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#11-anti-patterns-i-keep-seeing&quot;&gt;Anti-patterns I keep seeing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#12-field-rules-i-will-not-compromise-on&quot;&gt;Field rules I will not compromise on&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#13-faq&quot;&gt;FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#14-sources&quot;&gt;Sources&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-why-this-migration-is-harder-than-the-slide-deck&quot;&gt;1) Why this migration is harder than the slide deck&lt;/h3&gt;
&lt;p&gt;If you have lived in &lt;strong&gt;Azure Synapse&lt;/strong&gt; for years, the marketing line sounds simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Move to Databricks. Unity Catalog. Lakehouse. Done.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In production programs, that is not the hard part.&lt;/p&gt;
&lt;p&gt;The hard part is that &lt;strong&gt;Synapse is not one system&lt;/strong&gt;. Under one brand you usually own years of Dedicated SQL Pool logic, ADF/Synapse Pipelines, serverless SQL over the lake, Spark pools, permission models, and Power BI datasets wired to Synapse endpoints.&lt;/p&gt;
&lt;p&gt;Treat that estate as &lt;strong&gt;one workstream with one deadline&lt;/strong&gt;, and the schedule slips. Every time.&lt;/p&gt;
&lt;p&gt;I work as a &lt;strong&gt;Lead Data Engineer&lt;/strong&gt; across Azure Synapse, Microsoft Fabric, Azure Data Lake, Databricks/Spark, SQL/T-SQL, and Power BI. This playbook is the version I want in the room &lt;em&gt;before&lt;/em&gt; the first converted stored procedure lands in production.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Thesis:&lt;/strong&gt; Migration success is not “all notebooks on Databricks.”&lt;br&gt;
It is &lt;strong&gt;decision-ready data, trusted KPIs, and a platform your team can operate&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-what-you-are-actually-migrating&quot;&gt;2) What you are actually migrating&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Synapse piece &lt;/th&gt;&lt;th&gt;Typical complexity &lt;/th&gt;&lt;th&gt;What “done” looks like on Databricks&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dedicated SQL Pools&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Highest — procs, distributions, CCI, years of tuning &lt;/td&gt;&lt;td&gt;Databricks SQL / Spark jobs, Delta tables, Liquid Clustering / predictive optimization&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Serverless SQL&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Medium — views &amp;amp; external tables over ADLS &lt;/td&gt;&lt;td&gt;Views / tables in Unity Catalog over the same lake paths (often simpler)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Spark pools&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Lower — already Spark-shaped &lt;/td&gt;&lt;td&gt;Databricks Runtime notebooks / jobs (often few changes)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;ADF / Synapse Pipelines&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;High — orchestration + connectors &lt;/td&gt;&lt;td&gt;Lakeflow Connect / Lakeflow Jobs / Workflows&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Governance&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;High — SQL perms + Purview stitching &lt;/td&gt;&lt;td&gt;Unity Catalog (and stop dual-governing forever)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Power BI / semantic models&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;High politically &lt;/td&gt;&lt;td&gt;Databricks SQL Warehouse endpoints + dataset remaps&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Dedicated SQL Pools usually consume most of the calendar. Orchestration and BI consume most of the &lt;strong&gt;organizational&lt;/strong&gt; risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-program-shape-discovery-waves&quot;&gt;3) Program shape: discovery → waves&lt;/h3&gt;
&lt;p&gt;Do not start with “transpile everything.” Start with a &lt;strong&gt;program&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[1 Discovery&amp;lt;br&amp;gt;Lakebridge Profiler] --&amp;gt; B[2 Assessment&amp;lt;br&amp;gt;Analyzer / T-SQL]
  B --&amp;gt; C[3 Design&amp;lt;br&amp;gt;Hybrid lift + modernize]
  C --&amp;gt; D[4 Pilot&amp;lt;br&amp;gt;Lighthouse E2E]
  D --&amp;gt; E[5 Waves&amp;lt;br&amp;gt;Parallel workstreams]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;phase-a-discovery&quot;&gt;Phase A — Discovery&lt;/h4&gt;
&lt;p&gt;Inventory what actually runs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;pools, databases, schemas, object counts&lt;/li&gt;
&lt;li&gt;top queries / heavy procs by CPU &amp;amp; IO&lt;/li&gt;
&lt;li&gt;pipeline inventory (ADF / Synapse)&lt;/li&gt;
&lt;li&gt;downstream BI datasets and owners&lt;/li&gt;
&lt;li&gt;unused objects (your cheapest scope cut)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Lakebridge Profiler&lt;/strong&gt; (Databricks Labs) is built for Synapse estate profiling — metadata, utilisation, query patterns, baselines for a TCO conversation.&lt;/p&gt;
&lt;h4 id=&quot;phase-b-assessment&quot;&gt;Phase B — Assessment&lt;/h4&gt;
&lt;p&gt;Classify the T-SQL estate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;low / medium / high complexity&lt;/li&gt;
&lt;li&gt;unsupported constructs&lt;/li&gt;
&lt;li&gt;dependency graphs (what breaks if this view moves)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Lakebridge Analyzer&lt;/strong&gt; helps here. Start waves with low/medium complexity and clear business owners — not the ugliest proc on day one.&lt;/p&gt;
&lt;h4 id=&quot;phase-c-design-usually-hybrid-&quot;&gt;Phase C — Design (usually hybrid)&lt;/h4&gt;
&lt;p&gt;Three strategies show up in every steering committee:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Approach &lt;/th&gt;&lt;th&gt;When it works &lt;/th&gt;&lt;th&gt;Failure mode&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Lift-and-shift&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Hard deadline off Synapse &lt;/td&gt;&lt;td&gt;You recreate Synapse-shaped pain on a new bill&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Big-bang rewrite&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Greenfield team, tiny estate &lt;/td&gt;&lt;td&gt;Timeline fantasy&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hybrid&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Most real enterprises &lt;/td&gt;&lt;td&gt;Needs discipline: automate bulk convert, modernize in waves&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For most Synapse migrations, &lt;strong&gt;hybrid&lt;/strong&gt; wins: automated conversion gets you off Synapse on a schedule; modernization (Delta patterns, Liquid Clustering, Lakeflow, UC) happens after workloads are stable.&lt;/p&gt;
&lt;h4 id=&quot;phase-d-pilot-lighthouse-&quot;&gt;Phase D — Pilot (lighthouse)&lt;/h4&gt;
&lt;p&gt;Pick &lt;strong&gt;one&lt;/strong&gt; end-to-end slice:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;source → ingest → transform → gold mart → Power BI&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Prove:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;storage + UC layout&lt;/li&gt;
&lt;li&gt;job standards&lt;/li&gt;
&lt;li&gt;reconciliation pack&lt;/li&gt;
&lt;li&gt;BI remap&lt;/li&gt;
&lt;li&gt;on-call runbook&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pilot’s job is not heroics. It is producing &lt;strong&gt;reusable assets&lt;/strong&gt; for later waves.&lt;/p&gt;
&lt;h4 id=&quot;phase-e-waves&quot;&gt;Phase E — Waves&lt;/h4&gt;
&lt;p&gt;Each wave should ship a &lt;strong&gt;visible business win&lt;/strong&gt;, not just “another schema moved.”&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-four-parallel-workstreams&quot;&gt;4) Four parallel workstreams&lt;/h3&gt;
&lt;p&gt;After the lighthouse, scale on &lt;strong&gt;four tracks together&lt;/strong&gt;. Sequential “ETL first, BI last” is how stakeholders only feel pain.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart TB
  P[Lighthouse Pilot]
  P --&amp;gt; I[Ingestion&amp;lt;br&amp;gt;ADF/Synapse Pipelines → Lakeflow Connect]
  P --&amp;gt; T[Transformation&amp;lt;br&amp;gt;T-SQL / procs → Databricks SQL / Spark]
  P --&amp;gt; O[Orchestration&amp;lt;br&amp;gt;Schedules → Lakeflow Jobs]
  P --&amp;gt; C[Consumption&amp;lt;br&amp;gt;Power BI → Databricks SQL Warehouse]
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Track &lt;/th&gt;&lt;th&gt;From &lt;/th&gt;&lt;th&gt;To&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Ingestion&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;ADF / Synapse Pipelines &lt;/td&gt;&lt;td&gt;Lakeflow Connect / Jobs (or open ingest into Delta)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Transformation&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;T-SQL / procs / views &lt;/td&gt;&lt;td&gt;Databricks SQL / Spark with clear contracts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Orchestration&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Synapse schedules &amp;amp; deps &lt;/td&gt;&lt;td&gt;Lakeflow Jobs / Workflows&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Consumption&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Power BI / semantic models &lt;/td&gt;&lt;td&gt;Databricks SQL Warehouses&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;sequencing-tip-that-builds-trust&quot;&gt;Sequencing tip that builds trust&lt;/h4&gt;
&lt;p&gt;A &lt;strong&gt;BI-aware&lt;/strong&gt; sequence often works better than pure ETL-first:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Land or expose business-facing marts early (even interim / federation patterns where appropriate).&lt;/li&gt;
&lt;li&gt;Put a trusted KPI dataset in front of users.&lt;/li&gt;
&lt;li&gt;Modernize the uglier ETL underneath while the business already sees progress.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Enablement is a fifth unofficial track. Synapse-native teams underinvest here and then call the platform “not ready.”&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-data-movement-skip-the-jdbc-tax&quot;&gt;5) Data movement: skip the JDBC tax&lt;/h3&gt;
&lt;p&gt;For large Dedicated SQL Pool tables, &lt;strong&gt;JDBC Synapse → Databricks&lt;/strong&gt; is how you burn cluster hours and patience.&lt;/p&gt;
&lt;p&gt;Prefer:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Synapse CETAS → Parquet on ADLS Gen2 → Databricks Spark read → Delta in Unity Catalog&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  S[Synapse Dedicated SQL Pool] --&amp;gt;|CETAS| L[ADLS Gen2 Parquet]
  L --&amp;gt;|Spark read / Delta write| D[Unity Catalog Delta]
  S -.-&amp;gt;|JDBC slow path ❌| D
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-this-path-wins&quot;&gt;Why this path wins&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Synapse writes columnar Parquet in parallel (CETAS).&lt;/li&gt;
&lt;li&gt;ADLS is the shared landing zone both engines already understand.&lt;/li&gt;
&lt;li&gt;Databricks reads Parquet with strong scan performance, then writes &lt;strong&gt;Delta&lt;/strong&gt; (ACID, time travel, UC governance).&lt;/li&gt;
&lt;li&gt;You avoid row-by-row JDBC serialization.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;example-cetas-from-synapse-dedicated-sql-pool&quot;&gt;Example — CETAS from Synapse Dedicated SQL Pool&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- File format
CREATE EXTERNAL FILE FORMAT ParquetFF
WITH (
    FORMAT_TYPE = PARQUET,
    DATA_COMPRESSION = 'org.apache.hadoop.io.compress.SnappyCodec'
);

-- Landing zone on ADLS Gen2
CREATE EXTERNAL DATA SOURCE MigLanding
WITH (
    LOCATION = 'https://&amp;lt;storage&amp;gt;.dfs.core.windows.net/&amp;lt;container&amp;gt;',
    TYPE = HADOOP
);

-- Export a bounded slice (always partition/filter on first runs)
CREATE EXTERNAL TABLE ext.fact_sales_export
WITH (
    LOCATION = '/migration/fact_sales/ds=2026-09-03/',
    DATA_SOURCE = MigLanding,
    FILE_FORMAT = ParquetFF
)
AS
SELECT
    sales_id,
    customer_nk,
    product_nk,
    business_date,
    net_amount,
    quantity
FROM dbo.fact_sales
WHERE business_date &amp;gt;= '2024-01-01';
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;example-land-as-delta-on-databricks&quot;&gt;Example — land as Delta on Databricks&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from pyspark.sql import functions as F

src = &quot;abfss://&amp;lt;container&amp;gt;@&amp;lt;storage&amp;gt;.dfs.core.windows.net/migration/fact_sales/&quot;

df = (
    spark.read.parquet(src)
    .withColumn(&quot;ingested_at&quot;, F.current_timestamp())
)

(
    df.write
    .format(&quot;delta&quot;)
    .mode(&quot;overwrite&quot;)
    .option(&quot;overwriteSchema&quot;, &quot;true&quot;)
    .saveAsTable(&quot;sales_uc.gold.fact_sales&quot;)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;physical-design-do-not-cargo-cult-synapse&quot;&gt;Physical design: do not cargo-cult Synapse&lt;/h4&gt;
&lt;p&gt;In Synapse you spent years on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DISTRIBUTION = HASH(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ROUND_ROBIN&lt;/code&gt; / &lt;code&gt;REPLICATE&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;clustered columnstore choices&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On Databricks / Delta, those directives &lt;strong&gt;do not travel&lt;/strong&gt;. Mapping &lt;code&gt;HASH(customer_id)&lt;/code&gt; to a Delta &lt;strong&gt;partition&lt;/strong&gt; is a classic self-own: high-cardinality keys create tiny-file nightmares.&lt;/p&gt;
&lt;p&gt;Prefer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Liquid Clustering&lt;/strong&gt; / &lt;code&gt;CLUSTER BY AUTO&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;predictive optimization / compaction as an operating model&lt;/li&gt;
&lt;li&gt;partitions only for coarse, stable predicates (e.g. &lt;code&gt;business_date&lt;/code&gt;) when they truly help&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Drop physical Synapse directives. Re-tune with lakehouse primitives after you have real query telemetry — not before.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-code-conversion-syntax-is-easy-semantics-bite&quot;&gt;6) Code conversion: syntax is easy, semantics bite&lt;/h3&gt;
&lt;p&gt;Automated conversion (Lakebridge transpile and friends) often handles a large share of syntax — commonly cited in the &lt;strong&gt;~80–90%&lt;/strong&gt; range for bulk translation. Your engineers live in the remaining &lt;strong&gt;10–20%&lt;/strong&gt;: cursors, dynamic SQL, weird error handling, and semantic mismatches.&lt;/p&gt;
&lt;h4 id=&quot;common-remaps&quot;&gt;Common remaps&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;T-SQL &lt;/th&gt;&lt;th&gt;Databricks SQL&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;GETDATE()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;CURRENT_TIMESTAMP()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ISNULL(a, b)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;COALESCE(a, b)&lt;/code&gt; / &lt;code&gt;IFNULL(a, b)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;LEN(s)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;LENGTH(s)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CHARINDEX(sub, str)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;LOCATE(sub, str)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SELECT TOP 10 ...&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT ... LIMIT 10&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CONVERT(INT, col)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;CAST(col AS INT)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;semantic-trap-1-case-sensitivity&quot;&gt;Semantic trap #1 — case sensitivity&lt;/h4&gt;
&lt;p&gt;Dedicated SQL Pools are often &lt;strong&gt;case-insensitive&lt;/strong&gt; by collation. Databricks SQL is &lt;strong&gt;case-sensitive&lt;/strong&gt; by default.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- May silently change results after migration
WHERE status = 'active'

-- Make intent explicit
WHERE LOWER(status) = 'active'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build this into reconciliation: pick string dimensions and compare distinct counts &lt;strong&gt;before&lt;/strong&gt; you argue about revenue.&lt;/p&gt;
&lt;h4 id=&quot;semantic-trap-2-stored-procedures&quot;&gt;Semantic trap #2 — stored procedures&lt;/h4&gt;
&lt;p&gt;Migrate &lt;strong&gt;structure first&lt;/strong&gt; (parameters, control flow, set-based DML). Optimize second.&lt;/p&gt;
&lt;p&gt;Rewrite candidates (do not pretend transpile “finished” them):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cursors / row-by-row loops&lt;/li&gt;
&lt;li&gt;heavy dynamic SQL&lt;/li&gt;
&lt;li&gt;Synapse-only performance hints&lt;/li&gt;
&lt;li&gt;temp-table choreography that exists only to fight distribution skew&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;semantic-trap-3-try-catch-vs-workflow-design&quot;&gt;Semantic trap #3 — TRY/CATCH vs workflow design&lt;/h4&gt;
&lt;p&gt;Simple audit logging often stays SQL-shaped. Multi-step compensation and downstream signaling usually belong in &lt;strong&gt;Lakeflow Jobs&lt;/strong&gt; (retries, quarantine tasks, alerting) rather than a 2,000-line proc that tries to be an orchestrator.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-dimensional-patterns-on-delta-scd2-&quot;&gt;7) Dimensional patterns on Delta (SCD2)&lt;/h3&gt;
&lt;p&gt;Slowly Changing Dimensions are where Synapse estates accumulate snowflake uniqueness. Your goal is not to clone the old proc line-for-line. Preserve the &lt;strong&gt;business rule&lt;/strong&gt;: history + current row queryability.&lt;/p&gt;
&lt;h4 id=&quot;example-pattern-expire-then-insert-illustrative-&quot;&gt;Example pattern — expire then insert (illustrative)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1) Expire changed current rows
MERGE INTO dim_customer AS t
USING staging_customer AS s
ON t.customer_nk = s.customer_nk AND t.is_current = true
WHEN MATCHED AND (
     t.segment &amp;lt;&amp;gt; s.segment
  OR t.country &amp;lt;&amp;gt; s.country
) THEN UPDATE SET
  t.is_current = false,
  t.valid_to   = current_timestamp();

-- 2) Insert new current versions
INSERT INTO dim_customer (
  customer_nk, segment, country, valid_from, valid_to, is_current
)
SELECT
  s.customer_nk,
  s.segment,
  s.country,
  current_timestamp(),
  CAST(NULL AS TIMESTAMP),
  true
FROM staging_customer s
LEFT ANTI JOIN dim_customer t
  ON t.customer_nk = s.customer_nk
 AND t.is_current = true;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Delta’s ACID behavior makes multi-step SCD flows safer than “hope the warehouse lock ordering holds.” Still: wrap critical loads in jobs with clear quarantine paths.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-reconciliation-amp-cutover&quot;&gt;8) Reconciliation &amp;amp; cutover&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Budget reality:&lt;/strong&gt; validation often costs more calendar time than conversion.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;minimum-reconcile-pack-every-wave-&quot;&gt;Minimum reconcile pack (every wave)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Row counts&lt;/strong&gt; by business date / partition  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aggregates&lt;/strong&gt; — revenue, quantity, distinct business keys  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hash / checksum&lt;/strong&gt; compares on ordered business-key sets  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tolerance checks&lt;/strong&gt; for floats / FX / rounded measures  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;String-dimension sanity&lt;/strong&gt; (case folding surprises)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Lakebridge &lt;strong&gt;Reconcile&lt;/strong&gt; supports structured comparison patterns across these dimensions.&lt;/p&gt;
&lt;h4 id=&quot;cutover-pattern-that-keeps-executives-calm&quot;&gt;Cutover pattern that keeps executives calm&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Parallel run (Synapse + Databricks) for the lighthouse mart  &lt;/li&gt;
&lt;li&gt;Side-by-side Power BI validation with the business owner  &lt;/li&gt;
&lt;li&gt;Flip the dataset connection  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pause&lt;/strong&gt; Synapse compute (do not delete yet)  &lt;/li&gt;
&lt;li&gt;Keep rollback for a defined soak window  &lt;/li&gt;
&lt;li&gt;Only then decommission&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Early decommissioning is how “successful migrations” become weekend rollbacks.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-power-bi-consumption-cutover&quot;&gt;9) Power BI / consumption cutover&lt;/h3&gt;
&lt;p&gt;Technical remap is usually:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Synapse endpoint → &lt;strong&gt;Databricks SQL Warehouse&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;refresh credentials / gateway assumptions&lt;/li&gt;
&lt;li&gt;validate DAX measures against reconcile packs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Political remap is harder:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;who signs off KPI parity?&lt;/li&gt;
&lt;li&gt;which report is the source of truth during dual-run?&lt;/li&gt;
&lt;li&gt;what is the communication plan when numbers differ by 0.3% due to case folding or timezone?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Treat consumption as a &lt;strong&gt;first-class workstream&lt;/strong&gt; with named business owners, not a Friday afternoon connection string change.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-30-60-90-plan&quot;&gt;10) 30 / 60 / 90 plan&lt;/h3&gt;
&lt;h4 id=&quot;days-1-30-foundations&quot;&gt;Days 1–30 — foundations&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Run Lakebridge Profiler + Analyzer on Dedicated pools&lt;/li&gt;
&lt;li&gt;[ ] Cut dead objects from scope (document why)&lt;/li&gt;
&lt;li&gt;[ ] Choose one lighthouse: pipeline → gold mart → one Power BI dataset&lt;/li&gt;
&lt;li&gt;[ ] Stand up Unity Catalog layout + storage conventions&lt;/li&gt;
&lt;li&gt;[ ] Define job naming, environments, and CI basics&lt;/li&gt;
&lt;li&gt;[ ] Write the reconcile notebook/job v0&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;days-31-60-prove-the-path&quot;&gt;Days 31–60 — prove the path&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;[ ] CETAS → ADLS → Delta for large facts (no JDBC heroics)&lt;/li&gt;
&lt;li&gt;[ ] Transpile + hand-fix the top procs for the lighthouse&lt;/li&gt;
&lt;li&gt;[ ] Repoint one BI workspace to Databricks SQL Warehouse&lt;/li&gt;
&lt;li&gt;[ ] Parallel run + signed business acceptance&lt;/li&gt;
&lt;li&gt;[ ] Capture runbooks: failure modes, quarantine, reruns&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;days-61-90-scale-without-drama&quot;&gt;Days 61–90 — scale without drama&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Wave 2/3 domains with the same pack&lt;/li&gt;
&lt;li&gt;[ ] Orchestration parity on Lakeflow Jobs&lt;/li&gt;
&lt;li&gt;[ ] Enablement plan for Synapse-native engineers&lt;/li&gt;
&lt;li&gt;[ ] Cost review (warehouse sizing vs old pool bills)&lt;/li&gt;
&lt;li&gt;[ ] Only then: formal Synapse decommission proposal&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-anti-patterns-i-keep-seeing&quot;&gt;11) Anti-patterns I keep seeing&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Transpile-first, assess-never&lt;/strong&gt; — migrating abandoned procs with confidence.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JDBC for multi-billion-row facts&lt;/strong&gt; — slow, expensive, fragile.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HASH key → Delta partition&lt;/strong&gt; — tiny files, sad Spark UI.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Big-bang BI cutover&lt;/strong&gt; — no dual-run, no owner, no soak.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recreating every Synapse knob&lt;/strong&gt; — preserving constraints without preserving reasons.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skipping enablement&lt;/strong&gt; — platform is fine; team still thinks in distributions.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decommission on day one of “green”&lt;/strong&gt; — optimism is not a rollback strategy.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;12-field-rules-i-will-not-compromise-on&quot;&gt;12) Field rules I will not compromise on&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Hybrid &amp;gt; pure lift-and-shift &amp;gt; big-bang rewrite&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assessment before conversion&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ADLS + Parquet/Delta over JDBC for big moves&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do not map Synapse HASH keys to partitions&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconcile every wave&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do not decommission Synapse early&lt;/strong&gt;  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enablement is a workstream&lt;/strong&gt;, not a lunch-and-learn  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate outcomes (trusted KPIs), not just objects&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;13-faq&quot;&gt;13) FAQ&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Q: Should we move to Fabric instead of Databricks?&lt;/strong&gt;&lt;br&gt;
A: Different target, overlapping Azure story. This article assumes a &lt;strong&gt;Databricks Lakehouse&lt;/strong&gt; landing zone. If Fabric is in play, treat that as an explicit architecture decision — do not accidentally run two migrations under one slogan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Q: Can we keep Synapse Serverless for “just a few views”?&lt;/strong&gt;&lt;br&gt;
A: Sometimes as a bridge. Long-term dual query planes usually recreate the governance tax you are trying to escape.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Q: How perfect must automated conversion be?&lt;/strong&gt;&lt;br&gt;
A: Good enough to move bulk syntax. Your senior engineers should own semantic diffs, SCD logic, and orchestration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Q: What is the first metric of a healthy pilot?&lt;/strong&gt;&lt;br&gt;
A: Not “job duration.” It is &lt;strong&gt;signed KPI parity&lt;/strong&gt; with a business owner plus a rerun runbook a junior can execute.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;14-sources&quot;&gt;14) Sources&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Databricks — &lt;em&gt;Navigating a Synapse Migration to Databricks&lt;/em&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.databricks.com/blog/navigating-synapse-migration-databricks&quot;&gt;https://www.databricks.com/blog/navigating-synapse-migration-databricks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Databricks — &lt;em&gt;Introducing Lakebridge&lt;/em&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.databricks.com/blog/introducing-lakebridge-free-open-data-migration-databricks-sql&quot;&gt;https://www.databricks.com/blog/introducing-lakebridge-free-open-data-migration-databricks-sql&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Lakebridge docs — Synapse Profiler&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://databrickslabs.github.io/lakebridge/docs/assessment/profiler/synapse/&quot;&gt;https://databrickslabs.github.io/lakebridge/docs/assessment/profiler/synapse/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Lakebridge docs — Transpile&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://databrickslabs.github.io/lakebridge/docs/transpile/&quot;&gt;https://databrickslabs.github.io/lakebridge/docs/transpile/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Practical large-table movement discussion (CETAS → ADLS → Delta)&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@prabhakarankanniappan/copy-3-5-billion-rows-from-synapse-to-databricks-in-30-minutes-fda15e0ffca0&quot;&gt;https://medium.com/@prabhakarankanniappan/copy-3-5-billion-rows-from-synapse-to-databricks-in-30-minutes-fda15e0ffca0&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;suggested-linkedin-teaser-after-you-publish-on-dev-to-&quot;&gt;Suggested LinkedIn teaser (after you publish on DEV.to)&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;Synapse → Databricks is not a notebook move.&lt;br&gt;
It’s Dedicated SQL Pools + pipelines + T-SQL semantics + Power BI trust.&lt;br&gt;
I wrote a practical playbook with diagrams, CETAS→Delta examples, reconcile guidance, and a 30/60/90.&lt;br&gt;
Link: &amp;lt;your DEV.to URL&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Firat Celik — Lead Data Engineer · Azure Synapse · Microsoft Fabric · Databricks · ETL/ELT · Power BI&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30474/azure-synapse-to-databricks-a-migration-playbook-that-survives-production</guid>
<pubDate>Fri, 09 Oct 2026 13:19:33 +0000</pubDate>
</item>
<item>
<title>ETL vs ELT — Why Notebook Placement Matters  Body / Content: Yes — the placement of your Databricks</title>
<link>https://coderlegion.com/11185/etl-vs-elt-why-notebook-placement-matters-body-content-yes-the-placement-your-databricks</link>
<description>&lt;h2 id=&quot;etl-vs-elt-why-notebook-placement-matters&quot;&gt;ETL vs ELT — Why Notebook Placement Matters&lt;/h2&gt;
&lt;h3 id=&quot;body-content-&quot;&gt;Body / Content:&lt;/h3&gt;
&lt;p&gt;The placement of your Databricks notebooks determines whether your pipeline behaves like ETL or ELT.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If Databricks runs after loading data into the lake, it’s ELT.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If Databricks runs&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; before loading transformed data, it’s ETL.&lt;/p&gt;
&lt;p&gt;Business Analogy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clean vegetables before putting them in the fridge → ETL&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Dump everything in the fridge and clean later → ELT&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Architecture Mapping:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;RAW → BRONZE in ADF = Extract + Load (EL)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SILVER → GOLD in Data-bricks = Transform (T)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;✅ This makes your pipeline ELT, the modern standard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cheap storage Scalable compute Delta Lake supports late&lt;br&gt;
transformations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notebooks preserve raw data and give flexibility&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11185/etl-vs-elt-why-notebook-placement-matters-body-content-yes-the-placement-your-databricks</guid>
<pubDate>Fri, 06 Feb 2026 14:09:12 +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>
</channel>
</rss>