<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: lnc</title>
    <description>The latest articles on DEV Community by lnc (@lisancao).</description>
    <link>https://dev.to/lisancao</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4155393%2F179b9bad-bb88-475f-a5f3-7276f987446f.png</url>
      <title>DEV Community: lnc</title>
      <link>https://dev.to/lisancao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lisancao"/>
    <language>en</language>
    <item>
      <title>Apache Iceberg v4 Column Updates Are About to Change Feature Engineering</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Mon, 09 Nov 2026 20:03:03 +0000</pubDate>
      <link>https://dev.to/lisancao/apache-iceberg-v4-column-updates-are-about-to-change-feature-engineering-41km</link>
      <guid>https://dev.to/lisancao/apache-iceberg-v4-column-updates-are-about-to-change-feature-engineering-41km</guid>
      <description>&lt;h2&gt;
  
  
  Efficient Column Updates in a Nutshell
&lt;/h2&gt;

&lt;p&gt;Apache Iceberg has two common ways to change a row in a table, and both of them work at file or row granularity. Under &lt;strong&gt;&lt;em&gt;copy-on-write&lt;/em&gt;&lt;/strong&gt;, the engine finds the data file that holds the row, rewrites the entire file with the new value, and swaps the old file out of the table's metadata. Under &lt;strong&gt;&lt;em&gt;merge-on-read&lt;/em&gt;&lt;/strong&gt;, it leaves the old file alone, records the old row as deleted, and writes the new row into a new data file; readers apply the deletes while scanning. In the v2 version of my experiment, that delete was a &lt;em&gt;position delete file&lt;/em&gt; (a list of file paths and row numbers). In the v3 version, it was a &lt;em&gt;deletion vector&lt;/em&gt;, or DV (a compressed bitmap of positions for one data file). Either way, Iceberg is still changing rows, not individual columns.&lt;/p&gt;

&lt;p&gt;Row-level updates are good enough for many workloads. They get expensive on very wide tables: feature stores, embedding tables, and model-scoring tables with hundreds or thousands of columns. A nightly job might recompute one feature, refresh one model's scores, or backfill one new column for every existing row. That is one column changing across the table, but today it can still mean rewriting every byte in the touched data files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/apache/iceberg/issues/15146" rel="noopener noreferrer"&gt;Efficient column updates in Iceberg&lt;/a&gt; is the v4 work aimed at that case. It builds on an earlier column-family proposal, where related columns would have been declared up front and written separately from the start. Column updates keep that idea in view, but the focus is narrower: make the cost of a write closer to the columns that changed instead of the current all-or-nothing shape.&lt;/p&gt;

&lt;p&gt;At the heart of the design is a new &lt;strong&gt;&lt;em&gt;column file&lt;/em&gt;&lt;/strong&gt;: a file that holds one or more whole columns for every row of an existing data file. That lets the data payload for a column update be the updated column, not the whole row. Iceberg finds column files the same way it finds everything else, through its &lt;strong&gt;&lt;em&gt;manifests&lt;/em&gt;&lt;/strong&gt;: metadata files that list every data file in the table along with row counts, partition values and column statistics. A column file is recorded on the manifest entry for the data file it belongs to. The reader then uses that metadata to turn the base file plus its column files back into rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current Column Updates in Iceberg Are Expensive
&lt;/h2&gt;

&lt;p&gt;Take a table shaped like a feature store: an &lt;code&gt;id&lt;/code&gt; column plus two hundred &lt;code&gt;double&lt;/code&gt; columns of random numbers, with 200,000 rows. I wrote it with Spark 4.0.1 and Iceberg 1.12.0 as a single 300.9 MB Parquet file, so every update has one base file to deal with and the snapshot history stays easy to read. Then I made two copies: one with &lt;code&gt;write.update.mode&lt;/code&gt; set to copy-on-write, and one set to merge-on-read. I also ran the same experiment on v2 tables. The only difference was that v2 wrote a position delete file where v3 wrote a deletion vector, so I'm only showing v3 here.&lt;/p&gt;

&lt;p&gt;Against each copy, I ran two &lt;code&gt;UPDATE&lt;/code&gt; statements. The first is the backfill case: change one column, &lt;code&gt;c_1&lt;/code&gt;, for every row. The second is the partial case: change one column, &lt;code&gt;c_2&lt;/code&gt;, for one row in ten. After each statement, the newest snapshot summary and the &lt;code&gt;.files&lt;/code&gt; metadata table show what the commit added and how large it was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Ficeberg-column-updates-setup-flow.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Ficeberg-column-updates-setup-flow.svg" alt="Setup and flow: one base table copied into a copy-on-write table and a merge-on-read table; against each copy a backfill update, a read of the snapshot and files tables, a partial update, and another read" width="880" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Ficeberg-column-updates-table-and-updates.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Ficeberg-column-updates-table-and-updates.svg" alt="The demo table as a grid of 200 columns by 200,000 rows, with the backfill touching one whole column and the partial update one column in every tenth row; beside it, what copy-on-write, merge-on-read and a v4 column file each write for both cases" width="900" height="560"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the backfill case: every row, one column of two hundred&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wide_v3_merge_on_read&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;c_1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c_1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- the partial case: one row in ten, one column&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wide_v3_merge_on_read&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;c_2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c_2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- what I read after each one&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wide_v3_merge_on_read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;committed_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_size_in_bytes&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wide_v3_merge_on_read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Update mode&lt;/th&gt;
&lt;th&gt;Statement&lt;/th&gt;
&lt;th&gt;What the commit added&lt;/th&gt;
&lt;th&gt;MB written&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;copy-on-write&lt;/td&gt;
&lt;td&gt;all rows, &lt;code&gt;c_1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one new data file, all 200 columns (the old file deleted)&lt;/td&gt;
&lt;td&gt;301.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;merge-on-read&lt;/td&gt;
&lt;td&gt;all rows, &lt;code&gt;c_1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one new data file, all 200 columns, plus one deletion vector&lt;/td&gt;
&lt;td&gt;301.1 + under 0.01 (DV)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;copy-on-write&lt;/td&gt;
&lt;td&gt;one row in ten, &lt;code&gt;c_2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one new data file, all 200 columns (the old file deleted)&lt;/td&gt;
&lt;td&gt;301.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;merge-on-read&lt;/td&gt;
&lt;td&gt;one row in ten, &lt;code&gt;c_2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one new data file of 20,000 rows, all 200 columns, plus one deletion vector&lt;/td&gt;
&lt;td&gt;30.1 + 0.03 (DV)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;column file (v4, estimated)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;all rows, &lt;code&gt;c_1&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;one column file holding &lt;code&gt;c_1&lt;/code&gt; for every row, plus a rewritten manifest entry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 1.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;column file (v4, estimated)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;one row in ten, &lt;code&gt;c_2&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;one column file holding &lt;code&gt;c_2&lt;/code&gt; for every row (nine tenths carried over), plus a rewritten manifest entry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 1.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bold rows are estimates. No released Iceberg can run a v4 column update.&lt;/p&gt;

&lt;p&gt;For the backfill, both modes wrote a new 301.1 MB file, slightly larger than the 300.9 MB base file because the updated values compress a little differently. That is all 200 columns rewritten to change one. Copy-on-write does this by definition. Merge-on-read does it because Iceberg treats an &lt;code&gt;UPDATE&lt;/code&gt; as a delete of the old row plus an insert of the new one. When every row changes, the insert half is a complete new data file. The deletion vector marks every row of the old file as gone, and until compaction the table holds both files.&lt;/p&gt;

&lt;p&gt;The partial update is where the two modes differ. Merge-on-read wrote a 30.1 MB file holding every column of the 20,000 touched rows, about a tenth of the table. Copy-on-write rewrote the full 301.1 MB file again. Merge-on-read scales with the number of rows touched, but it still writes every column for each touched row.&lt;/p&gt;

&lt;p&gt;A v4 column update changes the scale of the write. The column &lt;code&gt;c_1&lt;/code&gt; on its own, with the same values in the same order, is 1.6 MB as a Parquet file: about half a percent of the base file.&lt;/p&gt;

&lt;p&gt;In the current modes, write size is driven by rows touched times columns carried. Changing one column or 200 columns has the same shape.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBjb3cgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgbW9yIGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIHY0IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFVbXCJVUERBVEUgdCBTRVQgY18xID0gY18xICsgMVwiXVxuICBzdWJncmFwaCBDT1dbXCJjb3B5LW9uLXdyaXRlLCAxLjEyLjA6IDMwMS4xIE1CIHdyaXR0ZW5cIl1cbiAgICBDMVtcIm5ldyBkYXRhIGZpbGUsIGFsbCAyMDAgY29sdW1ucyAoMzAxLjEgTUIpPGJyLz5vbGQgZmlsZSBkZWxldGVkXCJdOjo6Y293XG4gIGVuZFxuICBzdWJncmFwaCBNT1JbXCJtZXJnZS1vbi1yZWFkLCAxLjEyLjA6IDMwMS4xIE1CIHdyaXR0ZW5cIl1cbiAgICBNMVtcIm5ldyBkYXRhIGZpbGUsIGFsbCAyMDAgY29sdW1ucyAoMzAxLjEgTUIpXCJdOjo6bW9yXG4gICAgTTJbXCJkZWxldGlvbiB2ZWN0b3IsIGV2ZXJ5IHJvdyBvZiB0aGUgb2xkIGZpbGUgKHVuZGVyIDAuMDEgTUIpXCJdOjo6bW9yXG4gIGVuZFxuICBzdWJncmFwaCBWNFtcInY0IGNvbHVtbiB1cGRhdGUsIHByb3Bvc2VkOiBhYm91dCAxLjYgTUIgd3JpdHRlblwiXVxuICAgIFYxW1wiY29sdW1uIGZpbGUgZm9yIGNfMSwgb25lIGNvbHVtbiwgYWxsIHJvd3MgKGFib3V0IDEuNiBNQilcIl06Ojp2NFxuICAgIFYyW1wibWFuaWZlc3QgZW50cnkgcmV3cml0dGVuICh1bmRlciAwLjEgTUIpXCJdOjo6djRcbiAgZW5kXG4gIFUgLS0-IENPV1xuICBVIC0tPiBNT1JcbiAgVSAtLT4gVjRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBjb3cgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgbW9yIGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIHY0IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFVbXCJVUERBVEUgdCBTRVQgY18xID0gY18xICsgMVwiXVxuICBzdWJncmFwaCBDT1dbXCJjb3B5LW9uLXdyaXRlLCAxLjEyLjA6IDMwMS4xIE1CIHdyaXR0ZW5cIl1cbiAgICBDMVtcIm5ldyBkYXRhIGZpbGUsIGFsbCAyMDAgY29sdW1ucyAoMzAxLjEgTUIpPGJyLz5vbGQgZmlsZSBkZWxldGVkXCJdOjo6Y293XG4gIGVuZFxuICBzdWJncmFwaCBNT1JbXCJtZXJnZS1vbi1yZWFkLCAxLjEyLjA6IDMwMS4xIE1CIHdyaXR0ZW5cIl1cbiAgICBNMVtcIm5ldyBkYXRhIGZpbGUsIGFsbCAyMDAgY29sdW1ucyAoMzAxLjEgTUIpXCJdOjo6bW9yXG4gICAgTTJbXCJkZWxldGlvbiB2ZWN0b3IsIGV2ZXJ5IHJvdyBvZiB0aGUgb2xkIGZpbGUgKHVuZGVyIDAuMDEgTUIpXCJdOjo6bW9yXG4gIGVuZFxuICBzdWJncmFwaCBWNFtcInY0IGNvbHVtbiB1cGRhdGUsIHByb3Bvc2VkOiBhYm91dCAxLjYgTUIgd3JpdHRlblwiXVxuICAgIFYxW1wiY29sdW1uIGZpbGUgZm9yIGNfMSwgb25lIGNvbHVtbiwgYWxsIHJvd3MgKGFib3V0IDEuNiBNQilcIl06Ojp2NFxuICAgIFYyW1wibWFuaWZlc3QgZW50cnkgcmV3cml0dGVuICh1bmRlciAwLjEgTUIpXCJdOjo6djRcbiAgZW5kXG4gIFUgLS0-IENPV1xuICBVIC0tPiBNT1JcbiAgVSAtLT4gVjRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 1" width="574" height="973"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Byte by Byte
&lt;/h2&gt;

&lt;p&gt;For the data payload, a v4 column update writes one new file holding the updated column for every row. The dominant write is that one column. On this table every column is random doubles, so each column is roughly 1/200th of the file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;base file (200 columns, 200,000 rows)&lt;/td&gt;
&lt;td&gt;300.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one column, by proportion (300.9 MB ÷ 200)&lt;/td&gt;
&lt;td&gt;about 1.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;c_1&lt;/code&gt; written on its own as a Parquet file, measured&lt;/td&gt;
&lt;td&gt;1.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The partial update lands at the same estimate. A column file must hold every row of the column, so changing one row in ten still writes the column in full. The untouched nine tenths are copied from the base file. The size depends on the column, not on how many rows changed.&lt;/p&gt;

&lt;p&gt;Two small costs are left out of that estimate: the rewritten manifest entry, which is a fraction of a megabyte here, and a null at each deleted position if the base file has deletes. At typical deletion ratios, neither changes the rough size of the write.&lt;/p&gt;

&lt;p&gt;The expected write drops from 301.1 MB to about 1.6 MB: nearly 200x on this table because the table has 200 similarly sized columns. For any table, the factor is the base file size divided by the updated column's size. &lt;code&gt;column_sizes&lt;/code&gt; in the &lt;code&gt;.files&lt;/code&gt; metadata table gives that size per column. A real writer will add overhead on top of the column file; this demo does not measure that yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a Column File
&lt;/h2&gt;

&lt;p&gt;The public v4 draft defines a column file as "a file that stores the values of one or more fields for each row of another file." That last part matters. A column file is not a list of changed cells. It has the same row count as the data file and holds the complete current value of one or more fields for every row. The data file's manifest entry lists which fields live in which column file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3://bucket/data/base_file_1.parquet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"record_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"column_files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3://bucket/data/column_update_7.parquet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"field_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"parquet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file_size_in_bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sketch omits most tracked-file fields. The important part is the &lt;code&gt;column_files&lt;/code&gt; list. Each entry names the column file's location, the live top-level field IDs it stores, the file format (&lt;code&gt;avro&lt;/code&gt;, &lt;code&gt;orc&lt;/code&gt; or &lt;code&gt;parquet&lt;/code&gt;) and the file size in bytes. It may also carry encryption key metadata.&lt;/p&gt;

&lt;p&gt;A reader that understands column files resolves each projected field from the column file that lists it and every other field from the data file. The remaining requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Consequence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;It is stored in a normal data-file format&lt;/td&gt;
&lt;td&gt;The bytes are ordinary Parquet, ORC or Avro; stitching them into the base file still requires Iceberg v4 column-file support.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole top-level fields only&lt;/td&gt;
&lt;td&gt;A struct is updated as a unit; one leaf cannot be updated alone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One column file per field&lt;/td&gt;
&lt;td&gt;Updating a field again replaces its column file instead of stacking another.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every row, in the base file's order&lt;/td&gt;
&lt;td&gt;The read is a column swap by position; the write has to line up exactly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unchanged rows are carried over&lt;/td&gt;
&lt;td&gt;A partial update still writes the whole column.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted rows are written as null&lt;/td&gt;
&lt;td&gt;A heavily deleted file carries a few percent of filler.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The deletion vector decides what is deleted&lt;/td&gt;
&lt;td&gt;A null is a filler, never a tombstone.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fifth requirement sets the size of a partial update. A column file for one row in ten is not 20,000 values keyed by position. It is 200,000 values: 20,000 new values and the rest copied from wherever the field lived before, or null where the row is deleted.&lt;/p&gt;

&lt;p&gt;That is the 1.6 MB file from the demo, not a 0.03 MB delta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A column file is the whole column, not a delta. It mirrors its base file row for row and is swapped in by position at read time. Wide backfills get cheaper; row-level refreshes are not the sweet spot.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note&lt;/p&gt;
&lt;p&gt;"Column families" is a different thing&lt;br&gt;
The earlier proposal was about &lt;em&gt;column families&lt;/em&gt;: declaring which columns belong together so they are written as separate files from the start. The design doc keeps that as future work and the current column-file text does not define families. "Column file" in this post is the v4 spec term.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Stitching It Back Together on Read
&lt;/h2&gt;

&lt;p&gt;A read of a data file with column files has four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use the manifest entry to split the requested fields between the base file and column files.&lt;/li&gt;
&lt;li&gt;Open the base file and only the column files needed for the projection.&lt;/li&gt;
&lt;li&gt;Read those files in position order and combine each position's pieces into one row or batch.&lt;/li&gt;
&lt;li&gt;Apply the deletion vector to the combined result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the files are row-aligned, step 3 is what the design doc calls "reference substitution": the column file's array replaces the base file's array for the updated field. The intended read shape is a column-level substitution, not a join or keyed merge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBiYXNlIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGNvbCBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiByZWcgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgRVtcIm1hbmlmZXN0IGVudHJ5PGJyLz5kYXRhIGZpbGUgKyBjb2x1bW5fZmlsZXM8YnIvPls3XSBpbiBjZl83LCBbNCw1XSBpbiBjZl80NVwiXSAtLT4gUFtcInByb2plY3Rpb24gc3BsaXR0ZXI8YnIvPmlkLG5hbWUgZnJvbSBiYXNlPGJyLz43IGZyb20gY2ZfNywgNSBmcm9tIGNmXzQ1XCJdOjo6cmVnXG4gIFAgLS0-IEJbXCJiYXNlIGZpbGUgcmVhZGVyPGJyLz5jb2x1bW5zIGlkLCBuYW1lXCJdOjo6YmFzZVxuICBQIC0tPiBDN1tcImNvbHVtbiBmaWxlIHJlYWRlcjxici8-Y29sdW1uIDdcIl06Ojpjb2xcbiAgUCAtLT4gQzQ1W1wiY29sdW1uIGZpbGUgcmVhZGVyPGJyLz5jb2x1bW4gNSAoNCBub3QgcHJvamVjdGVkKVwiXTo6OmNvbFxuICBCIC0tPiBTW1wic3RpdGNoZXI8YnIvPm9uZSByb3cgLyBvbmUgYmF0Y2ggcGVyIHBvc2l0aW9uXCJdOjo6cmVnXG4gIEM3IC0tPiBTXG4gIEM0NSAtLT4gU1xuICBTIC0tPiBEVltcImFwcGx5IGRlbGV0aW9uIHZlY3Rvcjxici8-ZHJvcCBkZWxldGVkIHBvc2l0aW9ucyBhbmQgdGhlaXIgbnVsbCBmaWxsZXJzXCJdXG4gIERWIC0tPiBSW1wicm93cyBhcyBpZiBmcm9tIG9uZSBmaWxlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBiYXNlIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGNvbCBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiByZWcgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgRVtcIm1hbmlmZXN0IGVudHJ5PGJyLz5kYXRhIGZpbGUgKyBjb2x1bW5fZmlsZXM8YnIvPls3XSBpbiBjZl83LCBbNCw1XSBpbiBjZl80NVwiXSAtLT4gUFtcInByb2plY3Rpb24gc3BsaXR0ZXI8YnIvPmlkLG5hbWUgZnJvbSBiYXNlPGJyLz43IGZyb20gY2ZfNywgNSBmcm9tIGNmXzQ1XCJdOjo6cmVnXG4gIFAgLS0-IEJbXCJiYXNlIGZpbGUgcmVhZGVyPGJyLz5jb2x1bW5zIGlkLCBuYW1lXCJdOjo6YmFzZVxuICBQIC0tPiBDN1tcImNvbHVtbiBmaWxlIHJlYWRlcjxici8-Y29sdW1uIDdcIl06Ojpjb2xcbiAgUCAtLT4gQzQ1W1wiY29sdW1uIGZpbGUgcmVhZGVyPGJyLz5jb2x1bW4gNSAoNCBub3QgcHJvamVjdGVkKVwiXTo6OmNvbFxuICBCIC0tPiBTW1wic3RpdGNoZXI8YnIvPm9uZSByb3cgLyBvbmUgYmF0Y2ggcGVyIHBvc2l0aW9uXCJdOjo6cmVnXG4gIEM3IC0tPiBTXG4gIEM0NSAtLT4gU1xuICBTIC0tPiBEVltcImFwcGx5IGRlbGV0aW9uIHZlY3Rvcjxici8-ZHJvcCBkZWxldGVkIHBvc2l0aW9ucyBhbmQgdGhlaXIgbnVsbCBmaWxsZXJzXCJdXG4gIERWIC0tPiBSW1wicm93cyBhcyBpZiBmcm9tIG9uZSBmaWxlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="1128" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the Java implementation work, step 3 is a &lt;em&gt;stitcher&lt;/em&gt;. There is one stitcher per engine row type, such as Spark &lt;code&gt;InternalRow&lt;/code&gt;, Flink &lt;code&gt;RowData&lt;/code&gt;, and the generic &lt;code&gt;Record&lt;/code&gt;. Steps 1, 2 and 4 are shared across engines.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A column file is opened only when a query projects one of its fields. A query on &lt;code&gt;c_200&lt;/code&gt; reads the base file alone.&lt;/li&gt;
&lt;li&gt;Column files do not need to share the base file's row groups, the chunks of rows a Parquet file is written and read in. When row groups do not line up, the reader slices batches to the overlapping window.&lt;/li&gt;
&lt;li&gt;Deletion vectors are applied after stitching, so a column file can hold nulls at deleted rows and is never consulted about deletion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Explaining every row
&lt;/h2&gt;

&lt;p&gt;The every-row requirement is what makes partial updates less dramatic.&lt;/p&gt;

&lt;p&gt;The alternative was a &lt;em&gt;sparse&lt;/em&gt; column file: only the changed rows, each tagged with its position. A one-in-ten update would then weigh a tenth of the column. The design rejects that tradeoff because it moves cost to readers outside the JVM:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The two representations have asymmetric read-side cost across language ecosystems. Spark's &lt;code&gt;ColumnVector&lt;/code&gt; is an abstract class with virtual dispatch, so a custom stitching subclass can redirect per-row reads (&lt;code&gt;getInt(rowId)&lt;/code&gt; → choose base vs column file) with no memory copy. Concrete Arrow array types in arrow-rs, PyArrow, and arrow-go do not support this yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Spark, a sparse file can stay lazy on read. A &lt;code&gt;ColumnVector&lt;/code&gt; subclass can answer &lt;code&gt;getDouble(i)&lt;/code&gt; from the column file when position &lt;code&gt;i&lt;/code&gt; was updated and from the base file otherwise, without materializing a replacement array.&lt;/p&gt;

&lt;p&gt;An Arrow array in Rust, Python or Go is a contiguous buffer with no per-row redirect. Those readers would have to materialize a new array per batch by scattering the updated values over the base column. That read cost gives back much of what the sparse file saved on write.&lt;/p&gt;

&lt;p&gt;With every row present, the stitch can be expressed as an array-level substitution instead of a sparse overlay.&lt;/p&gt;

&lt;p&gt;v4 tables need a layout that works across those engines, so the draft standardizes the every-row representation instead of letting writers choose between dense and sparse column files. Once a layout is allowed, every reader has to handle it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Every row (the spec)&lt;/th&gt;
&lt;th&gt;Updated rows only (the alternative)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Partial update writes&lt;/td&gt;
&lt;td&gt;the whole column&lt;/td&gt;
&lt;td&gt;only the changed rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read in Spark&lt;/td&gt;
&lt;td&gt;swap one array&lt;/td&gt;
&lt;td&gt;cheap either way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read in Rust, Python, Go&lt;/td&gt;
&lt;td&gt;substitute an array&lt;/td&gt;
&lt;td&gt;copy the column&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How a column update effects manifest
&lt;/h2&gt;

&lt;p&gt;A column update changes the manifest as well as the data. Conceptually, the new snapshot stops pointing at the old version of the tracked file and points at a successor entry for the same base file with a new &lt;code&gt;column_files&lt;/code&gt; list. Earlier snapshots still see the old metadata for time travel. A field may appear in only one column file for a tracked file, so updating one field of an existing column file means rewriting that column file with its other fields carried over.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBvbGQgZmlsbDojNTU1NTU1LGZpbGwtb3BhY2l0eTowLjYsc3Ryb2tlOiM1NTU1NTUsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBuZXcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29sIGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFBbXCJwcmlvciBlbnRyeTxici8-YmFzZV9maWxlXzEucGFycXVldDxici8-c2VxIDc8YnIvPmNvbHVtbl9maWxlcyBbIHs0LDV9IC0-IGNmX2EgXTxici8-RFY6IHsxMiwgNDAwfVwiXTo6Om9sZFxuICBQIC0tPnxcImNvbHVtbiB1cGRhdGUgb2YgZmllbGRzIDUgYW5kIDk8YnIvPmluIHRoZSBzbmFwc2hvdCB3aXRoIHNlcSAxMVwifCBSW1wicHJldmlvdXMgdmVyc2lvbjxici8-a2VwdCBmb3Igb2xkZXIgc25hcHNob3RzXCJdOjo6b2xkXG4gIFAgLS0-IE1bXCJjdXJyZW50IHRyYWNrZWQgZW50cnk8YnIvPmJhc2VfZmlsZV8xLnBhcnF1ZXQ8YnIvPnNlcSAxMTxici8-Y29sdW1uX2ZpbGVzIFsgezR9IC0-IGNmX2IsIHs1LDl9IC0-IGNmX2MgXTxici8-RFY6IGV2ZXJ5IGRlbGV0ZSB0aGF0IGFwcGxpZWQgc28gZmFyPGJyLz5zdGF0cyBmb3IgNCwgNSwgOSBmcm9tIHRoZSBjb2x1bW4gZmlsZXNcIl06OjpuZXdcbiAgTSAtLT4gQ0JbXCJjZl9iOiBmaWVsZCA0PGJyLz5jYXJyaWVkIG92ZXIgZnJvbSBjZl9hXCJdOjo6Y29sXG4gIE0gLS0-IENDW1wiY2ZfYzogZmllbGRzIDUsIDkgKyBfbGFzdF91cGRhdGVkX3NlcXVlbmNlX251bWJlcjxici8-NSB1cGRhdGVkLCA5IG5ld1wiXTo6OmNvbFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBvbGQgZmlsbDojNTU1NTU1LGZpbGwtb3BhY2l0eTowLjYsc3Ryb2tlOiM1NTU1NTUsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBuZXcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29sIGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFBbXCJwcmlvciBlbnRyeTxici8-YmFzZV9maWxlXzEucGFycXVldDxici8-c2VxIDc8YnIvPmNvbHVtbl9maWxlcyBbIHs0LDV9IC0-IGNmX2EgXTxici8-RFY6IHsxMiwgNDAwfVwiXTo6Om9sZFxuICBQIC0tPnxcImNvbHVtbiB1cGRhdGUgb2YgZmllbGRzIDUgYW5kIDk8YnIvPmluIHRoZSBzbmFwc2hvdCB3aXRoIHNlcSAxMVwifCBSW1wicHJldmlvdXMgdmVyc2lvbjxici8-a2VwdCBmb3Igb2xkZXIgc25hcHNob3RzXCJdOjo6b2xkXG4gIFAgLS0-IE1bXCJjdXJyZW50IHRyYWNrZWQgZW50cnk8YnIvPmJhc2VfZmlsZV8xLnBhcnF1ZXQ8YnIvPnNlcSAxMTxici8-Y29sdW1uX2ZpbGVzIFsgezR9IC0-IGNmX2IsIHs1LDl9IC0-IGNmX2MgXTxici8-RFY6IGV2ZXJ5IGRlbGV0ZSB0aGF0IGFwcGxpZWQgc28gZmFyPGJyLz5zdGF0cyBmb3IgNCwgNSwgOSBmcm9tIHRoZSBjb2x1bW4gZmlsZXNcIl06OjpuZXdcbiAgTSAtLT4gQ0JbXCJjZl9iOiBmaWVsZCA0PGJyLz5jYXJyaWVkIG92ZXIgZnJvbSBjZl9hXCJdOjo6Y29sXG4gIE0gLS0-IENDW1wiY2ZfYzogZmllbGRzIDUsIDkgKyBfbGFzdF91cGRhdGVkX3NlcXVlbmNlX251bWJlcjxici8-NSB1cGRhdGVkLCA5IG5ld1wiXTo6OmNvbFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 3" width="743" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current tracked entry also gets a new &lt;em&gt;sequence number&lt;/em&gt;: the file's age in commit order, which decides which delete files apply to it. With the new number, older deletes should not be replayed against the new column values.&lt;/p&gt;

&lt;p&gt;That sequence-number change would otherwise make older deletes tricky. The v4 work handles this by folding deletes that already applied to the file, including equality deletes, into the entry's own deletion vector. The DV becomes the complete delete record for the current version of the tracked file, and the reader does not need to replay old delete files against the updated columns.&lt;/p&gt;

&lt;p&gt;Stats for the updated fields are recomputed from the column file, so file pruning based on those fields can still work.&lt;/p&gt;

&lt;p&gt;Row lineage is part of this too. Since v3, every row carries &lt;code&gt;_last_updated_sequence_number&lt;/code&gt;, the sequence number of the commit that last changed it. One of the new column files has to hold that field: null for every row the update changed, so those rows inherit the new sequence number on read, and the previous value for every row it did not change. Row IDs do not change.&lt;/p&gt;

&lt;p&gt;If the updated field is used for partitioning or sorting, metadata has to become conservative for that file because the rows may no longer match the old placement information. Plan for weaker pruning and ordering metadata on that file until compaction rewrites it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read the column's current values and the file's deletes&lt;/td&gt;
&lt;td&gt;a read before the write, even for a partial update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write one column file per base file&lt;/td&gt;
&lt;td&gt;the column file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewrite the manifest entry, stats and partition info&lt;/td&gt;
&lt;td&gt;small, but every touched file gets it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compact later (&lt;code&gt;rewrite_data_files&lt;/code&gt; folds column files back in)&lt;/td&gt;
&lt;td&gt;stitching is a cost between compactions, not forever&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  But why not directly in Parquet?
&lt;/h2&gt;

&lt;p&gt;The design doc evaluated a Parquet-level alternative and discarded it. Parquet's footer has a &lt;code&gt;ColumnChunk.file_path&lt;/code&gt; field, so in theory a Parquet file could say that its &lt;code&gt;score&lt;/code&gt; chunk lives in one file while its other chunks live in &lt;code&gt;users.parquet&lt;/code&gt;. A column update could then be a narrow Parquet file whose footer describes the whole row. Iceberg would record it as an ordinary data file, with no spec change.&lt;/p&gt;

&lt;p&gt;The catch is in the comment on that field in today's &lt;code&gt;parquet.thrift&lt;/code&gt;: "there are no known reference implementations that will read externally stored column data if this field is populated within a standard parquet file. Making use of the field for this purpose is not considered part of the Parquet specification." The field exists, but readers do not honor it. "No Parquet change" would have become a parquet-format change after all, followed by changes in every Parquet reader Iceberg depends on.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;In the manifest (chosen)&lt;/th&gt;
&lt;th&gt;In the Parquet footer (discarded)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who must change&lt;/td&gt;
&lt;td&gt;Iceberg's spec and readers&lt;/td&gt;
&lt;td&gt;parquet-format and every Parquet reader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the planner skip files?&lt;/td&gt;
&lt;td&gt;yes, from the manifest&lt;/td&gt;
&lt;td&gt;only after opening footers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can cleanup jobs see every file?&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no, the link is inside a footer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formats&lt;/td&gt;
&lt;td&gt;Parquet, ORC, Avro&lt;/td&gt;
&lt;td&gt;Parquet only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://dev.to/posts/data-engineering-ai/iceberg-file-format-api"&gt;File Format API&lt;/a&gt; makes the same kind of choice: keep the bytes in plain data-file formats and put the table-level structure in Iceberg metadata, where every engine already has to look.&lt;/p&gt;

&lt;p&gt;A column file is tracked by Iceberg, not by Parquet. v4-aware &lt;code&gt;expire_snapshots&lt;/code&gt;, &lt;code&gt;remove_orphan_files&lt;/code&gt; and &lt;code&gt;rewrite_data_files&lt;/code&gt; implementations can account for column files from the manifest. A Parquet tool that opens a column file directly sees an ordinary narrow Parquet file with no link to its base file.&lt;/p&gt;

&lt;h2&gt;
  
  
  State of the Implementation
&lt;/h2&gt;

&lt;p&gt;Everything above is based on the public v4 draft, the proposal issue and open implementation work as of 2026-10-27. The public spec includes v4 column-file text, but v4 itself is still under active development and has not been formally adopted. The row-count requirement has changed once already; implementation details can still move.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Proposal&lt;/td&gt;
&lt;td&gt;open, milestone "Iceberg V4 Spec"&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/iceberg/issues/15146" rel="noopener noreferrer"&gt;issue 15146&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design doc&lt;/td&gt;
&lt;td&gt;current&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.google.com/document/d/1Bd7JVzgajA8-DozzeEE24mID_GLuz6iwj0g4TlcVJcs" rel="noopener noreferrer"&gt;doc&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spec text&lt;/td&gt;
&lt;td&gt;present in the public v4 draft, still part of active v4 work&lt;/td&gt;
&lt;td&gt;Iceberg spec and linked PRs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readers and writers&lt;/td&gt;
&lt;td&gt;implementation work in progress&lt;/td&gt;
&lt;td&gt;linked under Further reading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format version 4&lt;/td&gt;
&lt;td&gt;not formally adopted; nothing here is in Iceberg 1.12.0&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The demo ran once on one machine, measured file sizes only, and used random doubles that make every column about the same width. The reference column is a Spark Parquet file, not an Iceberg column file, so a real column file may be slightly larger. The filler-overhead figures come from the design doc and were not reproduced here. I did not build the reader PR. The research, demo script and draft used AI assistance; the reading of the spec and the opinions are mine, and so are any mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you should know and how to proceed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have a wide table with a regular column refresh.&lt;/strong&gt; Budget for the full rewrite until v4 ships in your engine, or keep the fast-moving columns in a narrower table on the same key and join at read time. That is column families by hand. Migrating to v4 later becomes a consolidation, not a full redesign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your refresh touches a few rows, not all of them.&lt;/strong&gt; Stay on merge-on-read unless the math says otherwise. A column file writes every row of the column, so compare the fraction of rows you touch against the column's share of bytes (&lt;code&gt;column_sizes&lt;/code&gt; in the &lt;code&gt;.files&lt;/code&gt; table) before expecting v4 to help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You run compaction and orphan cleanup on a schedule.&lt;/strong&gt; Every query on an updated column reads the base file and the column file until &lt;code&gt;rewrite_data_files&lt;/code&gt; folds them back together. Compaction is part of the column-update workflow, not an optional cleanup later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You partition or sort on a column you might update.&lt;/strong&gt; Treat this as a conservative-metadata case. Pruning and ordering information can get weaker for the affected file until compaction rewrites it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You write an engine.&lt;/strong&gt; The stitcher is the interface to watch. Every-row column files make the stitch an array-level substitution; Arrow arrays are the reason the layout is every-row.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/apache/iceberg/issues/15146" rel="noopener noreferrer"&gt;Efficient column updates in Iceberg&lt;/a&gt;, the proposal, and its &lt;a href="https://docs.google.com/document/d/1Bd7JVzgajA8-DozzeEE24mID_GLuz6iwj0g4TlcVJcs" rel="noopener noreferrer"&gt;design doc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The code trail: the &lt;a href="https://github.com/apache/iceberg/pull/16425" rel="noopener noreferrer"&gt;spec text&lt;/a&gt; and &lt;a href="https://github.com/apache/iceberg/pull/18324" rel="noopener noreferrer"&gt;reader work&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ColumnChunk.file_path&lt;/code&gt; in &lt;a href="https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift" rel="noopener noreferrer"&gt;parquet.thrift&lt;/a&gt;; the &lt;a href="https://whimsy-vm9.apache.org/board/minutes/Parquet.html" rel="noopener noreferrer"&gt;Parquet board minutes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lance &lt;a href="https://lancedb.github.io/lance/format/" rel="noopener noreferrer"&gt;table format&lt;/a&gt; and &lt;a href="https://github.com/lancedb/lance/blob/main/docs/src/format/table/data_overlay_file.md" rel="noopener noreferrer"&gt;data overlay files&lt;/a&gt;; Paimon &lt;a href="https://paimon.apache.org/docs/1.3/append-table/data-evolution/" rel="noopener noreferrer"&gt;data evolution&lt;/a&gt;; Hudi &lt;a href="https://github.com/apache/hudi/blob/master/rfc/rfc-80/rfc-80.md" rel="noopener noreferrer"&gt;RFC-80&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>iceberg</category>
      <category>parquet</category>
      <category>lakehouse</category>
      <category>spec</category>
    </item>
    <item>
      <title>Spark Connect from Rust: Pull Spark outputs into DuckDB and Polars</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Mon, 09 Nov 2026 20:02:57 +0000</pubDate>
      <link>https://dev.to/lisancao/spark-connect-from-rust-pull-spark-outputs-into-duckdb-and-polars-533j</link>
      <guid>https://dev.to/lisancao/spark-connect-from-rust-pull-spark-outputs-into-duckdb-and-polars-533j</guid>
      <description>&lt;h2&gt;
  
  
  Setting the Rust-Native Scene
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://systemsnull.net/posts/data-engineering-ai/build-a-spark-connect-client" rel="noopener noreferrer"&gt;OCaml post&lt;/a&gt; we walked through the process of building a Connect client from scratch, but in truth the Rust client is probably the only one you really need if you're working right now in the data engineering field. It gives you in-process interoperability with engines like Polars (written in Rust) and DuckDB (C++, with official Rust bindings), meaning you no longer have to choose between Spark and . In my connect &lt;a href="https://systemsnull.net/posts/data-engineering-ai/idiots-guide-spark-connect-clients" rel="noopener noreferrer"&gt;idiot's guide&lt;/a&gt; I only hinted at the Rust client as part of a way to gauge supported operations. In this follow-up, I want to deep dive into the Rust Client. It'll be helpful to know what Spark connect is ahead of time, but you'll probably figure it out pretty easily if you're a smart cookie. TLDR on the previous posts: Connect is Spark's attempt to decouple the JVM from the user's environment by implementing a client-server architecture where a very lightweight client sends unresolved logical plans to a remote cluster. &lt;/p&gt;

&lt;p&gt;Spark Connect sends query results to the client as Arrow record batches (thank god). The client builds a plan, the server runs it, and results are returned as Arrow. If you want to know why this is important, I'd suggest reading &lt;a href="https://systemsnull.net/posts/data-engineering-ai/arrow-and-spark" rel="noopener noreferrer"&gt;my previous blog on Arrow batches replacing pickle serialization between the JVM and Python process in Spark&lt;/a&gt;. Every Connect client gets those same Arrow batches, so this isn't unique to Rust: in Python, PySpark's &lt;code&gt;toArrow()&lt;/code&gt; (4.0 and later) hands you a pyarrow table that DuckDB and Polars can read directly. What Rust adds is doing it in one compiled program with no Python in the process, where DuckDB and Polars read the batches as Rust libraries. This separation allows Spark to do the work that needs a cluster (the scan, the join across a billion rows, the group-by), and when the result comes down as Arrow,  DuckDB or Polars can be used in the same process without writing intermediary data files. &lt;/p&gt;

&lt;p&gt;The demo and code in this blog can be found in &lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/09_spark_connect_rust" rel="noopener noreferrer"&gt;spark-demos/demos/09_spark_connect_rust&lt;/a&gt; with its full run log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With the official Rust client, one Spark result comes out as Arrow IPC bytes that DuckDB and Polars both read cheaply, DuckDB registers it without a further copy, and the answer goes back to Spark.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFNSVltcIlNwYXJrIDQuMi4wPGJyLz5Db25uZWN0IHNlcnZlclwiXSAtLT58XCJBcnJvdyBiYXRjaGVzIG92ZXIgZ1JQQ1wifCBDTElbXCJhcGFjaGUtc3BhcmstY29ubmVjdDxici8-KGFycm93LXJzIDU4KVwiXVxuICAgIENMSSAtLT58XCJ0b19hcnJvdygpIElQQyBieXRlc1wifCBEVUNLW1wiRHVja0RCIDIuMDxici8-KGFycm93LXJzIDYwKVwiXVxuICAgIENMSSAtLT58XCJ0b19hcnJvdygpIElQQyBieXRlc1wifCBQT0xbXCJQb2xhcnM8YnIvPihwb2xhcnMtYXJyb3cpXCJdXG4gICAgUE9MIC0tPnxcIkFycm93IElQQyBieXRlc1wifCBEVUNLXG4gICAgRFVDSyAtLT58XCJjcmVhdGVfZGF0YWZyYW1lKCk8YnIvPnNhdmVBc1RhYmxlXCJ8IFNSVlxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFNSVltcIlNwYXJrIDQuMi4wPGJyLz5Db25uZWN0IHNlcnZlclwiXSAtLT58XCJBcnJvdyBiYXRjaGVzIG92ZXIgZ1JQQ1wifCBDTElbXCJhcGFjaGUtc3BhcmstY29ubmVjdDxici8-KGFycm93LXJzIDU4KVwiXVxuICAgIENMSSAtLT58XCJ0b19hcnJvdygpIElQQyBieXRlc1wifCBEVUNLW1wiRHVja0RCIDIuMDxici8-KGFycm93LXJzIDYwKVwiXVxuICAgIENMSSAtLT58XCJ0b19hcnJvdygpIElQQyBieXRlc1wifCBQT0xbXCJQb2xhcnM8YnIvPihwb2xhcnMtYXJyb3cpXCJdXG4gICAgUE9MIC0tPnxcIkFycm93IElQQyBieXRlc1wifCBEVUNLXG4gICAgRFVDSyAtLT58XCJjcmVhdGVfZGF0YWZyYW1lKCk8YnIvPnNhdmVBc1RhYmxlXCJ8IFNSVlxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="1311" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Rust client actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/apache/spark-connect-rust" rel="noopener noreferrer"&gt;apache/spark-connect-rust&lt;/a&gt; is the Apache Spark project's own Rust client, listed on the &lt;a href="https://spark.apache.org/spark-connect/" rel="noopener noreferrer"&gt;Spark Connect page&lt;/a&gt; next to the Go and Swift ones, with its documentation at &lt;a href="https://apache.github.io/spark-connect-rust/" rel="noopener noreferrer"&gt;apache.github.io/spark-connect-rust&lt;/a&gt;. The crate is &lt;code&gt;apache-spark-connect&lt;/code&gt;, used in code as &lt;code&gt;spark_connect&lt;/code&gt;, and the version number is the Spark version it speaks: &lt;code&gt;4.2.0&lt;/code&gt; means Spark 4.2.0 and later, which is a nice convention more clients should steal.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apache.github.io/spark-connect-rust/architecture/" rel="noopener noreferrer"&gt;architecture page&lt;/a&gt; describes one Rust core (plan building, gRPC via tonic, Arrow decoding) with two faces on it: a synchronous Rust API shaped like PySpark, and a &lt;code&gt;pyspark-client-rust&lt;/code&gt; wheel on PyPI that installs as &lt;code&gt;pyspark&lt;/code&gt; and is meant as a drop-in for the reference Python client. This blog uses the Rust API. If you've used other Rust gRPC clients, "synchronous" is the part to notice: no &lt;code&gt;async&lt;/code&gt;, no runtime to set up, and no &lt;code&gt;.await&lt;/code&gt; anywhere in your code. The hello world from the docs is the entire API in miniature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;spark_connect&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sc://localhost:15002"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.get_or_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;
        &lt;span class="nf"&gt;.range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.select&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="nn"&gt;f&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nn"&gt;f&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"count = {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="nf"&gt;.count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://apache.github.io/spark-connect-rust/compatibility/" rel="noopener noreferrer"&gt;compatibility page&lt;/a&gt; says the goal is full API parity with the reference PySpark Connect client, that the protobuf plans it builds are checked byte-for-byte against the reference Python client in CI, and that the official PySpark Connect test suite is run against it. In practice, if you know PySpark you can mostly guess the Rust method name (&lt;code&gt;spark.sql()&lt;/code&gt;, &lt;code&gt;df.filter()&lt;/code&gt;, &lt;code&gt;df.group_by()&lt;/code&gt;, &lt;code&gt;df.write().save_as_table()&lt;/code&gt;), and when you guess wrong the compiler tells you, which is more than Python does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the environment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The server.&lt;/strong&gt; Any Spark 4.2 or later Connect server will do. The demo's &lt;code&gt;compose.yaml&lt;/code&gt; starts the official image's Connect server (it listens on all interfaces by default) and gives it a health check, so &lt;code&gt;docker compose up --wait&lt;/code&gt; returns when the port actually answers rather than when the container starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spark-connect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/spark:4.2.0-scala2.13-java21-python3-ubuntu&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/sbin/start-connect-server.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--wait&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--conf&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark.driver.memory=4g&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:${CONNECT_PORT:-15002}:15002"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bash"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/dev/tcp/127.0.0.1/15002"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The published port is the one thing you may need to change. My machine already had a Spark Connect server on 15002 from another demo the first time I ran this (this keeps happening to me; it's the first item in the idiot's guide's debugging section), so that run used &lt;code&gt;CONNECT_PORT=15902&lt;/code&gt;. It's also why the first thing the program prints is the server's version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The crate.&lt;/strong&gt; Three things have to be installed before &lt;code&gt;cargo build&lt;/code&gt; works, and each fails at build time if you skip it. You need &lt;code&gt;protoc&lt;/code&gt;, because the client compiles the Spark Connect &lt;code&gt;.proto&lt;/code&gt; files when it builds; a C++ compiler plus CMake, because DuckDB is built from source; and Rust 1.98, because polars 0.55 uses standard library APIs stabilised after 1.92 (&lt;code&gt;std::hint::cold_path&lt;/code&gt; among them) and won't compile on anything older. The demo pins 1.98 in &lt;code&gt;rust-toolchain.toml&lt;/code&gt;, so &lt;code&gt;rustup&lt;/code&gt; fetches the right compiler on its own, and the other two are one package each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;protobuf-compiler build-essential cmake   &lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;protobuf cmake                                    &lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then this &lt;code&gt;Cargo.toml&lt;/code&gt;, which is the part worth copying exactly. The &lt;code&gt;polars&lt;/code&gt; feature list is the demo's own needs (lazy frames, IPC, rolling windows) plus four I found the slow way on 0.54 so you don't have to: &lt;code&gt;parquet&lt;/code&gt;, &lt;code&gt;strings&lt;/code&gt; and &lt;code&gt;temporal&lt;/code&gt;, without which &lt;code&gt;lazy&lt;/code&gt; doesn't compile, and &lt;code&gt;timezones&lt;/code&gt;, without which a timestamp column from Spark won't print:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="c"&gt;# The official client. Its `polars` feature (to_polars()) is left off: it pins polars 0.54.&lt;/span&gt;
&lt;span class="n"&gt;spark-connect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"apache-spark-connect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"4.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# arrow-rs (the Rust Arrow library), at DuckDB 2.0's major. The client uses 58 internally.&lt;/span&gt;
&lt;span class="n"&gt;arrow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"60"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"prettyprint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# duckdb-rs for DuckDB 2.0, statically linked. `vtab-arrow` lets DuckDB query arrow-rs batches.&lt;/span&gt;
&lt;span class="n"&gt;duckdb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"1.20000.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"bundled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"vtab-arrow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"appender-arrow"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# The newest polars crate. It reads Arrow IPC with its own Arrow implementation.&lt;/span&gt;
&lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0.55.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"lazy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ipc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ipc_streaming"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dtype-full"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"fmt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"timezones"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"rolling_window"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pct_change"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"round_series"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"parquet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"strings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"temporal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two version facts are worth knowing before you touch that file. DuckDB 2.0 came out on 2026-10-21, and its Rust crate (&lt;code&gt;duckdb&lt;/code&gt; 1.20000.1; the crate encodes DuckDB 2.0.0 in the minor version) is built on arrow-rs 60, while the client is still on 58. Different arrow-rs majors are different Rust types, so a &lt;code&gt;RecordBatch&lt;/code&gt; from the client won't type-check where DuckDB wants one; the hand-off is the Arrow IPC bytes from &lt;code&gt;to_arrow()&lt;/code&gt;, which any arrow-rs major can read. Polars doesn't use arrow-rs at all; it ships its own Arrow implementation (&lt;code&gt;polars-arrow&lt;/code&gt;) and reads the same IPC bytes. The client's &lt;code&gt;to_polars()&lt;/code&gt; does exactly that internally, but against polars 0.54, so the demo leaves that feature off and calls Polars' IPC reader itself, which works with whatever polars you have. As for Polars 2.0, it's a Python release (2.0.0 on 2026-10-06, 2.1 since). There's no 2.x &lt;code&gt;polars&lt;/code&gt; crate yet, and the 2.x source only builds with Polars' own patched &lt;code&gt;hashbrown&lt;/code&gt; and a nightly toolchain, so the newest crate, 0.55.2, is what a Rust program gets. (The client's own &lt;a href="https://apache.github.io/spark-connect-rust/installation/" rel="noopener noreferrer"&gt;installation page&lt;/a&gt; says Rust 1.70 or later, which is true of the client alone; Polars is what raises the floor.) The first build took 4m23s in a clean container, since DuckDB compiles from source, and a rebuild after a code change takes seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Some data to play with
&lt;/h2&gt;

&lt;p&gt;Nothing is downloaded. Spark builds a 20-million-row orders table from &lt;code&gt;range()&lt;/code&gt; and hash functions, so the whole demo runs anywhere a Spark 4.2 server is reachable and nobody has to find a dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sc://localhost:15002"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.get_or_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Spark {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="nf"&gt;.version&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="nf"&gt;.sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"CREATE TABLE IF NOT EXISTS orders USING parquet AS
     SELECT id AS order_id,
            element_at(array('sfo','lax','sea','nyc','chi','aus','den','pdx','phx','mia','bos','atl'),
                       CAST(pmod(xxhash64(id, 7), 12) AS INT) + 1)            AS city,
            timestamp_seconds(1756684800 + pmod(xxhash64(id, 1), 2592000))     AS ts,
            CAST(round(5 + pmod(xxhash64(id, 2), 9500) / 100.0, 2) AS DOUBLE)  AS amount,
            CAST(1 + pmod(xxhash64(id, 3), 6) AS INT)                         AS items,
            element_at(array('delivered','delivered','delivered','cancelled','refunded'),
                       CAST(pmod(xxhash64(id, 4), 5) AS INT) + 1)             AS status
     FROM range(20000000)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.collect()&lt;/code&gt; on the end is not a typo. Everything in this client is lazy, the same as PySpark: &lt;code&gt;spark.sql()&lt;/code&gt; builds a plan, and nothing runs on the server until an action asks for a result, which for a &lt;code&gt;CREATE TABLE&lt;/code&gt; means you have to ask for its (empty) result on purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Spark 4.2.0 at sc://localhost:15002
  orders: 20000000 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Getting a result out as Arrow (four ways, and one of them is wrong)
&lt;/h2&gt;

&lt;p&gt;The client has four ways worth knowing to turn a DataFrame into something you hold in Rust (there are others, such as &lt;code&gt;to_local_iterator()&lt;/code&gt; for streaming rows and &lt;code&gt;to_datafusion()&lt;/code&gt; behind a feature flag), and picking the right one is most of the performance story in this blog:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;You get&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Vec&amp;lt;Row&amp;gt;&lt;/code&gt;, each value boxed as a &lt;code&gt;Value&lt;/code&gt; enum&lt;/td&gt;
&lt;td&gt;You want to look at a few rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collect_record_batches()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Vec&amp;lt;arrow::RecordBatch&amp;gt;&lt;/code&gt;, the server's batches as decoded&lt;/td&gt;
&lt;td&gt;The result is going to DataFusion or your own arrow-rs 58 code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;to_arrow()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Vec&amp;lt;u8&amp;gt;&lt;/code&gt;, one Arrow IPC file&lt;/td&gt;
&lt;td&gt;The result is going to a file, a socket, or another Arrow library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;to_polars()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;polars::DataFrame&lt;/code&gt; (polars 0.54)&lt;/td&gt;
&lt;td&gt;The result is going to Polars and you're on 0.54; otherwise read &lt;code&gt;to_arrow()&lt;/code&gt; with Polars' &lt;code&gt;IpcReader&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One city's orders, as batches and as one IPC file (the form DuckDB and Polars get below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="nf"&gt;.sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT order_id, city, ts, amount, items, status FROM orders WHERE city = 'sfo'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sfo&lt;/span&gt;&lt;span class="nf"&gt;.collect_record_batches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ipc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sfo&lt;/span&gt;&lt;span class="nf"&gt;.to_arrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;176 RecordBatches, 1667285 rows
arrow-rs schema: order_id: Int64, city: Utf8, ts: Timestamp(µs, "Etc/UTC"),
                 amount: Float64, items: Int32, status: Utf8
as Arrow IPC: 77.3 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The batch count is the server's choice, not yours. Connect caps each batch at &lt;code&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/code&gt; rows (default 10,000) and at a byte size, &lt;code&gt;spark.connect.grpc.arrow.maxBatchSize&lt;/code&gt; (default 128 MiB in Spark 4.x, of which it uses 70%), and the client hands you the batches exactly as they arrived. Here the row cap is the one that bites (176 batches rather than the 167 the cap alone would give, since a batch never spans two partitions), and &lt;code&gt;arrow::compute::concat_batches&lt;/code&gt; joins the pieces into one when a consumer wants that.&lt;/p&gt;

&lt;p&gt;The same 1.67 million rows, each way, on one machine with the server in a container on the same host. Each path re-runs the query on the server, and each number includes that scan (an earlier count and sum on the same table had already put the files in the OS page cache and warmed up the JVM):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collect_record_batches()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;to_arrow()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;477&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1475&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Rows cost about twice what batches cost for the same data.&lt;/strong&gt; Most of the difference is client-side work. (These are single runs: &lt;code&gt;to_arrow()&lt;/code&gt; calls &lt;code&gt;collect_record_batches()&lt;/code&gt; internally and still came in faster, so the noise is a couple of hundred milliseconds; the doubling for rows is well outside it.) In the client's source, &lt;code&gt;collect()&lt;/code&gt; decodes the same Arrow batches and then builds a &lt;code&gt;Row&lt;/code&gt; for every row and a &lt;code&gt;Value&lt;/code&gt; for every cell, which is a perfectly reasonable thing to do for twenty rows you intend to print and a silly thing to do for 1.67 million you intend to hand to DuckDB. If the result is going to another engine, it should never pass through &lt;code&gt;collect()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: DuckDB 2.0 gets the batch
&lt;/h2&gt;

&lt;p&gt;First the result crosses from the client's arrow-rs to DuckDB's: the &lt;code&gt;to_arrow()&lt;/code&gt; bytes from step 2, read with arrow 60's IPC reader and joined into one batch. For the 1.67 million rows that takes 70 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;arrow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FileReader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// arrow 60, DuckDB's major&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;ipc_to_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;FileReader&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Cursor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="nf"&gt;.schema&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="py"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;concat_batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;duckdb-rs has an Arrow table function behind the &lt;code&gt;vtab-arrow&lt;/code&gt; feature. In DuckDB 2.0's crate you register a &lt;code&gt;RecordBatch&lt;/code&gt; by wrapping it in an &lt;code&gt;ArrowBatchRegistration&lt;/code&gt;, which you pass to &lt;code&gt;arrow(?)&lt;/code&gt; as a query parameter. The batch isn't copied; it stays registered until the registration is dropped. A view can't take a prepared parameter, so to get a view the registration goes into a DuckDB variable first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;vtab&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;arrow&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;ArrowBatchRegistration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ArrowVTab&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;arrow_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ArrowBatchRegistration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ArrowBatchRegistration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SET VARIABLE {name}_batch = ?"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.execute_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"CREATE VIEW {name} AS SELECT * FROM arrow(getvariable('{name}_batch'))"&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open_in_memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="py"&gt;.register_table_function&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ArrowVTab&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"arrow"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;arrow_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ipc_to_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_surge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;arrow_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"surge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;surge_schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ts_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// built in Rust; its timestamps use Spark's ts type&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the registrations in variables for as long as you query the views. Dropping one unregisters the batch, and the view then fails with &lt;code&gt;is not registered&lt;/code&gt; rather than reading freed memory, which is the right failure but a confusing one if you discarded the return value with a &lt;code&gt;?;&lt;/code&gt;. (DuckDB 1.5's crate took two integers and a pair of &lt;code&gt;::UBIGINT&lt;/code&gt; casts instead, and kept every batch alive for the life of the process; the new API is an improvement on both counts.)&lt;/p&gt;

&lt;p&gt;Now the reason I brought DuckDB in at all. The &lt;code&gt;surge&lt;/code&gt; table is a per-six-hour price multiplier that exists only in the client process, and each order needs the latest multiplier at or before its timestamp. (It's built in Rust, with its &lt;code&gt;starts_at&lt;/code&gt; column given the same Arrow timestamp type and zone as Spark's &lt;code&gt;ts&lt;/code&gt;, so DuckDB sees two &lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt; columns rather than one with a zone and one without.) That is an ASOF join (match each row to the nearest earlier key instead of an equal one), which DuckDB has as a first-class SQL operator. Spark has an as-of join inside it (pandas-on-Spark's &lt;code&gt;merge_asof&lt;/code&gt;, and an &lt;code&gt;AsOfJoin&lt;/code&gt; relation in the Connect protocol), but 4.2 has no SQL syntax for it (4.3 adds &lt;code&gt;ASOF JOIN&lt;/code&gt;, off by default) and the Rust client doesn't expose it, so from Rust today this part is DuckDB's job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;surged&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;surged&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
    &lt;span class="n"&gt;ASOF&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;surge&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;surged&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;surged_revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_multiplier&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;surged&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------+--------+------------+----------------+----------------+
| day        | orders | revenue    | surged_revenue | avg_multiplier |
+------------+--------+------------+----------------+----------------+
| 2025-09-01 | 55829  | 2933784.48 | 3482949.81     | 1.1868         |
| 2025-09-02 | 55924  | 2927311.35 | 3476037.04     | 1.1881         |
| 2025-09-03 | 55491  | 2913342.93 | 3459069.6      | 1.1877         |
...
  PASS  DuckDB count == Spark count: 1667285
  PASS  DuckDB sum(amount) == Spark sum(amount): 87512653.61 vs 87512653.61
  PASS  ASOF JOIN kept every order: 1667285
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double cast on &lt;code&gt;ts&lt;/code&gt; is the one seam in this step. Spark's &lt;code&gt;timestamp&lt;/code&gt; arrives tagged with the session time zone (&lt;code&gt;Timestamp(µs, "Etc/UTC")&lt;/code&gt;), DuckDB maps that to &lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt;, and a direct cast to &lt;code&gt;DATE&lt;/code&gt; fails with &lt;code&gt;Conversion Error: Unimplemented type for cast (TIMESTAMP WITH TIME ZONE -&amp;gt; DATE)&lt;/code&gt;. Zone-aware casts are what DuckDB's ICU extension provides, and the &lt;code&gt;bundled&lt;/code&gt; build doesn't include it (duckdb-rs has an &lt;code&gt;icu&lt;/code&gt; feature that does). Casting through plain &lt;code&gt;TIMESTAMP&lt;/code&gt; sidesteps it for a UTC session; for any other zone, add the feature or do the date arithmetic in Spark before collecting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One practical rule for this step, and it's the one that surprised me.&lt;/strong&gt; Registering the batch is free, and a count and sum through the view take 65 ms. But the identical ASOF rollup took 1901 ms through the view and 139 ms on a native table that cost 146 ms to create from the same view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_native&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 146 ms for 1.67M rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if you're going to run more than one query over the rows, or any join, copy them into a table first; the copy pays for itself on the first join. I had assumed the zero-copy view was the fast path because zero-copy sounds like the fast path, and it isn't. I haven't profiled why. Part of it is in the duckdb-rs source: every scan through the table function converts the Arrow vectors into DuckDB's own format again, so each query pays the copy that the native table paid once. My guess for the rest is that the view is a single sequential source with no statistics. The gap was the same on DuckDB 1.5.6 (2226 ms against 177), so 2.0 didn't change this rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Polars gets Spark's aggregate
&lt;/h2&gt;

&lt;p&gt;Spark is the right place for the 20-million-row group-by. Polars is the nicer place for what comes after, which here is a per-city rolling window and a day-over-day change, both of which are one expression in Polars and a window specification you'd have to think about in Spark SQL. &lt;code&gt;to_arrow()&lt;/code&gt; runs the query and gives you the result as an in-memory Arrow IPC file, and Polars' &lt;code&gt;IpcReader&lt;/code&gt; turns that into a &lt;code&gt;polars::DataFrame&lt;/code&gt;. That's all &lt;code&gt;to_polars()&lt;/code&gt; does inside the client; doing it yourself means it works with polars 0.55. It costs a copy into IPC bytes and a decode into Polars' own buffers, because Polars has its own Arrow implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="nf"&gt;.sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"SELECT city, CAST(ts AS DATE) AS day, count(*) AS orders, round(sum(amount), 2) AS revenue
     FROM orders GROUP BY 1, 2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pl_daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;IpcReader&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Cursor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="nf"&gt;.to_arrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Spark runs the group-by; 360 rows cross as IPC&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;rolling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl_daily&lt;/span&gt;&lt;span class="nf"&gt;.lazy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.sort&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nn"&gt;SortMultipleOptions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;.with_columns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.rolling_mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RollingOptionsFixedWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;window_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_periods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="nf"&gt;.over&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
            &lt;span class="nf"&gt;.alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"revenue_7d"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.pct_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.over&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"revenue_pct"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;polars schema: {"city": String, "day": Date, "orders": Int64, "revenue": Float64}
┌──────┬────────────┬────────┬───────────┬────────────┬─────────────┐
│ city ┆ day        ┆ orders ┆ revenue   ┆ revenue_7d ┆ revenue_pct │
│ str  ┆ date       ┆ i64    ┆ f64       ┆ f64        ┆ f64         │
╞══════╪════════════╪════════╪═══════════╪════════════╪═════════════╡
│ atl  ┆ 2025-09-01 ┆ 55524  ┆ 2.9116e6  ┆ 2.9116e6   ┆ null        │
│ atl  ┆ 2025-09-02 ┆ 55327  ┆ 2.9054e6  ┆ 2.9085e6   ┆ -0.002129   │
│ atl  ┆ 2025-09-03 ┆ 55638  ┆ 2.9335e6  ┆ 2.9168e6   ┆ 0.009665    │
...
  PASS  Polars sum(orders) == Spark count(orders): 20000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting the aggregate into Polars took 695 ms here (nearly all of it Spark running the group-by) and the rolling computation after it 5 ms, which is the ratio you'd hope for when Spark is doing the heavy part. (Since polars 0.54, &lt;code&gt;over()&lt;/code&gt; returns a &lt;code&gt;Result&lt;/code&gt;, hence the &lt;code&gt;?&lt;/code&gt; inside the expression list; nothing I read warned me about that.) The IPC copy is cheap: once &lt;code&gt;to_arrow()&lt;/code&gt; has produced the 77.3 MiB IPC file for the whole 1.67-million-row slice, reading it into Polars takes 72 ms.&lt;/p&gt;

&lt;p&gt;A Polars frame can keep going into DuckDB too, by the same route the Spark result took. Polars writes the Arrow IPC stream format with &lt;code&gt;IpcStreamWriter&lt;/code&gt;, arrow-rs reads it with &lt;code&gt;StreamReader&lt;/code&gt;, and the result is a &lt;code&gt;RecordBatch&lt;/code&gt; for the same table function; for the 360 rolling rows that is 17.5 KiB and 1 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nn"&gt;IpcStreamWriter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rolling&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;StreamReader&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Cursor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="py"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_rolling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;arrow_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rolling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;concat_batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.schema&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that &lt;code&gt;rolling&lt;/code&gt; is a DuckDB view like &lt;code&gt;orders&lt;/code&gt; was, and &lt;code&gt;QUALIFY row_number() OVER (PARTITION BY city ORDER BY revenue_7d DESC) = 1&lt;/code&gt; (a clause that filters on a window function's result, which Spark SQL also picked up in 4.2) picks each city's best week out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Sending the answer back to Spark
&lt;/h2&gt;

&lt;p&gt;Spark Connect already takes Arrow inbound: &lt;code&gt;createDataFrame&lt;/code&gt; is sent as a &lt;code&gt;LocalRelation&lt;/code&gt;, the protocol's message for client-side data, whose payload is Arrow IPC bytes (step 6 of the OCaml post was writing exactly that by hand). The Rust client's public entry point for it is &lt;code&gt;create_dataframe(rows, schema)&lt;/code&gt;, which takes &lt;code&gt;Row&lt;/code&gt; values. So a &lt;code&gt;RecordBatch&lt;/code&gt; that came out of DuckDB gets unpacked into rows first (a loop over columns and a &lt;code&gt;match&lt;/code&gt; on the Arrow type; the demo's &lt;code&gt;record_batch_to_rows&lt;/code&gt; is about forty lines) and then written like any other DataFrame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;record_batch_to_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;surge_daily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="nf"&gt;.create_dataframe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.write&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"overwrite"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.save_as_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sfo_surge_daily"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;struct&amp;lt;day:date,orders:bigint,revenue:double,surged_revenue:double,avg_multiplier:double&amp;gt;
  PASS  Spark table rows == DuckDB rows: 30
  PASS  Spark sum(surged_revenue) == DuckDB sum: 103926081.03 vs 103926081.02
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 157 ms for 30 rows, timed as one step from Arrow to the saved table. (The cent between the sums is floating point, not lost data: Spark and DuckDB add the same thirty doubles in a different order, so the check allows five cents rather than demanding an exact match.) The row conversion grows with row count the same way &lt;code&gt;collect()&lt;/code&gt; does in the other direction, so for big data going back, write it somewhere both sides can read and &lt;code&gt;spark.read()&lt;/code&gt; it instead. A &lt;code&gt;create_dataframe&lt;/code&gt; that took batches directly would be a very welcome addition to the client, since the IPC writer it needs is already in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spark Type Mapping to DuckDB and Polars
&lt;/h2&gt;

&lt;p&gt;The full table with the values is in the demo README; this is the part to keep nearby:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spark type&lt;/th&gt;
&lt;th&gt;arrow-rs&lt;/th&gt;
&lt;th&gt;DuckDB&lt;/th&gt;
&lt;th&gt;Polars&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tinyint&lt;/code&gt; to &lt;code&gt;bigint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Int8&lt;/code&gt; to &lt;code&gt;Int64&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TINYINT&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;i8&lt;/code&gt; to &lt;code&gt;i64&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;float&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Float32&lt;/code&gt;, &lt;code&gt;Float64&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FLOAT&lt;/code&gt;, &lt;code&gt;DOUBLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;f32&lt;/code&gt;, &lt;code&gt;f64&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decimal(p,s)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Decimal128(p, s)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DECIMAL(p,s)&lt;/code&gt;, digits intact&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;decimal[p,s]&lt;/code&gt;, digits intact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;string&lt;/code&gt; (a &lt;code&gt;char(n)&lt;/code&gt; literal also arrives as &lt;code&gt;string&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Utf8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;str&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;binary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Binary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BLOB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;binary&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BOOLEAN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bool&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Date32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;date&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Timestamp(µs, "&amp;lt;session zone&amp;gt;")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;datetime[μs, &amp;lt;zone&amp;gt;]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timestamp_ntz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Timestamp(µs)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TIMESTAMP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;datetime[μs]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;array&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;List&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;T[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list[T]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map&amp;lt;K,V&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Map&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MAP(K, V)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list[struct[2]]&lt;/code&gt; (Polars has no map type)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;struct&amp;lt;...&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Struct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;STRUCT(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;struct[n]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interval day to second&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Duration(µs)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INTERVAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;duration[μs]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;interval year to month&lt;/code&gt;, calendar &lt;code&gt;interval&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Interval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INTERVAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;polars 0.55 panics building the frame (calendar intervals can be imported as a struct with &lt;code&gt;POLARS_IMPORT_INTERVAL_AS_STRUCT=1&lt;/code&gt;, marked unstable); cast to a number in Spark first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fails on the server with &lt;code&gt;UNSUPPORTED_TIME_TYPE&lt;/code&gt;: Spark's Arrow serialiser doesn't handle TIME, so it never leaves&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;variant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Struct(value Binary, metadata Binary)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;STRUCT(value BLOB, metadata BLOB)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;struct[2]&lt;/code&gt;; opaque bytes in both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;void&lt;/code&gt; (a bare &lt;code&gt;NULL&lt;/code&gt; literal)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;duckdb-rs can't bind it (&lt;code&gt;Unsupported data type: Null&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, &lt;code&gt;timestamp&lt;/code&gt; carries the server's session zone into DuckDB's and Polars' type systems (which is correct, and is why the &lt;code&gt;DATE&lt;/code&gt; cast in step 3 needed care); &lt;code&gt;timestamp_ntz&lt;/code&gt; doesn't. And Polars has no year-month interval type, so turn any such column into a number in Spark before it heads that way. Everything else, decimals included, keeps its precision and lands as a native column type in both engines (in pandas a decimal column becomes a column of Python &lt;code&gt;Decimal&lt;/code&gt; objects, exact but slow, which is a thing I'd rather not think about).&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;All of it ran on one machine with the server in a container on the same host, so the Spark-to-client times contain no real network; across a network the transfer would dominate and the gap between &lt;code&gt;collect()&lt;/code&gt; and batches would matter less in proportion. Every timing is a single run in a warm process, not a benchmark harness, and the DuckDB view-versus-table gap is an order of magnitude, not a precise ratio. The server ran with default Arrow batch settings. The type sweep didn't include GEOMETRY or GEOGRAPHY, which are new in Spark 4.2. Neither &lt;code&gt;to_datafusion()&lt;/code&gt; nor the client's WebAssembly UDF path (Rust functions compiled to wasm and run on the executors) was exercised; the latter is its own blog and probably a fun one. The Polars findings are for the 0.55.2 crate, not Polars 2.0, which Rust can't use yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summarizing the Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server:&lt;/strong&gt; any Spark 4.2+ Connect server; &lt;code&gt;docker compose up -d --wait&lt;/code&gt; with the official image and a health check on the port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build:&lt;/strong&gt; &lt;code&gt;protoc&lt;/code&gt;, a C++ toolchain and CMake installed; the Cargo.toml above, copied exactly; &lt;code&gt;rust-toolchain.toml&lt;/code&gt; pinned to 1.98.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect:&lt;/strong&gt; &lt;code&gt;SparkSession::builder().remote("sc://host:15002").get_or_create()?&lt;/code&gt;, then print &lt;code&gt;spark.version()&lt;/code&gt; before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get results out as Arrow:&lt;/strong&gt; &lt;code&gt;to_arrow()&lt;/code&gt; for DuckDB 2.0 and Polars (the IPC bytes cross any arrow-rs major), &lt;code&gt;collect_record_batches()&lt;/code&gt; for your own arrow-rs 58 code, &lt;code&gt;collect()&lt;/code&gt; only for rows you intend to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB:&lt;/strong&gt; register &lt;code&gt;ArrowVTab&lt;/code&gt;, wrap the batch in an &lt;code&gt;ArrowBatchRegistration&lt;/code&gt; and keep it alive, put a view over it, and copy it into a table if you'll query it more than once or join it at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polars:&lt;/strong&gt; &lt;code&gt;IpcReader&lt;/code&gt; over the &lt;code&gt;to_arrow()&lt;/code&gt; bytes and go; turn on the &lt;code&gt;timezones&lt;/code&gt; feature before the first timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back to Spark:&lt;/strong&gt; &lt;code&gt;create_dataframe(rows, schema)&lt;/code&gt; for small results; shared storage for large ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Types:&lt;/strong&gt; decide between &lt;code&gt;timestamp&lt;/code&gt; and &lt;code&gt;timestamp_ntz&lt;/code&gt; before data crosses a boundary, and keep intervals out of Polars.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idiot's guide said to check that the client you pick supports what you need before you build on it. For the Rust client that check comes out well, and what it's best at is the thing the protocol was designed around in the first place: the result is already Arrow, so the only question left is where you want to send it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/apache/spark-connect-rust" rel="noopener noreferrer"&gt;apache/spark-connect-rust&lt;/a&gt; and its &lt;a href="https://apache.github.io/spark-connect-rust/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;: &lt;a href="https://apache.github.io/spark-connect-rust/installation/" rel="noopener noreferrer"&gt;installation&lt;/a&gt;, &lt;a href="https://apache.github.io/spark-connect-rust/quickstart/" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;, &lt;a href="https://apache.github.io/spark-connect-rust/dataframes/" rel="noopener noreferrer"&gt;DataFrames&lt;/a&gt;, &lt;a href="https://apache.github.io/spark-connect-rust/architecture/" rel="noopener noreferrer"&gt;architecture&lt;/a&gt;, &lt;a href="https://apache.github.io/spark-connect-rust/compatibility/" rel="noopener noreferrer"&gt;compatibility&lt;/a&gt;, and &lt;a href="https://apache.github.io/spark-connect-rust/udfs/" rel="noopener noreferrer"&gt;Rust UDFs via WebAssembly&lt;/a&gt;, the part this blog skipped&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.rs/apache-spark-connect" rel="noopener noreferrer"&gt;&lt;code&gt;apache-spark-connect&lt;/code&gt; on docs.rs&lt;/a&gt;, for &lt;code&gt;collect_record_batches&lt;/code&gt;, &lt;code&gt;to_arrow&lt;/code&gt;, &lt;code&gt;to_polars&lt;/code&gt; and &lt;code&gt;to_datafusion&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/duckdb/duckdb/releases/tag/v2.0.0" rel="noopener noreferrer"&gt;DuckDB 2.0.0's release notes&lt;/a&gt; and &lt;a href="https://github.com/duckdb/duckdb-rs/releases/tag/v1.20000.1" rel="noopener noreferrer"&gt;duckdb-rs 1.20000.1's&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/spark-connect/" rel="noopener noreferrer"&gt;Spark Connect overview&lt;/a&gt;, which lists the Go, Rust and Swift clients, and the &lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Connect server docs&lt;/a&gt; for starting one without Docker&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/duckdb/duckdb-rs" rel="noopener noreferrer"&gt;duckdb-rs&lt;/a&gt; and its &lt;code&gt;vtab-arrow&lt;/code&gt; feature; &lt;a href="https://duckdb.org/docs/stable/guides/sql_features/asof_join" rel="noopener noreferrer"&gt;DuckDB's ASOF join&lt;/a&gt;, which Spark 4.2 has no SQL for&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pola.rs/" rel="noopener noreferrer"&gt;Polars&lt;/a&gt; and &lt;a href="https://crates.io/crates/polars-arrow" rel="noopener noreferrer"&gt;&lt;code&gt;polars-arrow&lt;/code&gt;&lt;/a&gt;, its own Arrow implementation and the reason for the IPC hop&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/09_spark_connect_rust" rel="noopener noreferrer"&gt;The demo&lt;/a&gt;, with the full run log and the complete type table in its README&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>sparkconnect</category>
      <category>rust</category>
      <category>duckdb</category>
    </item>
    <item>
      <title>The Ultimate Guide to Apache Spark 4.3</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Mon, 09 Nov 2026 19:52:28 +0000</pubDate>
      <link>https://dev.to/lisancao/the-ultimate-guide-to-apache-spark-43-4fcl</link>
      <guid>https://dev.to/lisancao/the-ultimate-guide-to-apache-spark-43-4fcl</guid>
      <description>&lt;h2&gt;
  
  
  A release about time (mostly)
&lt;/h2&gt;

&lt;p&gt;Spark 4.3.0 came out on October 30, including the more in depth &lt;a href="https://www.databricks.com/blog/introducing-apache-spark-43-now-available-databricks-runtime-19" rel="noopener noreferrer"&gt;Databricks release blog&lt;/a&gt;, which means my feeds are about to fill with listicles that paste the release notes into a numbered list and call it a day. I wanted to do something a little more in depth by reviewing each of the major features in this release as closely as possible, so that people can get a better idea of the expected behaviours and what it's like to actually run the changes. Funnily, I expect a blog like this to have a shorter shelf life than usual as these releases are currently en track for a quarterly cadence, meaning Spark is about to get much more fully featured in the next year as it moves towards 5.0.&lt;/p&gt;

&lt;p&gt;Reading the &lt;a href="https://spark.apache.org/releases/spark-release-4-3-0.html" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; end to end, I kept noticing that the big items all circle the same thing from different angles. Real-time Mode is about how quickly one record gets through a stateful query; SCD Type 2 is about how long a row's history survives; ASOF JOIN is about which version of a fact was true at a given moment; and nanosecond timestamps are about how finely Spark can tell two moments apart in the first place. The remaining features (Project Feather, the Spark Connect observability work, &lt;code&gt;DataFrame.zip&lt;/code&gt;, Python UDF transpilation) are about a different clock, which is yours: how long you wait between writing a query and seeing it run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of Spark 4.3 is about time, both the data's and yours: the scheduler learned to pipeline shuffles, so stateful streaming queries now run at millisecond latency; Auto CDC learned to keep history; SQL learned point-in-time joins and nanosecond precision; and the rest of the release shortens the loop between writing Spark code and finding out whether it works.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every example here is a runnable script in &lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/16_spark_43_guide" rel="noopener noreferrer"&gt;spark-demos/demos/16_spark_43_guide&lt;/a&gt;, and every clip below is that script running against Spark 4.3.0 (&lt;code&gt;pip install pyspark==4.3.0&lt;/code&gt;) on my machine. The Real-time Mode clips come from the &lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/14_real_time_mode" rel="noopener noreferrer"&gt;Real-time Mode demo&lt;/a&gt;, which is a teaser for one of my upcoming Spark Streaming blog posts ;) &lt;/p&gt;

&lt;p&gt;Below is a rundown of the new features and how to enable or take advantage of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;On by default?&lt;/th&gt;
&lt;th&gt;How to turn it on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stateful Real-time Mode&lt;/td&gt;
&lt;td&gt;Structured Streaming&lt;/td&gt;
&lt;td&gt;Yes (with the Real-time trigger)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.trigger(realTime="...")&lt;/code&gt;, update mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCD Type 2 in Auto CDC&lt;/td&gt;
&lt;td&gt;Declarative Pipelines&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;STORED AS SCD TYPE 2&lt;/code&gt; / &lt;code&gt;stored_as_scd_type=2&lt;/code&gt;; target must support DSv2 row-level ops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shuffle-free single-task execution&lt;/td&gt;
&lt;td&gt;Project Feather&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spark.sql.optimizer.singleTaskExecution.enabled=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrow-native Dataset cache&lt;/td&gt;
&lt;td&gt;Project Feather&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark.sql.cache.serializer=...ArrowCachedBatchSerializer&lt;/code&gt; (static)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operation IDs, REST endpoints, &lt;code&gt;with&lt;/code&gt; sessions&lt;/td&gt;
&lt;td&gt;Spark Connect&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent local Connect server&lt;/td&gt;
&lt;td&gt;Spark Connect&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SPARK_LOCAL_CONNECT_REUSE=1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DataFrame.zip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PySpark, Connect&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python UDF transpilation&lt;/td&gt;
&lt;td&gt;PySpark&lt;/td&gt;
&lt;td&gt;No (experimental)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark.sql.experimental.optimizer.transpilePyUDFs=true&lt;/code&gt; plus ANSI mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ASOF JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;No (preview)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spark.sql.join.asofJoin.enabled=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON_TABLE&lt;/code&gt;, VARIANT edit functions&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nanosecond timestamps&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;No (preview)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spark.sql.timestampNanosTypes.enabled=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UNNEST&lt;/code&gt;, &lt;code&gt;counter_diff&lt;/code&gt;, &lt;code&gt;jaro_winkler_similarity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading inside tar, zip and 7z archives&lt;/td&gt;
&lt;td&gt;File sources&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spark.sql.files.archive.reader.enabled=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Stateful Real-time Mode
&lt;/h2&gt;

&lt;p&gt;Real-time Mode landed in 4.1 as a new trigger for Structured Streaming: instead of slicing a stream into micro-batches and waiting for each one to be planned, scheduled and committed, the query runs long-lived tasks that process records as they arrive and checkpoint on a much longer interval. The catch in 4.1 and 4.2 was that it only ran stateless queries (filters, projections, stream-static joins), because anything stateful needs a shuffle, and a classic shuffle is a blocking barrier where every map task has to finish writing before a single reduce task can read.&lt;/p&gt;

&lt;p&gt;A new &lt;code&gt;PipelinedShuffleDependency&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58185" rel="noopener noreferrer"&gt;SPARK-58185&lt;/a&gt;) routes data through a new &lt;code&gt;StreamingShuffleManager&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57141" rel="noopener noreferrer"&gt;SPARK-57141&lt;/a&gt;), which pushes records to downstream tasks over Netty while the upstream tasks are still running. The &lt;code&gt;DAGScheduler&lt;/code&gt; now schedules a pipelined group of stages together, fails them together, and refuses to start a group without enough task slots (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58263" rel="noopener noreferrer"&gt;SPARK-58263&lt;/a&gt;, &lt;a href="https://issues.apache.org/jira/browse/SPARK-58398" rel="noopener noreferrer"&gt;SPARK-58398&lt;/a&gt;). This admission check prevents deadlock on a busy cluster: a consumer stage started without its producer would sit idle while holding cores. With that machinery in place, Real-time Mode supports streaming aggregation (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58635" rel="noopener noreferrer"&gt;SPARK-58635&lt;/a&gt;), &lt;code&gt;dropDuplicates&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58508" rel="noopener noreferrer"&gt;SPARK-58508&lt;/a&gt;) and Scala/Java &lt;code&gt;transformWithState&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57228" rel="noopener noreferrer"&gt;SPARK-57228&lt;/a&gt;), and is no longer marked &lt;code&gt;@Experimental&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIG9sZCBmaWxsOiM2YjRhOWIsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM2YjRhOWIsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIG5ldyBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICAgIHN1YmdyYXBoIE1CW1wiTWljcm8tYmF0Y2g6IFNvcnRTaHVmZmxlTWFuYWdlclwiXVxuICAgICAgICBNMVtcIm1hcCB0YXNrczxici8-d3JpdGUgYWxsIG91dHB1dFwiXTo6Om9sZCAtLT4gV1tcInNodWZmbGUgZmlsZXM8YnIvPihiYXJyaWVyKVwiXTo6Om9sZCAtLT4gUjFbXCJyZWR1Y2UgdGFza3M8YnIvPnN0YXJ0IGFmdGVyXCJdOjo6b2xkXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggUlRbXCJSZWFsLXRpbWU6IFN0cmVhbWluZ1NodWZmbGVNYW5hZ2VyXCJdXG4gICAgICAgIE0yW1wibWFwIHRhc2tzPGJyLz4obG9uZy1ydW5uaW5nKVwiXTo6Om5ldyAtLT58XCJyZWNvcmRzIHB1c2hlZDxici8-YXMgdGhleSBhcnJpdmVcInwgUjJbXCJzdGF0ZWZ1bCB0YXNrczxici8-KHJ1bm5pbmcgYXQgdGhlIHNhbWUgdGltZSlcIl06OjpuZXdcbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIG9sZCBmaWxsOiM2YjRhOWIsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM2YjRhOWIsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIG5ldyBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICAgIHN1YmdyYXBoIE1CW1wiTWljcm8tYmF0Y2g6IFNvcnRTaHVmZmxlTWFuYWdlclwiXVxuICAgICAgICBNMVtcIm1hcCB0YXNrczxici8-d3JpdGUgYWxsIG91dHB1dFwiXTo6Om9sZCAtLT4gV1tcInNodWZmbGUgZmlsZXM8YnIvPihiYXJyaWVyKVwiXTo6Om9sZCAtLT4gUjFbXCJyZWR1Y2UgdGFza3M8YnIvPnN0YXJ0IGFmdGVyXCJdOjo6b2xkXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggUlRbXCJSZWFsLXRpbWU6IFN0cmVhbWluZ1NodWZmbGVNYW5hZ2VyXCJdXG4gICAgICAgIE0yW1wibWFwIHRhc2tzPGJyLz4obG9uZy1ydW5uaW5nKVwiXTo6Om5ldyAtLT58XCJyZWNvcmRzIHB1c2hlZDxici8-YXMgdGhleSBhcnJpdmVcInwgUjJbXCJzdGF0ZWZ1bCB0YXNrczxici8-KHJ1bm5pbmcgYXQgdGhlIHNhbWUgdGltZSlcIl06OjpuZXdcbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 1" width="568" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The query itself doesn't change shape. In this deduplication and windowed-count step from the bookshop demo, only the trigger and output mode are specific to Real-time Mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;trending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clicks&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withWatermark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10 seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropDuplicates&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                       &lt;span class="c1"&gt;# resent clicks counted once
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10 seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aisle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;realTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30 seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# the whole change
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya9ddtot0h2jlzw32gdi.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya9ddtot0h2jlzw32gdi.gif" alt="Real-time Mode running dedup and a windowed count, measured in Real-time Mode and then with the streaming shuffle's flush wait at 1 ms" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Measured end to end on my machine (from the click's own timestamp to the broker's append time on the output topic, 200 clicks a second for 45 seconds), the same query looks like this in each mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;p50 ms&lt;/th&gt;
&lt;th&gt;p99 ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dedup + windowed count&lt;/td&gt;
&lt;td&gt;micro-batch&lt;/td&gt;
&lt;td&gt;237&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedup + windowed count&lt;/td&gt;
&lt;td&gt;Real-time&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedup + windowed count&lt;/td&gt;
&lt;td&gt;Real-time, flush wait 1 ms&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;transformWithState&lt;/code&gt; recommender&lt;/td&gt;
&lt;td&gt;micro-batch&lt;/td&gt;
&lt;td&gt;190&lt;/td&gt;
&lt;td&gt;396&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;transformWithState&lt;/code&gt; recommender&lt;/td&gt;
&lt;td&gt;Real-time&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;158&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 50 ms median comes from a timer. The streaming shuffle flushes when its buffer fills or &lt;code&gt;spark.shuffle.streaming.networkBufferMaxWaitTimeMs&lt;/code&gt; expires; the default is 50 ms, and a lightly loaded query does not fill the buffer first. Reducing the wait to 1 ms brought the median down to 2 ms at the cost of more small network writes, a tradeoff to tune per workload rather than a useful global default. (The shorter run in the clip measured a 67 ms median and 96 ms p99 at the default, then 2 ms and 3 ms at 1 ms; the table contains the longer benchmark, and this amount of variation is normal for a single-machine test.) Databricks reports p99 latencies of 136 ms for &lt;code&gt;dropDuplicates&lt;/code&gt;, 139 ms for a window aggregation and 141 ms for &lt;code&gt;transformWithState&lt;/code&gt; on its benchmark workloads (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58884" rel="noopener noreferrer"&gt;SPARK-58884&lt;/a&gt;). Those results are not guarantees, and neither are mine, which come from one desktop with Kafka running in Docker.&lt;/p&gt;

&lt;p&gt;For a stateful example, the recommender keeps a taste vector per shopper, built from book embeddings, in &lt;code&gt;transformWithState&lt;/code&gt; and rescores the catalog on every click. The recommendations therefore change a few milliseconds after each click:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fem42114vvn8y5fpmwtn2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fem42114vvn8y5fpmwtn2.gif" alt="One shopper browsing: each click goes through Kafka into a transformWithState processor and comes back as fresh picks a few milliseconds later" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Real-time Mode still has several limits. &lt;code&gt;transformWithState&lt;/code&gt; supports only Scala and Java, requires the RocksDB state store, and fires a processing-time timer on an idle key only when another row reaches its partition or the Real-time batch ends. &lt;code&gt;ASOF JOIN&lt;/code&gt; is not supported. Pipelined stages also require cores concurrently, so the cluster must be sized for the whole pipeline rather than its widest stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  SCD Type 2 in Auto CDC
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://systemsnull.net/posts/data-engineering-ai/auto-cdc-declarative-pipelines" rel="noopener noreferrer"&gt;Auto CDC&lt;/a&gt; arrived in 4.2 as the Declarative Pipelines way to turn a change feed into current state: give it keys, a sequencing column and a delete rule, and it keeps a target table in sync with MERGE, handling out-of-order and replayed events for you. In 4.2 that meant SCD Type 1, where the target only ever holds the latest version of each row. 4.3 adds SCD Type 2 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58321" rel="noopener noreferrer"&gt;SPARK-58321&lt;/a&gt;), in Python (&lt;code&gt;stored_as_scd_type=2&lt;/code&gt;, &lt;code&gt;track_history_column_list&lt;/code&gt;), in SQL (&lt;code&gt;STORED AS SCD TYPE 2&lt;/code&gt;, &lt;code&gt;TRACK HISTORY ON&lt;/code&gt;) and through Spark Connect, so the target keeps every version, each bounded by &lt;code&gt;__START_AT&lt;/code&gt; and &lt;code&gt;__END_AT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I reused the ghost kitchen from &lt;a href="https://systemsnull.net/posts/data-engineering-ai/auto-cdc-declarative-pipelines" rel="noopener noreferrer"&gt;that post&lt;/a&gt;, this time tracking its menu. Each dish change arrives on a Kafka topic as a JSON event, as it would from a CDC tool. The pipeline has a Python source that reads the topic,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dp.table&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;menu_changes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka.bootstrap.servers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost:29292&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subscribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;menu-changes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startingOffsets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;earliest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;CHANGE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e.*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and this one SQL statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Keep every version of every dish, not just the latest one.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;STREAMING&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;
&lt;span class="n"&gt;FLOW&lt;/span&gt; &lt;span class="n"&gt;AUTO&lt;/span&gt; &lt;span class="n"&gt;CDC&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu_changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;KEYS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dish_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;
&lt;span class="n"&gt;APPLY&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt;
&lt;span class="n"&gt;COLUMNS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;EXCEPT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;STORED&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;SCD&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;TRACK&lt;/span&gt; &lt;span class="n"&gt;HISTORY&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;station&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp42pa7nwyekwy8q6ypby.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp42pa7nwyekwy8q6ypby.gif" alt="The Kafka source for the menu change feed, then the Auto CDC flow in SQL with STORED AS SCD TYPE 2 and TRACK HISTORY ON (price, station)" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TRACK HISTORY ON&lt;/code&gt; controls which changes open a new version. Changes to unlisted columns update the current version in place, which prevents corrections such as typo fixes from creating history entries. Publishing change events in batches and running the pipeline after each batch shows both cases:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzncpy7b421fdzd0cxcaj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzncpy7b421fdzd0cxcaj.gif" alt="Batch 1: two dishes go on the menu, each with one open-ended version" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4sh55tpzfuqp3xuuxpa.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4sh55tpzfuqp3xuuxpa.gif" alt="Batch 2: the smash burger goes up to 15.00 and the tacos move to the plancha, so their old versions close at 2 and new ones open; khao soi arrives" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qiburoak90u5zd6tcaz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qiburoak90u5zd6tcaz.gif" alt="Batch 3: fixing the &amp;quot;Birra&amp;quot; typo updates the tacos' current row in place, and taking khao soi off the menu closes its window instead of erasing it" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the third run, the table contains the full history of each dish:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dish_id&lt;/th&gt;
&lt;th&gt;dish&lt;/th&gt;
&lt;th&gt;price&lt;/th&gt;
&lt;th&gt;station&lt;/th&gt;
&lt;th&gt;&lt;code&gt;__START_AT&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;__END_AT&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Birra tacos&lt;/td&gt;
&lt;td&gt;14.00&lt;/td&gt;
&lt;td&gt;grill&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Birria tacos&lt;/td&gt;
&lt;td&gt;14.00&lt;/td&gt;
&lt;td&gt;plancha&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Smash burger&lt;/td&gt;
&lt;td&gt;13.50&lt;/td&gt;
&lt;td&gt;grill&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Smash burger&lt;/td&gt;
&lt;td&gt;15.00&lt;/td&gt;
&lt;td&gt;grill&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Khao soi&lt;/td&gt;
&lt;td&gt;16.00&lt;/td&gt;
&lt;td&gt;wok&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The typo fix at sequence 3 did not open a third row for the tacos because &lt;code&gt;dish&lt;/code&gt; is not tracked; it updated the current version instead. Removing khao soi closed its window at sequence 2 rather than deleting it, preserving the previous menu state. &lt;code&gt;__START_AT&lt;/code&gt; and &lt;code&gt;__END_AT&lt;/code&gt; contain the sequencing value, which is an integer here but could instead be a commit timestamp.&lt;/p&gt;

&lt;p&gt;As with Type 1, the target must support DSv2 row-level operations because Auto CDC applies each micro-batch with &lt;code&gt;MERGE&lt;/code&gt;. Spark's built-in file formats, including Parquet, fail at startup with &lt;code&gt;AUTOCDC_TARGET_DOES_NOT_SUPPORT_MERGE&lt;/code&gt;; Apache Iceberg is the usual choice. Iceberg has not published a Spark 4.3 runtime yet (Maven Central currently stops at &lt;code&gt;iceberg-spark-runtime-4.1&lt;/code&gt;), so I used an Iceberg runtime built from a branch with Spark 4.3 support. The Iceberg runtime, Kafka connector jars and &lt;code&gt;spark.sql.extensions&lt;/code&gt; are static configurations and must be passed to &lt;code&gt;spark-pipelines run&lt;/code&gt;, which accepts &lt;code&gt;spark-submit&lt;/code&gt; arguments. Putting them in &lt;code&gt;spark-pipeline.yml&lt;/code&gt; makes the CLI apply them to a live session and fail with &lt;code&gt;CANNOT_MODIFY_CONFIG&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Feather: faster on small data
&lt;/h2&gt;

&lt;p&gt;Spark has always been a slightly absurd tool for a 500-row file because its planning assumes the data is large: a &lt;code&gt;groupBy&lt;/code&gt; gets a partial aggregate, a hash shuffle and a final aggregate, while an &lt;code&gt;orderBy&lt;/code&gt; adds a range shuffle. Each exchange creates a stage boundary and another scheduling round trip. That overhead disappears into the work on a cluster; on a laptop, in a test suite, or in an agent running hundreds of small queries, it becomes the work. Project Feather (&lt;a href="https://issues.apache.org/jira/browse/SPARK-56978" rel="noopener noreferrer"&gt;SPARK-56978&lt;/a&gt;) addresses that overhead, and 4.3 includes its first two pieces, both opt-in.&lt;/p&gt;

&lt;p&gt;Shuffle-free single-task execution (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57851" rel="noopener noreferrer"&gt;SPARK-57851&lt;/a&gt;, &lt;code&gt;spark.sql.optimizer.singleTaskExecution.enabled&lt;/code&gt;) notices when a query fragment reads something that fits in one partition (by default a single non-empty small file, or a local relation of up to 1,000 rows) and plans the scans, aggregations, distincts, windows, sorts and limits above it as one task with no exchanges at all. The same aggregation, with the flag off and then on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;single_task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.optimizer.singleTaskExecution.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;single_task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# a fresh DataFrame each time, like a notebook cell or a test
&lt;/span&gt;        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;small&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mu4bhf5jqpouozejf3c.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mu4bhf5jqpouozejf3c.gif" alt="The same small aggregation with single-task execution off (two Exchanges in the plan) and on (none), with the time per query for each" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;singleTaskExecution&lt;/th&gt;
&lt;th&gt;Operators in the executed plan&lt;/th&gt;
&lt;th&gt;Time per query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Sort, Exchange (range), HashAggregate, Exchange (hash), HashAggregate, FileScan&lt;/td&gt;
&lt;td&gt;82.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;Sort, HashAggregate, FileScan&lt;/td&gt;
&lt;td&gt;51.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both exchanges (and the shuffle query stages AQE wraps around them) are gone, and the query takes about 60% of the time, warm, on a session that's already paid for JVM startup. The loop builds a new DataFrame every time on purpose, because that's what a notebook cell, a test or an agent does. My first version collected the same DataFrame twenty times, and from the second collect on AQE reuses the shuffle output it already has, skipping the very stages Feather removes, so the two settings came out within a few milliseconds of each other. I wrote up the full benchmark (more queries, cold starts, and a test suite that went from 12.6 s to 5.2 s) in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/project-feather-gains" rel="noopener noreferrer"&gt;Benchmark Project Feather on a Fresh DataFrame&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second piece is Arrow-native Dataset caching (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57268" rel="noopener noreferrer"&gt;SPARK-57268&lt;/a&gt;): set &lt;code&gt;spark.sql.cache.serializer=org.apache.spark.sql.execution.columnar.ArrowCachedBatchSerializer&lt;/code&gt; when the application starts (it's static) and &lt;code&gt;df.cache()&lt;/code&gt; stores Arrow batches instead of Spark's own columnar format, which pairs nicely with everything &lt;a href="https://systemsnull.net/posts/data-engineering-ai/arrow-and-spark" rel="noopener noreferrer"&gt;Arrow already does between the JVM and Python&lt;/a&gt;. Compression is available through &lt;code&gt;spark.sql.execution.arrow.compression.codec&lt;/code&gt;, but it defaults to &lt;code&gt;none&lt;/code&gt;, so out of the box the Arrow cache is bigger than the default one. Broader in-process local repartitioning was deferred to the next release (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57399" rel="noopener noreferrer"&gt;SPARK-57399&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Spark Connect: operation IDs and REST endpoints
&lt;/h2&gt;

&lt;p&gt;In my &lt;a href="https://systemsnull.net/posts/data-engineering-ai/idiots-guide-spark-connect-clients" rel="noopener noreferrer"&gt;idiot's guide to Connect&lt;/a&gt; I complained that debugging a Connect app means correlating a client-side error with a server you can't see, and 4.3 fixes the two halves of that. First, the Python and Scala clients now generate an operation ID before every &lt;code&gt;ExecutePlan&lt;/code&gt; request (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58547" rel="noopener noreferrer"&gt;SPARK-58547&lt;/a&gt;) and hand it back on success (&lt;code&gt;df.executionInfo.operation_id&lt;/code&gt;) and, more usefully, on failure (&lt;code&gt;e.operation_id&lt;/code&gt; on the exception), because a request that fails before its first response never learned a server-generated ID. The server publishes the same ID as a Spark local property, so listeners and executor tasks can see it too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;remote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://localhost:15043&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# new in 4.3: sessions are context managers
&lt;/span&gt;    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT count(*) AS n FROM range(1000000)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded, operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executionInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT revenue FROM range(10)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# the client knows the ID even on failure
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed,    operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hagg5y0nf6desbct84b.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hagg5y0nf6desbct84b.gif" alt="A Connect session in a with block printing the operation ID of a successful query and of a failed one" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Second, the server's sessions and operations are now in the monitoring REST API (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57941" rel="noopener noreferrer"&gt;SPARK-57941&lt;/a&gt;), next to the SQL and streaming endpoints, where before they only lived in the Connect tab of the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$API&lt;/span&gt;/connect/sessions | jq
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$API&lt;/span&gt;/connect/operations | jq &lt;span class="s1"&gt;'.[] | select(.operationId == env.OP)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the ID the client printed for the failure, and the server's record of that same operation has its state, timings, the SQL execution it ran and the full error:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7eeqeiktslojs7lmo7e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7eeqeiktslojs7lmo7e.gif" alt="curl against the new /connect/sessions and /connect/operations endpoints, then the failed operation looked up by the ID the client printed" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because the Connect listener writes these events to the event log, the same endpoints work in the History Server after the application exits. Other Connect changes include the session context manager above (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58159" rel="noopener noreferrer"&gt;SPARK-58159&lt;/a&gt;), a &lt;code&gt;SQLContext&lt;/code&gt; wrapper for existing code (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57021" rel="noopener noreferrer"&gt;SPARK-57021&lt;/a&gt;), an experimental &lt;code&gt;SPARK_LOCAL_CONNECT_REUSE=1&lt;/code&gt; setting that keeps a local Connect server alive between runs (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57787" rel="noopener noreferrer"&gt;SPARK-57787&lt;/a&gt;), and broader support in the Connect JDBC driver for complex types, &lt;code&gt;DatabaseMetaData&lt;/code&gt; keys and SQLSTATE class &lt;code&gt;08&lt;/code&gt; connection errors. The &lt;code&gt;INVALID_HANDLE.SESSION_*&lt;/code&gt; errors changed SQLSTATE from &lt;code&gt;HY000&lt;/code&gt; to &lt;code&gt;08003&lt;/code&gt;, so retry logic that matches SQLSTATE may need updating. Connect Gateway and the Rust-backed Python client are concurrent projects, not Spark 4.3 features.&lt;/p&gt;

&lt;h2&gt;
  
  
  PySpark: DataFrame.zip and UDF transpilation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DataFrame.zip&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55886" rel="noopener noreferrer"&gt;SPARK-55886&lt;/a&gt;, with Connect support in &lt;a href="https://issues.apache.org/jira/browse/SPARK-57247" rel="noopener noreferrer"&gt;SPARK-57247&lt;/a&gt;) places two column projections of the same DataFrame side by side, row for row, without a join key. It replaces joins built on &lt;code&gt;monotonically_increasing_id()&lt;/code&gt;, whose rows are not guaranteed to align. Both sides must originate from the same plan, with matching filters, sorts, joins and aggregations beneath the projections.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1ip4l2adkperjfi21pr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1ip4l2adkperjfi21pr.gif" alt="Two projections of one DataFrame zipped back together row by row" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The experimental Python UDF transpiler (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55206" rel="noopener noreferrer"&gt;SPARK-55206&lt;/a&gt;) is the one I've been waiting for since the &lt;a href="https://systemsnull.net/posts/data-engineering-ai/pyspark-udf-showdown" rel="noopener noreferrer"&gt;UDF showdown&lt;/a&gt;, where the conclusion was that the fastest Python UDF is the one that never runs Python. With &lt;code&gt;spark.sql.experimental.optimizer.transpilePyUDFs=true&lt;/code&gt; and ANSI mode on, PySpark reads simple UDF bodies when you define them and lowers them to Catalyst expressions, so &lt;code&gt;len(dish)&lt;/code&gt; becomes Spark's own &lt;code&gt;length&lt;/code&gt; and the &lt;code&gt;BatchEvalPython&lt;/code&gt; step (and the Python worker, and the serialization) drops out of the plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.ansi.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# transpiling needs ANSI
&lt;/span&gt;         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;useArrow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;name_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqvpe05aabcuxiloercf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqvpe05aabcuxiloercf.gif" alt="The plan for the same UDF with transpilation off (BatchEvalPython, a Python worker) and on (no Python step, Spark's length)" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The restrictions are not all documented together, so I read &lt;code&gt;pyspark/sql/udf.py&lt;/code&gt; to collect them. Transpilation applies only to non-Arrow batched UDFs (hence &lt;code&gt;useArrow=False&lt;/code&gt;), classic PySpark, ANSI mode, and function bodies that PySpark can recover from source, excluding an inline lambda passed directly to &lt;code&gt;udf()&lt;/code&gt;. With ANSI mode disabled, PySpark warns and falls back because the generated expressions use ANSI semantics. The transpiler also preserves Python behavior where Spark differs: a transpiled &lt;code&gt;len&lt;/code&gt; raises on NULL as CPython would, represented in the full plan by a &lt;code&gt;raise_error&lt;/code&gt; branch. Parameter type hints keep the generated plan small. Other PySpark changes bound UDF input batches by bytes, implement more pandas API on Spark functions natively, remove Python 3.10 support, and raise the minimum NumPy version to 1.23.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spark SQL: ASOF JOIN, JSON_TABLE, VARIANT edits and nanoseconds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ASOF JOIN&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58092" rel="noopener noreferrer"&gt;SPARK-58092&lt;/a&gt;) is the point-in-time join: for each row on the left, the single closest row on the right that satisfies a &lt;code&gt;MATCH_CONDITION&lt;/code&gt; comparison, optionally within an equality &lt;code&gt;ON&lt;/code&gt; or &lt;code&gt;USING&lt;/code&gt; key. In my &lt;a href="https://systemsnull.net/posts/data-engineering-ai/spark-connect-rust-duckdb-polars" rel="noopener noreferrer"&gt;Rust client post&lt;/a&gt; I had to pull a Spark result into DuckDB to do exactly this, so it's nice to have it native, with a dedicated sort-merge operator. The canonical ML case is matching each prediction to the feature version that had actually been published when the prediction was made:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;made_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;features_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;predictions&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="n"&gt;ASOF&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;feature_versions&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="n"&gt;MATCH_CONDITION&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;made_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- latest one out by then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y5djcf9v84s2uatgdry.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y5djcf9v84s2uatgdry.gif" alt="ASOF JOIN matching a 10:03 prediction to the features published at 10:00, not the ones from 10:05" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 10:03 prediction gets &lt;code&gt;v1&lt;/code&gt; from 10:00 and never &lt;code&gt;v2&lt;/code&gt; from 10:05, which didn't exist yet (using it would be leakage), and the 10:07 prediction gets &lt;code&gt;v2&lt;/code&gt; alone, where a plain &lt;code&gt;&amp;gt;=&lt;/code&gt; join would return both versions and leave you to pick the latest yourself, which is exactly where the leakage bugs usually creep in. It's a preview: &lt;code&gt;spark.sql.join.asofJoin.enabled&lt;/code&gt; is off by default (and internal, so it won't show up in &lt;code&gt;SET -v&lt;/code&gt;), only &lt;code&gt;INNER&lt;/code&gt; and &lt;code&gt;LEFT OUTER&lt;/code&gt; are supported, and in streaming only stream-static works, with the stream on the left.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JSON_TABLE&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-58366" rel="noopener noreferrer"&gt;SPARK-58366&lt;/a&gt;) is the SQL-standard table function for expanding a JSON array into typed rows. It also supports &lt;code&gt;FOR ORDINALITY&lt;/code&gt; and &lt;code&gt;EXISTS&lt;/code&gt; columns:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0pwafria9ctml98uuhr1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0pwafria9ctml98uuhr1.gif" alt="JSON_TABLE expanding one order's items array into typed rows with an ordinal and an EXISTS column" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And VARIANT values can finally be edited in place with &lt;code&gt;variant_set&lt;/code&gt;, &lt;code&gt;variant_insert&lt;/code&gt;, &lt;code&gt;variant_delete&lt;/code&gt;, &lt;code&gt;variant_array_append&lt;/code&gt; and &lt;code&gt;variant_strip_nulls&lt;/code&gt; (plus &lt;code&gt;try_&lt;/code&gt; versions), instead of being parsed into a struct and serialized back:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyepiud7g3x6updizuto.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyepiud7g3x6updizuto.gif" alt="variant_set, variant_delete, variant_array_append and variant_strip_nulls applied to one profile" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nanosecond timestamps (&lt;a href="https://issues.apache.org/jira/browse/SPARK-56876" rel="noopener noreferrer"&gt;SPARK-56876&lt;/a&gt;) add &lt;code&gt;TIMESTAMP_NTZ(p)&lt;/code&gt; and &lt;code&gt;TIMESTAMP_LTZ(p)&lt;/code&gt; for precisions 7 to 9, with &lt;code&gt;unix_nanos&lt;/code&gt;, &lt;code&gt;timestamp_nanos&lt;/code&gt;, &lt;code&gt;current_timestamp(p)&lt;/code&gt;, datetime functions, interval arithmetic, and Parquet, ORC, JSON, CSV, Avro, JDBC, Arrow and Connect support. Spark's &lt;code&gt;TIMESTAMP&lt;/code&gt; has been microseconds since forever, which means two market events a nanosecond apart (or two rows from a Parquet file written by something that does use nanoseconds) silently became the same instant:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3gajb13ufp6a4m9rroj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3gajb13ufp6a4m9rroj.gif" alt="Two trades one nanosecond apart: identical at microsecond precision, distinct at TIMESTAMP_NTZ(9)" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nanosecond timestamps remain a preview behind &lt;code&gt;spark.sql.timestampNanosTypes.enabled&lt;/code&gt;, and the Databricks release post warns that they may change incompatibly. Typed literals such as &lt;code&gt;TIMESTAMP_NTZ(9) '...'&lt;/code&gt; do not parse yet, so values must be cast from strings. I would not use the type for production work yet: while building this demo, I found a query that returns an incorrect count when a nanosecond column appears beside other &lt;code&gt;DISTINCT&lt;/code&gt; aggregates in the same &lt;code&gt;SELECT&lt;/code&gt;; I am reducing the reproduction before filing it. The &lt;code&gt;TIME&lt;/code&gt; type also gained nanosecond precision and broader connector support, but remains disabled behind an internal flag.&lt;/p&gt;

&lt;p&gt;Other SQL additions include ANSI &lt;code&gt;UNNEST&lt;/code&gt; in &lt;code&gt;FROM&lt;/code&gt;, with &lt;code&gt;WITH ORDINALITY&lt;/code&gt;; &lt;code&gt;counter_diff&lt;/code&gt;, a window function that converts a running counter such as requests served or bytes sent into per-interval deltas and returns NULL after a reset; and &lt;code&gt;jaro_winkler_similarity&lt;/code&gt; for fuzzy name matching:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbsnn67qsmz6msp5fy3jp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbsnn67qsmz6msp5fy3jp.gif" alt="UNNEST with ORDINALITY, counter_diff turning a running counter into deltas with a reset as NULL, and jaro_winkler_similarity" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Also new and not filmed: &lt;code&gt;@&lt;/code&gt;-syntax time travel on table names (&lt;code&gt;t@v3&lt;/code&gt;, or &lt;code&gt;t@20261105093000000&lt;/code&gt; in the fixed 17-digit &lt;code&gt;yyyyMMddHHmmssSSS&lt;/code&gt; form), &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt;, dynamic table options on &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;MERGE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;AS JSON&lt;/code&gt; output for &lt;code&gt;SHOW TABLES&lt;/code&gt; and &lt;code&gt;SHOW PARTITIONS&lt;/code&gt;, &lt;code&gt;collect_union&lt;/code&gt;, &lt;code&gt;to_base32&lt;/code&gt;/&lt;code&gt;from_base32&lt;/code&gt;, and a &lt;code&gt;BIN BY&lt;/code&gt; relation operator behind a flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading files inside archives
&lt;/h2&gt;

&lt;p&gt;The archive reader may be the feature I use most. With &lt;code&gt;spark.sql.files.archive.reader.enabled=true&lt;/code&gt;, file sources read entries inside tar, zip and 7z archives as separate files, including during schema inference; &lt;code&gt;archivePathFilter&lt;/code&gt; selects the entries to read. This removes the usual step of unpacking a vendor's nightly archive into a temporary directory:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rk4easdwl59ufooh8wm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rk4easdwl59ufooh8wm.gif" alt="CSV files read straight out of a tar, with archivePathFilter skipping the README inside" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Other changes in Spark 4.3
&lt;/h2&gt;

&lt;p&gt;A lot of 4.3 is engine work you get for free. &lt;code&gt;ReplaceHashWithSortAgg&lt;/code&gt; and combining adjacent partial and final aggregates are now on by default, whole-stage codegen covers &lt;code&gt;UNION&lt;/code&gt;, AQE can convert a sort-merge join to a shuffled hash join in the physical plan, nested column pruning reaches into &lt;code&gt;transform&lt;/code&gt; over arrays of structs, and the vectorized Parquet reader got faster decoding paths and a &lt;code&gt;BYTE_STREAM_SPLIT&lt;/code&gt; reader. There's an opt-in to spread NULL outer join keys across partitions (a classic skew source), fractional &lt;code&gt;spark.task.cpus&lt;/code&gt;, &lt;code&gt;spark.rdd.compress&lt;/code&gt; on by default, and the Spark UI's Content Security Policy on by default. On Kubernetes the official images move to Ubuntu 26.04 with Python 3.14, and containers drop &lt;code&gt;allowPrivilegeEscalation&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;A few changes can change results or break things on upgrade, all in the &lt;a href="https://spark.apache.org/docs/4.3.0/sql-migration-guide.html" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt;: exact &lt;code&gt;percentile&lt;/code&gt;/&lt;code&gt;median&lt;/code&gt; may differ in the last ULP; &lt;code&gt;Dataset.observe&lt;/code&gt; metrics now count only the last successful task attempt; &lt;code&gt;HAVING&lt;/code&gt; is applied before window functions when the &lt;code&gt;SELECT&lt;/code&gt; also has a generator (fixing wrong results); non-deterministic filters are no longer pushed to DSv2 sources; Thrift Server sessions can no longer set JVM system properties; and the AQE rule &lt;code&gt;DynamicJoinSelection&lt;/code&gt; was renamed &lt;code&gt;DemoteBroadcastHashJoin&lt;/code&gt;, so an exclusion by the old name now silently does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  In Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateful Real-time Mode&lt;/strong&gt;: pipelined shuffles let dedup, aggregation and &lt;code&gt;transformWithState&lt;/code&gt; (Scala/Java) run at millisecond latency with only a trigger change, and the default median is a 50 ms flush timer you can tune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD Type 2 in Auto CDC&lt;/strong&gt; keeps every version with &lt;code&gt;__START_AT&lt;/code&gt;/&lt;code&gt;__END_AT&lt;/code&gt;; &lt;code&gt;TRACK HISTORY ON&lt;/code&gt; decides what opens a new version, and the target needs a row-level-capable catalog like Iceberg (whose 4.3 runtime isn't published yet).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project Feather&lt;/strong&gt; is opt-in: single-task execution removes the exchanges from small queries (about 1.6x faster here, warm), and the Arrow cache serializer is a static config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spark Connect&lt;/strong&gt; gives you an operation ID on success and failure, and the same operation in the server's REST API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DataFrame.zip&lt;/code&gt;&lt;/strong&gt; replaces the &lt;code&gt;monotonically_increasing_id&lt;/code&gt; join hack; &lt;strong&gt;UDF transpilation&lt;/strong&gt; removes Python from simple UDFs if you're on classic PySpark with ANSI on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ASOF JOIN, nanosecond timestamps and archive reading&lt;/strong&gt; are all behind flags; &lt;strong&gt;JSON_TABLE, VARIANT edits, UNNEST and &lt;code&gt;counter_diff&lt;/code&gt;&lt;/strong&gt; just work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of it comes back to time: a record's few milliseconds through a stateful query, a dish's price history that never gets overwritten, the feature version that was true at 10:03, the nanosecond between two trades, and the milliseconds a small query no longer spends on shuffles. On a quarterly cadence, expect all of it to keep moving faster than it used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.databricks.com/blog/introducing-apache-spark-43-now-available-databricks-runtime-19" rel="noopener noreferrer"&gt;Introducing Apache Spark 4.3&lt;/a&gt;, the Databricks launch post, with the Real-time Mode benchmarks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/releases/spark-release-4-3-0.html" rel="noopener noreferrer"&gt;Spark 4.3.0 release notes&lt;/a&gt;, every change with its JIRA.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/docs/4.3.0/sql-migration-guide.html" rel="noopener noreferrer"&gt;SQL migration guide, 4.2 to 4.3&lt;/a&gt;, for the behaviour changes above.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/16_spark_43_guide" rel="noopener noreferrer"&gt;spark-demos/demos/16_spark_43_guide&lt;/a&gt;, every script in this post, plus the film scripts.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://systemsnull.net/posts/data-engineering-ai/auto-cdc-declarative-pipelines" rel="noopener noreferrer"&gt;AUTO CDC: Turning an event log into current state&lt;/a&gt;, the SCD Type 1 post this builds on.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>spark43</category>
      <category>realtimemode</category>
      <category>declarativepipelines</category>
    </item>
    <item>
      <title>Building a Spark Connect Client in OCaml because I'm crazy and you're not</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Tue, 20 Oct 2026 22:18:03 +0000</pubDate>
      <link>https://dev.to/lisancao/building-a-spark-connect-client-in-ocaml-because-im-crazy-and-youre-not-3ip4</link>
      <guid>https://dev.to/lisancao/building-a-spark-connect-client-in-ocaml-because-im-crazy-and-youre-not-3ip4</guid>
      <description>&lt;h2&gt;
  
  
  But Lisa, surely you must have a good reason for using OCaml?
&lt;/h2&gt;

&lt;p&gt;No, not really. Well, yes. Sort of. &lt;/p&gt;

&lt;p&gt;In a &lt;a href="https://systemsnull.net/posts/data-engineering-ai/idiots-guide-spark-connect-clients" rel="noopener noreferrer"&gt;previous blog post&lt;/a&gt; I wrote a cheeky little guide to Spark Connect Clients, which I enjoyed writing thoroughly and was pretty well received. But it ended on a warning: not all Connect Clients are built equally, and it highly depends on the language you're using and the love and care of the maintainers. Current gold standards include the Rust Client, for instance, which gives you full interoperability with Rust native engines like Polars or DuckDB. More on that in a follow up post, but it's pretty cool. &lt;/p&gt;

&lt;p&gt;The follow-up question is what it takes to build one then and how to test it. And to be honest, I've always defaulted to OCaml as sort of a joke language- partially from it's niche quality and mostly from my secret love for the finance industry and beasts like Jane Street.&lt;/p&gt;

&lt;p&gt;OCaml is a useful language for the demonstration because it lacks all of the built in capabilities that would otherwise make life easy: there is no Arrow library, no concept of the gRPC protocol at all, and a protobuf compiler is its own project with its own parser rather than a plugin for Google's &lt;code&gt;protoc&lt;/code&gt;. Every piece of the Connect client has to be manually built; making you realize how good the Go or Rust clients have it in comparison because of their respectively rich ecosystems. Naturally, I vibe coded most of this with my good friend Claude Code, and if you want to skip the entire droll on the implementation journey you can skip ahead to &lt;a href="https://github.com/lisancao/spark-connect-ocaml" rel="noopener noreferrer"&gt;the repo here&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every Spark Connect client essentially has the same four basic requirements: compile the published protos, make a streaming gRPC call, build relation trees, and decode Arrow batches. The Connect server itself does so much of the work for you in this architecture that the only parts costing real effort are the two the protocol leaves to the client implementation: its protobuf toolchain and its Arrow codec.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Roles and Responsibilities of a Connect Client
&lt;/h2&gt;

&lt;p&gt;The client sits between the DataFrame calls and the server, and it never processes data itself. It turns method calls into a protobuf tree, sends the tree, and turns the Arrow that comes back into values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgc3J2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIEFbXCJEYXRhRnJhbWUgY2FsbHM8YnIvPmZpbHRlciwgam9pbiwgZ3JvdXBfYnkgLi4uXCJdOjo6eW91IC0tPiBCW1wiUmVsYXRpb24gLyBFeHByZXNzaW9uPGJyLz5wcm90b2J1ZiB0cmVlPGJyLz4oc3RlcCA0KVwiXTo6OnlvdVxuICBCIC0tPnxcIkV4ZWN1dGVQbGFuLCBnUlBDIG92ZXIgSFRUUC8yPGJyLz4oc3RlcHMgMiBhbmQgMylcInwgQ1tcIkNvbm5lY3Qgc2VydmVyPGJyLz5hbmFseXplciBcdTAwYjcgQ2F0YWx5c3QgXHUwMGI3IGV4ZWN1dG9yc1wiXTo6OnNydlxuICBDIC0tPnxcInN0cmVhbSBvZiByZXNwb25zZXMsPGJyLz5lYWNoIHdpdGggYW4gQXJyb3cgSVBDIGJhdGNoXCJ8IERbXCJBcnJvdyBkZWNvZGVyPGJyLz4oc3RlcCA1KVwiXTo6OnlvdVxuICBEIC0tPiBFW1wicm93c1wiXTo6OnlvdVxuICBGW1wibG9jYWwgcm93c1wiXTo6OnlvdSAtLT58XCJBcnJvdyB3cml0ZXIgKHN0ZXAgNik8YnIvPmFzIGEgTG9jYWxSZWxhdGlvblwifCBCXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgc3J2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIEFbXCJEYXRhRnJhbWUgY2FsbHM8YnIvPmZpbHRlciwgam9pbiwgZ3JvdXBfYnkgLi4uXCJdOjo6eW91IC0tPiBCW1wiUmVsYXRpb24gLyBFeHByZXNzaW9uPGJyLz5wcm90b2J1ZiB0cmVlPGJyLz4oc3RlcCA0KVwiXTo6OnlvdVxuICBCIC0tPnxcIkV4ZWN1dGVQbGFuLCBnUlBDIG92ZXIgSFRUUC8yPGJyLz4oc3RlcHMgMiBhbmQgMylcInwgQ1tcIkNvbm5lY3Qgc2VydmVyPGJyLz5hbmFseXplciBcdTAwYjcgQ2F0YWx5c3QgXHUwMGI3IGV4ZWN1dG9yc1wiXTo6OnNydlxuICBDIC0tPnxcInN0cmVhbSBvZiByZXNwb25zZXMsPGJyLz5lYWNoIHdpdGggYW4gQXJyb3cgSVBDIGJhdGNoXCJ8IERbXCJBcnJvdyBkZWNvZGVyPGJyLz4oc3RlcCA1KVwiXTo6OnlvdVxuICBEIC0tPiBFW1wicm93c1wiXTo6OnlvdVxuICBGW1wibG9jYWwgcm93c1wiXTo6OnlvdSAtLT58XCJBcnJvdyB3cml0ZXIgKHN0ZXAgNik8YnIvPmFzIGEgTG9jYWxSZWxhdGlvblwifCBCXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="1706" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The analyzer, Catalyst and the executors are the server's. The rest is the client's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Getting the protos to compile
&lt;/h2&gt;

&lt;p&gt;The protocol &lt;em&gt;is&lt;/em&gt; its protobuf definitions, published in the Spark repository under &lt;a href="https://github.com/apache/spark/tree/master/sql/connect/common/src/main/protobuf/spark/connect" rel="noopener noreferrer"&gt;&lt;code&gt;sql/connect/common/src/main/protobuf/spark/connect/&lt;/code&gt;&lt;/a&gt;. A client starts by copying them out of the tag it targets (I used v4.2.0) and compiling them with whatever protobuf toolchain its language has. They import &lt;code&gt;google/protobuf/any.proto&lt;/code&gt; and &lt;code&gt;timestamp.proto&lt;/code&gt;, and Spark's error details also need &lt;code&gt;google/rpc/status.proto&lt;/code&gt; and &lt;code&gt;error_details.proto&lt;/code&gt;. None of these are in the Spark repo. If the language's toolchain does not ship them, each is a few lines to write by hand, and wire-compatible as long as the field numbers match.&lt;/p&gt;

&lt;p&gt;This step is "mechanical" in Go, Rust, Swift and Java, where &lt;code&gt;protoc&lt;/code&gt; and a maintained plugin do it. In OCaml the compiler is &lt;a href="https://github.com/mransan/ocaml-protoc" rel="noopener noreferrer"&gt;&lt;code&gt;ocaml-protoc&lt;/code&gt;&lt;/a&gt;, a from-scratch parser and generator, and Spark's protos hit several things it disagrees with. Each fix is one line in a post-generation fix-up script:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single-quoted strings (&lt;code&gt;syntax = 'proto3';&lt;/code&gt;), legal but rare&lt;/td&gt;
&lt;td&gt;every Spark proto&lt;/td&gt;
&lt;td&gt;rewrite to double quotes when vendoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;reserved "userId", "sessionId";&lt;/code&gt; (field &lt;em&gt;name&lt;/em&gt; reservations)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;relations.proto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drop the lines; they have no wire effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A one-line default emitted before the function it calls exists&lt;/td&gt;
&lt;td&gt;the mutually recursive ML relation messages&lt;/td&gt;
&lt;td&gt;move the line below its dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key and payload emitted in the wrong order for a oneof case whose message is empty&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Catalog.CurrentDatabase&lt;/code&gt;, &lt;code&gt;CurrentCatalog&lt;/code&gt;, &lt;code&gt;ClearCache&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;swap the two generated lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The oneof bug surfaced the way every protobuf mistake surfaces. Three catalog calls, and only those three, came back from the server as &lt;code&gt;UNKNOWN: Application error processing RPC&lt;/code&gt;, with &lt;code&gt;Invalid protobuf byte sequence&lt;/code&gt; in the server log. That message means the server could not parse the request at all, and the diagnostic is to dump the bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Catalog{current_database}  generated:  00 0a    length 0, then the key for field 1
Catalog{current_database}  correct:    0a 00    key for field 1, then length 0
AnalyzePlan{spark_version} generated:  52 00    same shape, inlined elsewhere, comes out right
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ocaml-protoc&lt;/code&gt; encodes back to front (payload first, then its key, so a nested message's length is known before the key is written), and the standalone encoder for an empty message had the two lines in the wrong order. Nothing else in the API was affected: the bug lives in the encoder generated for a message that is nothing but a oneof, and &lt;code&gt;Catalog&lt;/code&gt; is the only such message on the request path with empty cases. A client in Go never meets this, because &lt;code&gt;protoc&lt;/code&gt; has been exercised by every Google service for a decade; a client in OCaml meets it immediately. &lt;strong&gt;The protobuf compiler is the first dependency the protocol pushes onto the host language, and the Spark protos are a stress test for it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Speak gRPC
&lt;/h2&gt;

&lt;p&gt;gRPC is small once HTTP/2 is available, and OCaml does have HTTP/2 (&lt;a href="https://github.com/anmonteiro/ocaml-h2" rel="noopener noreferrer"&gt;&lt;code&gt;h2&lt;/code&gt;&lt;/a&gt;, with TLS through &lt;code&gt;ocaml-tls&lt;/code&gt; and the system trust store). A call is a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/spark.connect.SparkConnectService/&amp;lt;Method&amp;gt;&lt;/code&gt; with two headers that matter, &lt;code&gt;content-type: application/grpc&lt;/code&gt; and &lt;code&gt;te: trailers&lt;/code&gt;, plus &lt;code&gt;authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; when a token is set. The body in each direction is a sequence of messages, each framed as a compression flag byte, a big-endian length, and the protobuf bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="sc"&gt;'\000'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                      &lt;span class="c"&gt;(* not compressed *)&lt;/span&gt;
  &lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_int32_be&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of_int&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c"&gt;(* length *)&lt;/span&gt;
  &lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blit_string&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unsafe_to_string&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status comes back in HTTP/2 &lt;em&gt;trailers&lt;/em&gt;, not in the response status line (which is always 200), as &lt;code&gt;grpc-status&lt;/code&gt; and a percent-encoded &lt;code&gt;grpc-message&lt;/code&gt;. Spark adds &lt;code&gt;grpc-status-details-bin&lt;/code&gt;, base64 of a &lt;code&gt;google.rpc.Status&lt;/code&gt;, which is where the useful error information lives. The reader side is the mirror of &lt;code&gt;frame&lt;/code&gt;: buffer the body, peel off messages as their lengths complete, and finish when the trailers arrive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;status_of_headers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;H2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nn"&gt;H2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="s2"&gt;"grpc-status"&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;None&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;int_of_string&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
      &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="n"&gt;percent_decode&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;H2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="s2"&gt;"grpc-message"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
      &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="n"&gt;base64_decode&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;H2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="s2"&gt;"grpc-status-details-bin"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
      &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spark Connect only uses two call shapes: unary (&lt;code&gt;AnalyzePlan&lt;/code&gt;, &lt;code&gt;Config&lt;/code&gt;, &lt;code&gt;Interrupt&lt;/code&gt;, &lt;code&gt;ReleaseSession&lt;/code&gt;, &lt;code&gt;FetchErrorDetails&lt;/code&gt;, and friends) and server-streaming (&lt;code&gt;ExecutePlan&lt;/code&gt;, &lt;code&gt;ReattachExecute&lt;/code&gt;). A unary call is a streaming call where the client reads one message. There are no client-streaming or bidirectional methods except &lt;code&gt;AddArtifacts&lt;/code&gt;, which is only needed to upload files to the server. That is the whole transport. It worked on the first query, and almost none of the effort in this project went into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: A session, and the first RPC
&lt;/h2&gt;

&lt;p&gt;Every request carries the same identity block: a &lt;code&gt;session_id&lt;/code&gt; the client mints (a UUID), a &lt;code&gt;user_context&lt;/code&gt; with a &lt;code&gt;user_id&lt;/code&gt;, and a &lt;code&gt;client_type&lt;/code&gt; string that is effectively a user agent. After the first reply the client also records the &lt;code&gt;server_side_session_id&lt;/code&gt; the server sent and echoes it back as &lt;code&gt;client_observed_server_side_session_id&lt;/code&gt; on every later request, which is how the server notices if it has restarted underneath the client.&lt;/p&gt;

&lt;p&gt;The hello world of Connect is &lt;code&gt;AnalyzePlan&lt;/code&gt; with the &lt;code&gt;spark_version&lt;/code&gt; option, because it needs no plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;analyze&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;analyze&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_analyze_plan_request&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;
      &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;client_observed_server_side_session_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server_side_session_id&lt;/span&gt;
      &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;user_context&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_context&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;client_type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_type&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;analyze&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="n"&gt;unary&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;method_&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AnalyzePlan"&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode_pb_analyze_plan_request&lt;/span&gt;
    &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode_pb_analyze_plan_response&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;|=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;observe&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server_side_session_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;analyze&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Spark_version&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;|=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Spark_version&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;failwith&lt;/span&gt; &lt;span class="s2"&gt;"unexpected response"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server version: 4.2.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that prints, the protobuf toolchain, the HTTP/2 stack, the gRPC framing and the session plumbing are all known good, and everything after it is vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Build plans instead of running them
&lt;/h2&gt;

&lt;p&gt;Plan building is the most clerical step of the seven. A &lt;code&gt;Relation&lt;/code&gt; is a &lt;code&gt;common&lt;/code&gt; block holding a &lt;code&gt;plan_id&lt;/code&gt; plus a oneof of every operator the server understands: &lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Project&lt;/code&gt;, &lt;code&gt;Filter&lt;/code&gt;, &lt;code&gt;Join&lt;/code&gt;, &lt;code&gt;Aggregate&lt;/code&gt;, &lt;code&gt;Sort&lt;/code&gt;, &lt;code&gt;Limit&lt;/code&gt;, &lt;code&gt;SetOp&lt;/code&gt;, &lt;code&gt;Sample&lt;/code&gt;, &lt;code&gt;Deduplicate&lt;/code&gt;, &lt;code&gt;Range&lt;/code&gt;, &lt;code&gt;Sql&lt;/code&gt;, &lt;code&gt;LocalRelation&lt;/code&gt;, and a long tail. A DataFrame method is a function that wraps the current relation in a new one, and that is the entire DataFrame API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;of_rel_type&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;rel_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_relation&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_relation_common&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fresh_plan_id&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;rel_type&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;cond&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;of_rel_type&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_filter&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;cond&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;join&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;how&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;`Inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="n"&gt;of_rel_type&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Join&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_join&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;join_condition&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;join_type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;join_type_pb&lt;/span&gt; &lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expressions are the same idea one level down. A column reference is an &lt;code&gt;UnresolvedAttribute&lt;/code&gt; holding a string; a literal is a &lt;code&gt;Literal&lt;/code&gt; with a typed oneof; and every function &lt;em&gt;and every operator&lt;/em&gt; is an &lt;code&gt;UnresolvedFunction&lt;/code&gt; called by name, so &lt;code&gt;a + b&lt;/code&gt; is the function &lt;code&gt;+&lt;/code&gt; applied to two arguments and &lt;code&gt;a == b&lt;/code&gt; is &lt;code&gt;==&lt;/code&gt;. The client never resolves anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distinct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="n"&gt;of_expr_type&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Unresolved_function&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_expression_unresolved_function&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;function_name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;is_distinct&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;distinct&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="s2"&gt;"+"&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="s2"&gt;"=="&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;gt&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;"&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no analyzer in the client: &lt;code&gt;Column.col "nope"&lt;/code&gt; is a string in a message until the server sees the plan, which is why a typo fails late under Connect. The client cannot check the name; it has no catalog to check it against.&lt;/p&gt;

&lt;p&gt;The size of this layer depends on how much the server does, and the server does nearly everything. Each of the following is a server message rather than client code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you'd expect to implement client-side&lt;/th&gt;
&lt;th&gt;What Connect actually does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rendering the table for &lt;code&gt;show()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ShowString&lt;/code&gt; is a &lt;em&gt;relation&lt;/em&gt;; the server formats the ASCII table and returns it as one string cell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an &lt;code&gt;Aggregate&lt;/code&gt; with &lt;code&gt;count(1)&lt;/code&gt;; the client reads back one row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema, &lt;code&gt;printSchema()&lt;/code&gt;, &lt;code&gt;explain()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AnalyzePlan&lt;/code&gt; with &lt;code&gt;Schema&lt;/code&gt;, &lt;code&gt;TreeString&lt;/code&gt; or &lt;code&gt;Explain&lt;/code&gt;; the server returns the type tree, the formatted text, or the plan string (the only way to see one, since Catalyst isn't on your machine)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;listTables()&lt;/code&gt;, &lt;code&gt;currentDatabase()&lt;/code&gt;, &lt;code&gt;tableExists()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;relations under the &lt;code&gt;Catalog&lt;/code&gt; message; they execute like a query and return rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sql()&lt;/code&gt; for DDL vs for a query&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;SqlCommand&lt;/code&gt;; the server runs it eagerly and, for a query, hands back a relation to keep building on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named and positional SQL parameters&lt;/td&gt;
&lt;td&gt;literals attached to the &lt;code&gt;SqlCommand&lt;/code&gt;; the server binds &lt;code&gt;:name&lt;/code&gt; and &lt;code&gt;?&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telling &lt;code&gt;a.id&lt;/code&gt; from &lt;code&gt;b.id&lt;/code&gt; in a self-join&lt;/td&gt;
&lt;td&gt;every relation carries a client-assigned &lt;code&gt;plan_id&lt;/code&gt;, and an attribute can name the plan it belongs to&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;plan_id&lt;/code&gt; is the one piece of real design in this step. Because the client assigns &lt;code&gt;plan_id&lt;/code&gt;s, it can mint a column reference bound to a particular DataFrame, and the server resolves it against that side of the join even when both sides have the same column names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_of_plan&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="s2"&gt;"a"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="s2"&gt;"b"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Execute, and decode the Arrow that comes back
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ExecutePlan&lt;/code&gt; takes a &lt;code&gt;Plan&lt;/code&gt; (either a &lt;code&gt;Relation&lt;/code&gt; to query or a &lt;code&gt;Command&lt;/code&gt; to run) and returns a stream of &lt;code&gt;ExecutePlanResponse&lt;/code&gt; messages. Each one has a &lt;code&gt;response_type&lt;/code&gt; oneof: &lt;code&gt;arrow_batch&lt;/code&gt; carries data, &lt;code&gt;sql_command_result&lt;/code&gt; carries the relation a &lt;code&gt;SqlCommand&lt;/code&gt; produced, &lt;code&gt;result_complete&lt;/code&gt; is the end marker, and progress and metrics messages can be ignored at first. The collect loop is a fold over the stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;collect_arrow&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="nc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="bp"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="nn"&gt;Lwt_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iter&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute_plan_response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response_type&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Arrow_batch&lt;/span&gt; &lt;span class="n"&gt;ab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arrow_ipc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read_stream&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unsafe_to_string&lt;/span&gt; &lt;span class="n"&gt;ab&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nn"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rev_append&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;
      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;|=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Each &lt;code&gt;arrow_batch&lt;/code&gt; in &lt;code&gt;ab.data&lt;/code&gt; is a complete Arrow IPC stream&lt;/strong&gt;, a schema message followed by one record batch, not a bare batch sharing a schema across the response. So the decoder is a full IPC stream reader. In a language with an Arrow library this step is one function call. OCaml does not have one (the package on opam binds the C++ library, which defeats the point of a thin client), so this is where the project stopped being clerical.&lt;/p&gt;

&lt;p&gt;An IPC stream is a sequence of messages, each framed as a continuation marker, a metadata length, a &lt;a href="https://flatbuffers.dev/" rel="noopener noreferrer"&gt;FlatBuffers&lt;/a&gt; &lt;code&gt;Message&lt;/code&gt;, and a body of raw buffers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FF FF FF FF  &amp;lt;int32 metadata length&amp;gt;  &amp;lt;FlatBuffer: Message{version, header type, header, bodyLength}&amp;gt;  &amp;lt;body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Message&lt;/code&gt; header is a &lt;code&gt;Schema&lt;/code&gt; (a vector of &lt;code&gt;Field&lt;/code&gt;s, each with a name, a nullability flag, a type union and child fields) or a &lt;code&gt;RecordBatch&lt;/code&gt; (a row count, a vector of field &lt;em&gt;nodes&lt;/em&gt; giving each column's length and null count, and a vector of &lt;em&gt;buffers&lt;/em&gt; giving offsets into the body). Decoding a batch is a depth-first walk: for each field, take the next node, take its validity bitmap buffer, take its data buffer(s), and recurse into children for lists, structs and maps. FlatBuffers itself is a vtable-indirected binary format and reading it is a page or so of offset arithmetic. Then there is one decoder per type. The decoder is the largest file in the project not because Arrow is complicated but because Spark produces many Arrow types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spark type&lt;/th&gt;
&lt;th&gt;What arrives in the Arrow batch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tinyint, smallint, int, bigint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Int&lt;/code&gt; with bit width and signedness; a fixed-width buffer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;float, double&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FloatingPoint&lt;/code&gt; with precision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;string, binary&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Utf8&lt;/code&gt; / &lt;code&gt;Binary&lt;/code&gt;: an offsets buffer plus a data buffer (and &lt;code&gt;Large&lt;/code&gt; variants with 64-bit offsets)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decimal(p, s)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Decimal&lt;/code&gt;: 128-bit (or 256-bit) little-endian two's complement, which needs a bignum-to-decimal-string conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;date&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Date32&lt;/code&gt;, days since the epoch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Timestamp&lt;/code&gt; in microseconds, with the session time zone as a string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;timestamp_ntz&lt;/td&gt;
&lt;td&gt;the same with no time zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;interval year to month&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Interval(YEAR_MONTH)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;interval day to second&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Duration(MICROSECOND)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;array, struct, map&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Struct&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;: child columns with their own nodes and validity bitmaps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Decimal decoding was the only algorithmic piece, and only because the natural answer (the &lt;code&gt;zarith&lt;/code&gt; bignum library) is a C dependency I did not want in a client whose point is having none. Performance without tuning was acceptable: a two-column projection over three million rows (a bigint and a SHA-1 string) came back and decoded in 1.63 seconds on localhost, with the client building a boxed value per cell, the least efficient representation available. The server's time dominates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Sending data to the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createDataFrame&lt;/code&gt; has to send rows &lt;em&gt;to&lt;/em&gt; the server, and Connect's &lt;code&gt;LocalRelation&lt;/code&gt; message carries them as, again, an Arrow IPC stream. A client that builds a DataFrame from local values therefore needs an Arrow &lt;em&gt;writer&lt;/em&gt;, including a FlatBuffers &lt;em&gt;builder&lt;/em&gt; for the schema and record batch metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;create_dataframe&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arrow_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="n"&gt;of_rel_type&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Local_relation&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_local_relation&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unsafe_of_string&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The builder is the fiddly half. FlatBuffers are written back to front with vtables, alignment padding and forward offsets, and a byte out of place produces a Java stack trace on the server rather than anything that points at the byte. So the acceptance test for the writer was not the Spark server, which is forgiving, but &lt;code&gt;pyarrow&lt;/code&gt;'s &lt;code&gt;validate(full=True)&lt;/code&gt;, which is not. The stream passed with every Spark type the decoder reads, including nested lists, structs and decimals. The server infers the Spark schema from the Arrow schema, so a local table of &lt;code&gt;Value.Int&lt;/code&gt;, &lt;code&gt;Value.String&lt;/code&gt; and &lt;code&gt;Value.Float&lt;/code&gt; cells comes up as &lt;code&gt;bigint&lt;/code&gt;, &lt;code&gt;string&lt;/code&gt; and &lt;code&gt;double&lt;/code&gt; with no DDL string needed (a DDL string can be passed for narrower types).&lt;/p&gt;

&lt;p&gt;Every official client carries both halves of this codec; they borrow them from an Arrow library the language already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Errors, the way Spark sends them
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;grpc-status-details-bin&lt;/code&gt; trailer is base64 of a &lt;code&gt;google.rpc.Status&lt;/code&gt;, whose &lt;code&gt;details&lt;/code&gt; is a list of &lt;code&gt;Any&lt;/code&gt;. Spark puts a &lt;code&gt;google.rpc.ErrorInfo&lt;/code&gt; in it, and the &lt;code&gt;metadata&lt;/code&gt; map on that is where everything useful lives: &lt;code&gt;classes&lt;/code&gt; (the JVM exception class chain as a JSON array), &lt;code&gt;sqlState&lt;/code&gt;, &lt;code&gt;errorId&lt;/code&gt;, and &lt;code&gt;messageParameters&lt;/code&gt;. The error id goes to &lt;code&gt;FetchErrorDetails&lt;/code&gt; for the server-side stack trace and query context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode_pb_status&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Pbrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of_string&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_map&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ends_with&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"google.rpc.ErrorInfo"&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_url&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Some&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Error_details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode_pb_error_info&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Pbrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;of_string&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_string&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;
&lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="c"&gt;(* info.metadata: ("classes", "[...]"), ("sqlState", "42P01"), ("errorId", "..."), ... *)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Spark error condition itself is not a separate field; it is the bracketed prefix of the message, so a client lifts it with a trivial parse. The OCaml client's output for a query on a table that does not exist, with no string matching beyond that bracket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Spark error INTERNAL (org.apache.spark.sql.catalyst.ExtendedAnalysisException):
[TABLE_OR_VIEW_NOT_FOUND] The table or view `nope` cannot be found. ...
SQLSTATE: 42P01; line 1 pos 14;
'Project [*]
+- 'UnresolvedRelation [nope], [], false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;error_class = TABLE_OR_VIEW_NOT_FOUND&lt;/code&gt;, &lt;code&gt;sql_state = 42P01&lt;/code&gt;, and the exception classes as a list. The server supplies all of it; the client's job is to keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;With those seven pieces the client reads like every other Connect client, because they all converge on the proto's shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;open&lt;/span&gt; &lt;span class="nn"&gt;Lwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Infix&lt;/span&gt;
&lt;span class="k"&gt;open&lt;/span&gt; &lt;span class="nc"&gt;Spark_connect&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;Lwt_main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="k"&gt;begin&lt;/span&gt;
    &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt; &lt;span class="s2"&gt;"sc://localhost:15002"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_dataframe&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="s2"&gt;"ann"&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="s2"&gt;"bob"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_dataframe&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"amount"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;per_person&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;group_by&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Grouped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agg&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="s2"&gt;"total"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show&lt;/span&gt; &lt;span class="n"&gt;per_person&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="bp"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----+-----+
|name|total|
+----+-----+
| ann| 15.5|
+----+-----+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two local tables went up inside the plan as &lt;code&gt;LocalRelation&lt;/code&gt;s, the join and aggregate went up as relation nodes around them, the server formatted the table and returned it as a one-cell Arrow batch, and the whole exchange was a single &lt;code&gt;ExecutePlan&lt;/code&gt; call, since nothing before &lt;code&gt;show&lt;/code&gt; touches the server. Pointed at a Databricks endpoint with &lt;code&gt;sc://host:443/;token=...&lt;/code&gt;, the same client turns TLS on because the token is present, which is what the connection string spec asks for and what every client reimplements.&lt;/p&gt;

&lt;p&gt;The SQL path is the only one with a wrinkle: &lt;code&gt;sql()&lt;/code&gt; sends a &lt;code&gt;SqlCommand&lt;/code&gt; and executes eagerly, so &lt;code&gt;CREATE TABLE&lt;/code&gt; runs right away, and for a query the reply carries a &lt;code&gt;sql_command_result&lt;/code&gt; relation that the client wraps as the DataFrame to keep building on. Config is a unary call with get/set/unset operations, the catalog is relations, writers are &lt;code&gt;WriteOperation&lt;/code&gt; (v1, with save modes) and &lt;code&gt;WriteOperationV2&lt;/code&gt; (create/replace/append) commands, and &lt;code&gt;persist&lt;/code&gt; is an &lt;code&gt;AnalyzePlan&lt;/code&gt; option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing its Production Readiness
&lt;/h2&gt;

&lt;p&gt;So, here begets the real question: is this actually usable? Well, maybe. As much as I'd love Jane Street to plug this in and hire me to maintain it (though, I'm pretty happy at Databricks right now), there's still some coverage issues, particularly around UDFs which are highly variable anyways.&lt;/p&gt;

&lt;p&gt;For my own due diligence, I tested the client two ways: every message it can build, exercised against more than one Spark release, and execution on a real cluster of the kind of work I naively imagine someone at Jane Street would reach for OCaml to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every message, tested against three Spark releases
&lt;/h3&gt;

&lt;p&gt;Coverage is precisely measurable for a Connect client, because the protocol is a finite list of protobuf messages. The repo has a script that reads the generated bindings, finds which relations, expressions, commands, catalog calls and analyze requests the client builds, follows each one back to the public function that builds it, and checks whether a test calls that function. Its output is &lt;a href="https://github.com/lisancao/spark-connect-ocaml/blob/main/docs/protocol-coverage.md" rel="noopener noreferrer"&gt;docs/protocol-coverage.md&lt;/a&gt;, regenerated rather than maintained by hand:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protocol area&lt;/th&gt;
&lt;th&gt;Implemented&lt;/th&gt;
&lt;th&gt;Of those, tested&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Relations (DataFrame operations)&lt;/td&gt;
&lt;td&gt;33 of 61&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expressions&lt;/td&gt;
&lt;td&gt;14 of 22&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Literal types&lt;/td&gt;
&lt;td&gt;18 of 22&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog&lt;/td&gt;
&lt;td&gt;21 of 36&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AnalyzePlan requests&lt;/td&gt;
&lt;td&gt;11 of 14&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config operations&lt;/td&gt;
&lt;td&gt;7 of 7&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commands&lt;/td&gt;
&lt;td&gt;4 of 20&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same suite passes against Spark 4.1.3, 4.2.0 and 4.3.0-rc1. The unimplemented messages are mostly the runtime half of the protocol (streaming, UDFs, ML, pipelines), plus a tail of statistics relations like &lt;code&gt;crosstab&lt;/code&gt; and &lt;code&gt;approx_quantile&lt;/code&gt; that I have not needed yet.&lt;/p&gt;

&lt;p&gt;The tests found bugs that a demo doesn't make obvious. &lt;code&gt;na_fill&lt;/code&gt; sent whole numbers as 32-bit integer literals, which the server's fill rejects outright (PySpark always sends longs, so it never meets this). Sort orders without an explicit null ordering went up as "unspecified", so an ascending sort put nulls last where Spark and PySpark put them first. And &lt;code&gt;show()&lt;/code&gt; did not end with a newline, so two tables printed in a row ran together. The suite also had a bug of its own: it compared a &lt;code&gt;TIMESTAMP&lt;/code&gt; literal without pinning the session time zone, so it passed on every server running in UTC (all of my Docker ones) and failed on the one running natively on my machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study: A small options desk
&lt;/h3&gt;

&lt;p&gt;OCaml's best-known home outside academia is finance, where trading firms write their pricing and risk code in it, wirh the same firms keep their market history in data lakes that Spark reads. OCaml and Spark here have historically met through Python or a SQL string.&lt;/p&gt;

&lt;p&gt;So my E2E is a small options desk with one question: &lt;strong&gt;should 30-day index options be priced off realized volatility, measured from recent prices, or off the VIX, the market's implied volatility?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The pricing code is borrowed. It is the closed-form Black-Scholes module from Case Ollila's &lt;a href="https://github.com/crollila/option-pricing-model-comparison" rel="noopener noreferrer"&gt;option-pricing-model-comparison&lt;/a&gt; (MIT), typed OCaml with Greeks in trader units and every numeric routine written from scratch, vendored unchanged into the example. The market data is public: daily S&amp;amp;P 500, Nasdaq Composite and Dow Jones closes, the VIX, and the 3-month Treasury yield, all from &lt;a href="https://fred.stlouisfed.org/" rel="noopener noreferrer"&gt;FRED&lt;/a&gt;. The whole thing runs as one OCaml program, &lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/08_spark_connect_ocaml" rel="noopener noreferrer"&gt;&lt;code&gt;spark-demos/demos/08_spark_connect_ocaml&lt;/code&gt;&lt;/a&gt;, against a standalone Spark 4.2 cluster with Delta Lake:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgc3J2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGRhdGEgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgQVtcIm9wdGlvbnNfZGVzay5leGU8YnIvPk9DYW1sIGNsaWVudCArPGJyLz5vcHRpb25fcHJpY2VyXCJdOjo6eW91IC0tPnxcInNjOi8vLCBnUlBDXCJ8IEJbXCJTcGFyayBDb25uZWN0IHNlcnZlcjxici8-RGVsdGEgTGFrZVwiXTo6OnNydlxuICBCIC0tPnxcInRhc2tzXCJ8IFcxW1wid29ya2VyIDE8YnIvPmV4ZWN1dG9yICsgUHl0aG9uXCJdOjo6ZXhcbiAgQiAtLT58XCJ0YXNrc1wifCBXMltcIndvcmtlciAyPGJyLz5leGVjdXRvciArIFB5dGhvblwiXTo6OmV4XG4gIFcxIC0tPnxcInB5c3BhcmtfZnJlZFwifCBGWyhcIkZSRUQ8YnIvPmRhaWx5IHNlcmllc1wiKV06OjpkYXRhXG4gIFcyIC0tPiBGXG4gIFcxIDwtLT4gRFsoXCJEZWx0YSB3YXJlaG91c2U8YnIvPnByaWNlcyBcdTAwYjcgZGFpbHkgXHUwMGI3IGJvb2tcIildOjo6ZGF0YVxuICBXMiA8LS0-IERcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgc3J2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGRhdGEgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgQVtcIm9wdGlvbnNfZGVzay5leGU8YnIvPk9DYW1sIGNsaWVudCArPGJyLz5vcHRpb25fcHJpY2VyXCJdOjo6eW91IC0tPnxcInNjOi8vLCBnUlBDXCJ8IEJbXCJTcGFyayBDb25uZWN0IHNlcnZlcjxici8-RGVsdGEgTGFrZVwiXTo6OnNydlxuICBCIC0tPnxcInRhc2tzXCJ8IFcxW1wid29ya2VyIDE8YnIvPmV4ZWN1dG9yICsgUHl0aG9uXCJdOjo6ZXhcbiAgQiAtLT58XCJ0YXNrc1wifCBXMltcIndvcmtlciAyPGJyLz5leGVjdXRvciArIFB5dGhvblwiXTo6OmV4XG4gIFcxIC0tPnxcInB5c3BhcmtfZnJlZFwifCBGWyhcIkZSRUQ8YnIvPmRhaWx5IHNlcmllc1wiKV06OjpkYXRhXG4gIFcyIC0tPiBGXG4gIFcxIDwtLT4gRFsoXCJEZWx0YSB3YXJlaG91c2U8YnIvPnByaWNlcyBcdTAwYjcgZGFpbHkgXHUwMGI3IGJvb2tcIildOjo6ZGF0YVxuICBXMiA8LS0-IERcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 2" width="1117" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The program is a pipeline, and each step that can check itself does so against an independent calculation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBzdGVwIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIG9jIGZpbGw6IzlhN2IwMCxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzlhN2IwMCxjb2xvcjojZmZmZmZmXG4gIFMxW1wiMSBpbmdlc3Q8YnIvPlB5dGhvbiBhZGFwdGVyIFx1MjE5MiBEZWx0YVwiXTo6OnN0ZXAgLS0-IFMyW1wiMiBhbmFseXRpY3M8YnIvPnJldHVybnMgXHUwMGI3IHJlYWxpemVkIHZvbCBcdTAwYjc8YnIvPmRyYXdkb3duc1wiXTo6OnN0ZXBcbiAgUzIgLS0-IFMzW1wiMyBwcmljaW5nPGJyLz5CbGFjay1TY2hvbGVzIGluIE9DYW1sXCJdOjo6b2NcbiAgUzMgLS0-IFM0W1wiNCByaXNrIGxheWVyPGJyLz5tZXRyaWMgdmlld1wiXTo6OnN0ZXBcbiAgUzQgLS0-IFM1W1wiNSBuZXh0IGRheTxici8-TUVSR0UgXHUwMGI3IHRpbWUgdHJhdmVsXCJdOjo6c3RlcFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBzdGVwIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIG9jIGZpbGw6IzlhN2IwMCxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzlhN2IwMCxjb2xvcjojZmZmZmZmXG4gIFMxW1wiMSBpbmdlc3Q8YnIvPlB5dGhvbiBhZGFwdGVyIFx1MjE5MiBEZWx0YVwiXTo6OnN0ZXAgLS0-IFMyW1wiMiBhbmFseXRpY3M8YnIvPnJldHVybnMgXHUwMGI3IHJlYWxpemVkIHZvbCBcdTAwYjc8YnIvPmRyYXdkb3duc1wiXTo6OnN0ZXBcbiAgUzIgLS0-IFMzW1wiMyBwcmljaW5nPGJyLz5CbGFjay1TY2hvbGVzIGluIE9DYW1sXCJdOjo6b2NcbiAgUzMgLS0-IFM0W1wiNCByaXNrIGxheWVyPGJyLz5tZXRyaWMgdmlld1wiXTo6OnN0ZXBcbiAgUzQgLS0-IFM1W1wiNSBuZXh0IGRheTxici8-TUVSR0UgXHUwMGI3IHRpbWUgdHJhdmVsXCJdOjo6c3RlcFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 3" width="1238" height="118"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Market data through a Python adapter.&lt;/strong&gt; The adapter is a short &lt;a href="https://github.com/lisancao/spark-demos/blob/main/demos/08_spark_connect_ocaml/pyspark_fred/__init__.py" rel="noopener noreferrer"&gt;Python data source&lt;/a&gt; that downloads FRED series, one partition per series, installed on every node as a &lt;code&gt;pyspark_fred&lt;/code&gt; package so Spark discovers it with no per-session registration. From OCaml the adapter is a format name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fred&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="s2"&gt;"fred"&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;option&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="s2"&gt;"series"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concat&lt;/span&gt; &lt;span class="s2"&gt;","&lt;/span&gt; &lt;span class="n"&gt;series&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client never learns that Python is involved: it sends a &lt;code&gt;Read&lt;/code&gt; relation naming a format, the executors run the adapter, and the same Arrow batches come back that the decoder reads for any query. The ingest lands ten years of closes in a Delta table (ten years because that is what FRED's agreement with S&amp;amp;P Dow Jones Indices lets it publish for their series, and the job trims the Nasdaq to the same window):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------+--------+----------+----------+
|   series|count(1)| min(date)| max(date)|
+---------+--------+----------+----------+
|     DJIA|    2514|2016-10-10|2026-10-09|
|NASDAQCOM|    2515|2016-10-10|2026-10-09|
|    SP500|    2514|2016-10-10|2026-10-09|
+---------+--------+----------+----------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The heavy part, in Spark.&lt;/strong&gt; Daily log returns come from a &lt;code&gt;lag&lt;/code&gt; over each series, realized volatility is the standard deviation of the last 21 returns annualized by the square root of 252 trading days, and drawdown is each close against the running maximum, all window functions built in OCaml and evaluated on the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;by_day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partition_by&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"series"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;with_order_by&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"date"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;last_21&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows_between&lt;/span&gt; &lt;span class="n"&gt;by_day&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;with_columns&lt;/span&gt; &lt;span class="n"&gt;prices&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ret"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"value"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;by_day&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;with_column&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="s2"&gt;"rv21"&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stddev&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"ret"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;last_21&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nn"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqrt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="mi"&gt;252&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------+----------+--------+--------+------------+----------------+-------------+
|   series|      date|    spot|rv21_pct|avg_rv21_pct|max_drawdown_pct|worst_day_pct|
+---------+----------+--------+--------+------------+----------------+-------------+
|     DJIA|2026-10-09|51654.95|    10.2|        14.3|           -37.1|        -13.8|
|NASDAQCOM|2026-10-09|27366.17|    14.2|        19.4|           -36.4|        -13.1|
|    SP500|2026-10-09| 7811.54|    10.0|        15.0|           -33.9|        -12.8|
+---------+----------+--------+--------+------------+----------------+-------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worst single day for all three is 16 March 2020. The deepest drawdown is March 2020 for the S&amp;amp;P 500 and the Dow, but December 2022 for the Nasdaq, whose 2022 bear market went further than the Covid crash. Joining the S&amp;amp;P 500's trailing realized volatility to the VIX day by day answers half the desk's question: over the decade the VIX sat above realized volatility on 86.1% of trading days, by 3.7 volatility points on average. That gap is a rough measure of the volatility risk premium, the price the market charges for insurance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pricing, in OCaml.&lt;/strong&gt; The program pulls the latest close and realized volatility for each index, plus the current VIX and T-bill yield, and hands them to the pricer as its author intended (I set the dividend yield to zero to keep the example small):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;strike&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;ttm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="o"&gt;/.&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="o"&gt;.;&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;div_yield&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;vol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rv&lt;/span&gt; &lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;BS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;greeks&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;BS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;BS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vega_per_point&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vega&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;BS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;theta_per_day&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  risk-free (3m T-bill) 4.23%, VIX 15.4
  index      kind contracts     strike    vol  premium   delta   vega/pt theta/day
  SP500      call        20    7811.54  10.0%   103.40   0.554      8.85     -1.96
  SP500      put        -10    7811.54  10.0%    76.28  -0.446      8.85     -1.06
  NASDAQCOM  call         5   27366.17  14.2%   492.76   0.542     31.13     -9.03
  DJIA       put          8   51654.95  10.2%   516.15  -0.447     58.56     -7.22
  SP500 30-day ATM call: 103.40 at realized vol (10.0%), 151.41 at VIX (15.4%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same at-the-money S&amp;amp;P call is worth 103.40 at realized volatility and 151.41 at the VIX, 46% more, which is the volatility risk premium expressed in dollars. Put-call parity, the pricer's own check that its calls and puts are consistent with each other, holds for every index to within 5e-13. The priced book then goes back to Spark the other way, as a &lt;code&gt;LocalRelation&lt;/code&gt; built from OCaml values and written as a Delta table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The risk layer, as a metric view.&lt;/strong&gt; Spark 4.2 added metric views (&lt;a href="https://issues.apache.org/jira/browse/SPARK-54403" rel="noopener noreferrer"&gt;SPARK-54403&lt;/a&gt;, &lt;a href="https://issues.apache.org/jira/browse/SPARK-54405" rel="noopener noreferrer"&gt;SPARK-54405&lt;/a&gt;): a view defined in YAML as a source, dimensions, and measures, where each measure is an aggregate defined once and queried with &lt;code&gt;measure()&lt;/code&gt; under whatever grouping the query asks for. A risk layer has the same shape, so the desk's exposures are defined once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.1"&lt;/span&gt;
&lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;desk_book&lt;/span&gt;
&lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;index_name&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;index_name&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind&lt;/span&gt;
&lt;span class="na"&gt;measures&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;premium_usd&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cast(round(sum(premium * contracts * 100)) as bigint)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dollar_delta&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cast(round(sum(delta * contracts * 100 * spot)) as bigint)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vega_usd_per_pt&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cast(round(sum(vega_pt * contracts * 100)) as bigint)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;theta_usd_per_day&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cast(round(sum(theta_day * contracts * 100)) as bigint)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client has no code for metric views at all. Defining one is a SQL statement, and querying one from the DataFrame API is a function call, because &lt;code&gt;measure&lt;/code&gt; is a function the server resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ocaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;group_by&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="s2"&gt;"desk_risk"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="s2"&gt;"index_name"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
&lt;span class="nn"&gt;Dataframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nn"&gt;Grouped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agg&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                           &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"premium_usd"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"dollar_delta"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"vega_usd_per_pt"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"theta_usd_per_day"&lt;/span&gt; &lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------+-----------+------------+---------------+-----------------+
|index_name|premium_usd|dollar_delta|vega_usd_per_pt|theta_usd_per_day|
+----------+-----------+------------+---------------+-----------------+
|      DJIA|     412920|   -18467411|          46844|            -5775|
| NASDAQCOM|     246379|     7417769|          15563|            -4514|
|     SP500|     130507|    12138611|           8853|            -2867|
+----------+-----------+------------+---------------+-----------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check computes the same four sums directly from the book table and compares them row by row. They match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The next day.&lt;/strong&gt; The desk adds to its S&amp;amp;P calls and closes the Nasdaq position, as a &lt;code&gt;MERGE&lt;/code&gt; from a DataFrame of fills built in OCaml. Delta keeps the previous book, and &lt;code&gt;VERSION AS OF&lt;/code&gt; reads it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  yesterday: [DJIA, put, 8] [NASDAQCOM, call, 5] [SP500, call, 20] [SP500, put, -10]
  today:     [DJIA, put, 8] [SP500, call, 25] [SP500, put, -10]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Compared against&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;Put-call parity, each index&lt;/td&gt;
&lt;td&gt;the pricer's own arbitrage identity&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priced book written to Delta&lt;/td&gt;
&lt;td&gt;the number of positions sent&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk measures from the metric view&lt;/td&gt;
&lt;td&gt;the same aggregation on the book table&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time travel to yesterday's book&lt;/td&gt;
&lt;td&gt;the book before the &lt;code&gt;MERGE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the cluster, every Spark job succeeded, with the tasks split close to evenly between the two workers' executors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left on my to do list, and where the official clients spend their time
&lt;/h2&gt;

&lt;p&gt;What this client does not do is also a map of where Connect stops being a plan-and-Arrow protocol and starts being a runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reattachable execution.&lt;/strong&gt; The official clients use &lt;code&gt;ReattachExecute&lt;/code&gt; and &lt;code&gt;ReleaseExecute&lt;/code&gt; so a dropped connection can resume a long query from the last response it saw. This one uses plain &lt;code&gt;ExecutePlan&lt;/code&gt;, which Spark 4 servers fully support, but a flaky network on an hour-long query loses the query. It is the first thing to add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-defined functions.&lt;/strong&gt; A UDF in Connect is serialized &lt;em&gt;code&lt;/em&gt; shipped to the server: pickled Python, or a Scala closure with its JARs uploaded through &lt;code&gt;AddArtifacts&lt;/code&gt;. It only works when the server can run the client's language, and there is no OCaml runtime on a Spark executor. &lt;strong&gt;This client will never have UDFs&lt;/strong&gt;; it has the whole SQL function library and higher-order functions with lambdas. Code that already lives on the server is a different matter: the desk's market-data adapter is Python installed on the executors, and any client can call it by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming, MLlib and pipeline commands.&lt;/strong&gt; The messages are generated and reachable, but each is a stateful API rather than a plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dictionary-encoded or compressed Arrow batches.&lt;/strong&gt; Spark does not send them by default; the decoder raises if it ever meets one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each item is either resilience or a place where the client has to be a runtime instead of a plan builder. The line between those halves is the line Connect draws. The first half is small enough to build in a language the Spark project will never target. The second half is where the Go, Swift and Rust clients are spending their effort, and it is the half to check before building on any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Spark Connect overview&lt;/a&gt; and the &lt;a href="https://github.com/apache/spark/tree/master/sql/connect/common/src/main/protobuf/spark/connect" rel="noopener noreferrer"&gt;protobuf definitions&lt;/a&gt; that are the real spec&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md" rel="noopener noreferrer"&gt;gRPC over HTTP/2&lt;/a&gt;, the framing and trailer rules from step 2&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arrow.apache.org/docs/format/Columnar.html#serialization-and-interprocess-communication-ipc" rel="noopener noreferrer"&gt;Arrow IPC streaming format&lt;/a&gt; and the &lt;a href="https://github.com/apache/arrow/tree/main/format" rel="noopener noreferrer"&gt;FlatBuffers schema&lt;/a&gt; it's described in&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lisancao/spark-connect-ocaml" rel="noopener noreferrer"&gt;spark-connect-ocaml&lt;/a&gt;, the client this post walks through, and the official &lt;a href="https://github.com/apache/spark-connect-go" rel="noopener noreferrer"&gt;Go&lt;/a&gt;, &lt;a href="https://github.com/apache/spark-connect-rust" rel="noopener noreferrer"&gt;Rust&lt;/a&gt; and &lt;a href="https://github.com/apache/spark-connect-swift" rel="noopener noreferrer"&gt;Swift&lt;/a&gt; clients to compare it against&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>sparkconnect</category>
      <category>ocaml</category>
      <category>arrow</category>
    </item>
    <item>
      <title>Hosting an Entire Open Lakehouse on Port 8080</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:21:07 +0000</pubDate>
      <link>https://dev.to/lisancao/hosting-an-entire-open-lakehouse-on-port-8080-3m0g</link>
      <guid>https://dev.to/lisancao/hosting-an-entire-open-lakehouse-on-port-8080-3m0g</guid>
      <description>&lt;p&gt;Most of my job, at least the part people see, is running Spark code in front of other people and promising it'll work. The catch is that I almost never demo the stable stuff. Nobody wants a talk about the version of Spark they're already running; they want the release that came out last month, or the feature that's still a JIRA ticket, so the thing I'm showing is usually newer than its documentation and occasionally newer than any release. Iceberg ships on its own schedule, both of them depend on an exact Hadoop and an exact AWS SDK, and the whole arrangement can quietly stop working between the week I build a demo and the week I give it without a single line of my code changing.&lt;/p&gt;

&lt;p&gt;So in December 2025 I started &lt;a href="https://github.com/lisancao/lakehouse-at-home" rel="noopener noreferrer"&gt;lakehouse-at-home&lt;/a&gt;: somewhere real to install new versions of Spark and Iceberg and find out what broke before an audience did. This is how it got built, one service and one problem at a time. Each section adds to the architecture diagram; &lt;strong&gt;new pieces are highlighted&lt;/strong&gt;, and everything built before them fades into the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on my own machine
&lt;/h2&gt;

&lt;p&gt;Honestly, part of it was curiosity about the hardware. My desktop is a Ryzen 7 7800X3D with 64GB of RAM and an RTX 5070 Ti, which is a ridiculous amount of machine to spend mostly on a browser, and I wanted to know whether it could carry a &lt;em&gt;real&lt;/em&gt; lakehouse: not single-node Spark reading a Parquet file, but the actual set of services a data platform runs on, all at once, talking to each other the way they would in production.&lt;/p&gt;

&lt;p&gt;The rest was control. Managed platforms give you the versions they support, not the one that shipped last week, and certainly not a build from Spark's master branch, which is where I ended up more than once. A demo also has to behave the same way every single time; while preparing a recording in March I wrote the requirement down more bluntly than any design doc would: "keep in mind with demos theres a certain amount of cleanup and restarting needed for each session and predictable behaviour". Tearing everything down and bringing it back identical is trivial when the whole lakehouse lives on a box I own, and it costs nothing, which matters when you do it several times a day.&lt;/p&gt;

&lt;p&gt;And I had a longer game in mind. If I was going to run a lakehouse at home anyway, it could eventually hold data that actually lives at home: readings from my IoT devices, my own documents, the kind of personal data I'd rather not ship to someone else's cloud to query. &lt;strong&gt;Local wasn't only the cheap option; it was the only one where the data could eventually be mine.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Starting from the data
&lt;/h2&gt;

&lt;p&gt;I started from the tables rather than the compute, because the tables were the point. I wanted &lt;strong&gt;Iceberg and Delta side by side&lt;/strong&gt;, reading and writing the same kinds of data, so that when a new release changed how one of them behaved I'd see the difference rather than read about it. (That paid off. Delta 4.0.1, for example, turned out not to load on Spark 4.1 at all, which I only found out because I tried.)&lt;/p&gt;

&lt;p&gt;An open table format is really three things pretending to be one: an engine that reads and writes, a &lt;strong&gt;catalog&lt;/strong&gt; that records which tables exist and which snapshot of each is current, and &lt;strong&gt;storage&lt;/strong&gt; for the data files themselves. Each needs somewhere to live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggZDFfSE9TVFtcImhvc3RcIl1cbiAgICBkMV9QR1soXCJQb3N0Z3JlU1FMIDE2PGJyLz5JY2ViZXJnIEpEQkMgY2F0YWxvZzxici8-OjU0MzJcIildOjo6bmV3XG4gICAgZDFfU1dbKFwiU2Vhd2VlZEZTPGJyLz5TMyBBUEk8YnIvPjo4MzMzXCIpXTo6Om5ld1xuICBlbmRcbiAgc3ViZ3JhcGggZDFfRE9DS0VSW1wiRG9ja2VyXCJdXG4gICAgZDFfUzQwW1wiU3BhcmsgNC4wLjE8YnIvPm1hc3RlciA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpuZXdcbiAgZW5kXG4gIGQxX1M0MCAtLT58XCJJY2ViZXJnIDEuMTAgbWV0YWRhdGFcInwgZDFfUEdcbiAgZDFfUzQwIC0tPnxcInMzYSBcdTAwYjcgUGFycXVldCBmaWxlc1wifCBkMV9TV1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggZDFfSE9TVFtcImhvc3RcIl1cbiAgICBkMV9QR1soXCJQb3N0Z3JlU1FMIDE2PGJyLz5JY2ViZXJnIEpEQkMgY2F0YWxvZzxici8-OjU0MzJcIildOjo6bmV3XG4gICAgZDFfU1dbKFwiU2Vhd2VlZEZTPGJyLz5TMyBBUEk8YnIvPjo4MzMzXCIpXTo6Om5ld1xuICBlbmRcbiAgc3ViZ3JhcGggZDFfRE9DS0VSW1wiRG9ja2VyXCJdXG4gICAgZDFfUzQwW1wiU3BhcmsgNC4wLjE8YnIvPm1hc3RlciA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpuZXdcbiAgZW5kXG4gIGQxX1M0MCAtLT58XCJJY2ViZXJnIDEuMTAgbWV0YWRhdGFcInwgZDFfUEdcbiAgZDFfUzQwIC0tPnxcInMzYSBcdTAwYjcgUGFycXVldCBmaWxlc1wifCBkMV9TV1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="724" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The simplest catalog that behaves like a real one is Iceberg's JDBC catalog, which keeps table metadata in an ordinary relational database, so I used Postgres, running natively on the host. For storage I wanted S3 semantics rather than a local directory, because every real lakehouse sits on object storage, so I picked &lt;strong&gt;SeaweedFS&lt;/strong&gt;, which speaks the S3 API, and ran it natively too. On the Spark side, wiring that up is a handful of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;spark.sql.extensions&lt;/span&gt;              &lt;span class="err"&gt;org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.iceberg&lt;/span&gt;         &lt;span class="err"&gt;org.apache.iceberg.spark.SparkCatalog&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.iceberg.type&lt;/span&gt;    &lt;span class="err"&gt;jdbc&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.iceberg.uri&lt;/span&gt;     &lt;span class="py"&gt;jdbc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;postgresql://localhost:5432/iceberg_catalog&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.iceberg.warehouse&lt;/span&gt;  &lt;span class="py"&gt;s3a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;//lakehouse/warehouse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's where it got hard, because Spark doesn't talk to S3 by itself. It goes through Hadoop's &lt;code&gt;s3a&lt;/code&gt; connector, which is compiled against one specific version of the AWS SDK, and Iceberg's runtime has to agree with both. Get any of them slightly wrong and nothing tells you which one; you get a &lt;code&gt;ClassNotFoundException&lt;/code&gt;, or a &lt;code&gt;NoSuchMethodError&lt;/code&gt; from somewhere deep in the SDK, or a credentials failure that looks exactly like a credentials failure and isn't. Getting Spark to authenticate against SeaweedFS at all took a lot of trial and error with the &lt;code&gt;s3a&lt;/code&gt; settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;spark.hadoop.fs.s3a.endpoint&lt;/span&gt;                &lt;span class="py"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;//localhost:8333&lt;/span&gt;
&lt;span class="err"&gt;spark.hadoop.fs.s3a.path.style.access&lt;/span&gt;       &lt;span class="err"&gt;true&lt;/span&gt;    &lt;span class="c"&gt;# SeaweedFS isn't AWS: no virtual-host bucket URLs
&lt;/span&gt;&lt;span class="err"&gt;spark.hadoop.fs.s3a.connection.ssl.enabled&lt;/span&gt;  &lt;span class="err"&gt;false&lt;/span&gt;   &lt;span class="c"&gt;# plain HTTP on localhost
&lt;/span&gt;&lt;span class="err"&gt;spark.hadoop.fs.s3a.impl&lt;/span&gt;                    &lt;span class="err"&gt;org.apache.hadoop.fs.s3a.S3AFileSystem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then a lot more trial and error with the JARs, until the combination that worked was pinned so hard that the first README warns about it in the troubleshooting section, parenthetical and all: &lt;em&gt;Hadoop 3.4.1 → AWS SDK v2.24.6 (not newer!)&lt;/em&gt;. The download script from that week still pulls &lt;strong&gt;both&lt;/strong&gt; AWS SDK bundles, the v1 bundle and the v2 one, because by that point I wasn't going to argue with anything that worked. All told it came to "~860MB of dependencies (exact versions required for compatibility)":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Pinned to&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;Spark&lt;/td&gt;
&lt;td&gt;4.0.1 (Scala 2.13, Java 17)&lt;/td&gt;
&lt;td&gt;the thing under test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg runtime&lt;/td&gt;
&lt;td&gt;&lt;code&gt;iceberg-spark-runtime-4.0_2.13-1.10.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;must match Spark's minor version and Scala version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hadoop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hadoop-aws-3.4.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;provides &lt;code&gt;s3a&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS SDK v2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bundle-2.24.6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the exact version Hadoop 3.4.1 was built against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS SDK v1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-java-sdk-bundle-1.12.780&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;belt and braces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres JDBC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;42.7.4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;for the Iceberg catalog&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Most of what goes wrong when you upgrade a lakehouse isn't the engine; it's the dependency graph around it&lt;/strong&gt;, and the only way to learn that graph is to break it somewhere safe. The project's agent guide still has a section called &lt;em&gt;Critical Versions: Do not change without testing&lt;/em&gt;, which is the same lesson written for machines.&lt;/p&gt;

&lt;p&gt;Once it worked, it was an ordinary lakehouse, and the first thing any test of it does is the ordinary thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;NAMESPACE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;iceberg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;iceberg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;    &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;      &lt;span class="nb"&gt;DOUBLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;iceberg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last decision in that first week was where everything should run, and it shaped the next nine months more than anything else. Spark went in Docker, because I'd want to swap its versions constantly. Postgres and SeaweedFS stayed native on the host, because they were storage, and storage should outlive the experiments. The first compose file still shows the argument I had with myself about how the two sides would reach each other: a bridge network, published ports and a &lt;code&gt;host.docker.internal&lt;/code&gt; gateway, all commented out, and above them the line that won:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/spark:4.0.1-scala2.13-java17-python3-r-ubuntu&lt;/span&gt;
  &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host"&lt;/span&gt;
&lt;span class="c1"&gt;#    ports:&lt;/span&gt;
&lt;span class="c1"&gt;#      - "8090:8080"  # Spark Master Web UI&lt;/span&gt;
&lt;span class="c1"&gt;#      - "7077:7077"  # Spark Master&lt;/span&gt;
&lt;span class="c1"&gt;#    networks:&lt;/span&gt;
&lt;span class="c1"&gt;#      - lakehouse&lt;/span&gt;
&lt;span class="c1"&gt;#    extra_hosts:&lt;/span&gt;
&lt;span class="c1"&gt;#      - "host.docker.internal:host-gateway"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Host networking made everything simple on my machine, and it's also the reason the title of this post is a port number.&lt;/strong&gt; Every container shared the host's ports with every native service, so from then on each new component had to find a port nobody else was using. Keep an eye on the port numbers in the diagrams; they're the plot.&lt;/p&gt;

&lt;h2&gt;
  
  
  A catalog I had to build
&lt;/h2&gt;

&lt;p&gt;Once I had tables, I wanted the kind of catalog people actually run in production: one that governs access, vends credentials, and lets more than one engine find the same tables. That meant &lt;strong&gt;Unity Catalog OSS&lt;/strong&gt;, running alongside the Postgres catalog rather than replacing it, so both paths stayed testable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQyX0hPU1RbXCJob3N0XCJdXG4gICAgZDJfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkMl9TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkMl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkMl9TNDBbXCJTcGFyayA0LjAuMSA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpvbGRcbiAgICBkMl9VQ1tcIlVuaXR5IENhdGFsb2cgT1NTPGJyLz4wLjUuMCBzbmFwc2hvdCwgYnVpbHQgZnJvbSBzb3VyY2U8YnIvPjo4MDgxICh3YW50ZWQgOjgwODApXCJdOjo6bmV3XG4gIGVuZFxuICBkMl9TNDAgLS0-IGQyX1BHXG4gIGQyX1M0MCAtLT4gZDJfU1dcbiAgZDJfUzQwIC0tPnxcIkljZWJlcmcgUkVTVCBjYXRhbG9nXCJ8IGQyX1VDXG4gIGQyX1VDIC0tPnxcInRhYmxlIGxvY2F0aW9uc1wifCBkMl9TV1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQyX0hPU1RbXCJob3N0XCJdXG4gICAgZDJfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkMl9TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkMl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkMl9TNDBbXCJTcGFyayA0LjAuMSA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpvbGRcbiAgICBkMl9VQ1tcIlVuaXR5IENhdGFsb2cgT1NTPGJyLz4wLjUuMCBzbmFwc2hvdCwgYnVpbHQgZnJvbSBzb3VyY2U8YnIvPjo4MDgxICh3YW50ZWQgOjgwODApXCJdOjo6bmV3XG4gIGVuZFxuICBkMl9TNDAgLS0-IGQyX1BHXG4gIGQyX1M0MCAtLT4gZDJfU1dcbiAgZDJfUzQwIC0tPnxcIkljZWJlcmcgUkVTVCBjYXRhbG9nXCJ8IGQyX1VDXG4gIGQyX1VDIC0tPnxcInRhYmxlIGxvY2F0aW9uc1wifCBkMl9TV1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="1141" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem was that the features I wanted were ahead of the published images. When I was preparing an episode of &lt;em&gt;&lt;a href="https://systemsnull.net/talks" rel="noopener noreferrer"&gt;OverArchitected&lt;/a&gt;&lt;/em&gt; in March, the release I needed didn't have a Docker image yet, so I cloned the repository, built the image myself, and ran a 0.5.0 snapshot from source. It was the first time the stack ran something that hadn't been released, and it wasn't the last. It also needed a port: its default was 8080, which Spark's UI already had, so it moved to 8081. From Spark's side it's just a second catalog, talking Iceberg's REST protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;spark.sql.catalog.unity&lt;/span&gt;               &lt;span class="err"&gt;org.apache.iceberg.spark.SparkCatalog&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.unity.catalog-impl&lt;/span&gt;  &lt;span class="err"&gt;org.apache.iceberg.rest.RESTCatalog&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.unity.uri&lt;/span&gt;           &lt;span class="py"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;//localhost:8081/api/2.1/unity-catalog/iceberg&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Streams: Kafka and Zookeeper
&lt;/h2&gt;

&lt;p&gt;Batch tables only tell half the story, so the next piece was streaming: Kafka for the events, with Zookeeper to coordinate it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQzX0hPU1RbXCJob3N0XCJdXG4gICAgZDNfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkM19TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkM19ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkM19aS1tcIlpvb2tlZXBlciA6MjE4MTxici8-YWRtaW4gOjk5OTkgKHdhbnRlZCA6ODA4MClcIl06OjpuZXdcbiAgICBkM19LW1wiS2Fma2EgOjkwOTJcIl06OjpuZXdcbiAgICBkM19TNDBbXCJTcGFyayA0LjAuMSA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpvbGRcbiAgICBkM19VQ1tcIlVuaXR5IENhdGFsb2cgOjgwODFcIl06OjpvbGRcbiAgZW5kXG4gIGQzX1pLIC0tLSBkM19LXG4gIGQzX0sgLS0-fFwicmVhZFN0cmVhbVwifCBkM19TNDBcbiAgZDNfUzQwIC0tPiBkM19QR1xuICBkM19TNDAgLS0-IGQzX1NXXG4gIGQzX1M0MCAtLT4gZDNfVUNcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQzX0hPU1RbXCJob3N0XCJdXG4gICAgZDNfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkM19TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkM19ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkM19aS1tcIlpvb2tlZXBlciA6MjE4MTxici8-YWRtaW4gOjk5OTkgKHdhbnRlZCA6ODA4MClcIl06OjpuZXdcbiAgICBkM19LW1wiS2Fma2EgOjkwOTJcIl06OjpuZXdcbiAgICBkM19TNDBbXCJTcGFyayA0LjAuMSA6NzA3NyBcdTAwYjcgVUkgOjgwODBcIl06OjpvbGRcbiAgICBkM19VQ1tcIlVuaXR5IENhdGFsb2cgOjgwODFcIl06OjpvbGRcbiAgZW5kXG4gIGQzX1pLIC0tLSBkM19LXG4gIGQzX0sgLS0-fFwicmVhZFN0cmVhbVwifCBkM19TNDBcbiAgZDNfUzQwIC0tPiBkM19QR1xuICBkM19TNDAgLS0-IGQzX1NXXG4gIGQzX1M0MCAtLT4gZDNfVUNcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 3" width="1190" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This part was harder than it had any right to be. When I finally sat down with it in January, the prompt was the whole mood of the week: "debug my kafka setup, why can't spark read it?" The answer was about eight small things at once. The streaming job never ran &lt;code&gt;from_json()&lt;/code&gt; on Kafka's &lt;code&gt;value&lt;/code&gt; column, so it was trying to aggregate raw bytes; a column was referenced as &lt;code&gt;total.revenue&lt;/code&gt; instead of &lt;code&gt;total_revenue&lt;/code&gt;; one alias was the wrong case; a debug query was blocking the stream. Kafka itself wasn't on host networking, so Spark couldn't reach it, and once it was, its connection string to Zookeeper was wrong. And Zookeeper's admin server, which listens on 8080 by default, was fighting Spark's web UI for the same port. Zookeeper lost, and moved to 9999.&lt;/p&gt;

&lt;p&gt;The fix for the first of those is the part every Kafka tutorial skips: Kafka hands Spark bytes, and you have to tell it what they mean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;kafka_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka.bootstrap.servers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost:9092&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subscribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startingOffsets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kafka_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event_schema&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka_timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weeks later I was still citing Kafka as the cautionary tale; when I worried I was adding things too fast, the reason I gave was "how much trouble even setting up and testing kafka was".&lt;/p&gt;

&lt;h2&gt;
  
  
  Data that misbehaves
&lt;/h2&gt;

&lt;p&gt;A lakehouse with no data in it proves nothing, and a lakehouse full of clean data proves less than you'd think: every pipeline looks correct when nothing is ever late, duplicated or missing. I needed data that behaved like the real thing, which is to say &lt;em&gt;badly&lt;/em&gt;, and at a volume that would actually exercise the stack.&lt;/p&gt;

&lt;p&gt;Rather than invent a domain, I borrowed one. Databricks' &lt;a href="https://github.com/databricks-solutions/caspers-kitchens" rel="noopener noreferrer"&gt;Casper's Kitchens&lt;/a&gt; simulates a ghost-kitchen delivery business, and I asked the agent to study how its generators worked and rebuild the idea for this stack ("please consult me first", I added, because I wanted to understand the shape of the data before it existed). What came out is a small Python package that simulates the full life of a food order:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGQ0X0Fbb3JkZXJfY3JlYXRlZF0gLS0-IGQ0X0Jba2l0Y2hlbl9zdGFydGVkXSAtLT4gZDRfQ1traXRjaGVuX2ZpbmlzaGVkXSAtLT4gZDRfRFtvcmRlcl9yZWFkeV1cbiAgICBkNF9EIC0tPiBkNF9FW2RyaXZlcl9hcnJpdmVkXSAtLT4gZDRfRltkcml2ZXJfcGlja2VkX3VwXSAtLT4gZDRfR1tcImRyaXZlcl9waW5nPGJyLz4oZXZlcnkgNjBzIGVuIHJvdXRlKVwiXSAtLT4gZDRfSFtkZWxpdmVyZWRdXG4gICAgZDRfQSAtLi0-fFwiY3VzdG9tZXIgY2FuY2Vsc1wifCBkNF9YW29yZGVyX2NhbmNlbGxlZF1cbiAgICBkNF9CIC0uLT58XCJraXRjaGVuIGNhbmNlbHNcInwgZDRfWFxuICAgIGQ0X0QgLS4tPnxcImtpdGNoZW4gY2FuY2Vsc1wifCBkNF9YXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGQ0X0Fbb3JkZXJfY3JlYXRlZF0gLS0-IGQ0X0Jba2l0Y2hlbl9zdGFydGVkXSAtLT4gZDRfQ1traXRjaGVuX2ZpbmlzaGVkXSAtLT4gZDRfRFtvcmRlcl9yZWFkeV1cbiAgICBkNF9EIC0tPiBkNF9FW2RyaXZlcl9hcnJpdmVkXSAtLT4gZDRfRltkcml2ZXJfcGlja2VkX3VwXSAtLT4gZDRfR1tcImRyaXZlcl9waW5nPGJyLz4oZXZlcnkgNjBzIGVuIHJvdXRlKVwiXSAtLT4gZDRfSFtkZWxpdmVyZWRdXG4gICAgZDRfQSAtLi0-fFwiY3VzdG9tZXIgY2FuY2Vsc1wifCBkNF9YW29yZGVyX2NhbmNlbGxlZF1cbiAgICBkNF9CIC0uLT58XCJraXRjaGVuIGNhbmNlbHNcInwgZDRfWFxuICAgIGQ0X0QgLS4tPnxcImtpdGNoZW4gY2FuY2Vsc1wifCBkNF9YXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 4" width="1904" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each step takes a Gaussian amount of time, demand follows the day, and some brands are growing while others decline, so there are real trends for a pipeline to find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServiceTimes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Gaussian distribution parameters for order lifecycle phases (in minutes).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;order_to_kitchen_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kitchen_prep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kitchen_to_driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pickup_to_delivery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;driver_ping_interval_sec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Demand peaks at noon and again from six to eight in the evening, and Fridays and Saturdays run 25 to 35 percent busier than a weekday. And then, on purpose, it breaks things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChaosConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Data quality issue injection settings.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;null_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;            &lt;span class="c1"&gt;# 5% of fields nullified
&lt;/span&gt;    &lt;span class="n"&gt;late_event_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;      &lt;span class="c1"&gt;# 3% out-of-order events
&lt;/span&gt;    &lt;span class="n"&gt;duplicate_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;       &lt;span class="c1"&gt;# 2% duplicate events
&lt;/span&gt;    &lt;span class="n"&gt;malformed_json_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;  &lt;span class="c1"&gt;# 1% malformed body
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chaos&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Null fields&lt;/td&gt;
&lt;td&gt;schema enforcement, defaults, data quality checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late (out-of-order) events&lt;/td&gt;
&lt;td&gt;watermarks, event time vs processing time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate events&lt;/td&gt;
&lt;td&gt;idempotency, deduplication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malformed JSON&lt;/td&gt;
&lt;td&gt;parsing, dead-letter handling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same generator feeds both halves of the stack:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ1X0dFTltcIi4vbGFrZWhvdXNlIHRlc3RkYXRhPGJyLz4oZ2hvc3Qta2l0Y2hlbiBnZW5lcmF0b3IpXCJdOjo6bmV3XG4gIGQ1X0ZJTEVTWy9cIlBhcnF1ZXQgaGlzdG9yeTxici8-Li9kYXRhXCIvXTo6Om5ld1xuICBzdWJncmFwaCBkNV9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkNV9LW1wiS2Fma2EgOjkwOTJcIl06OjpvbGRcbiAgICBkNV9TNDBbXCJTcGFyayA0LjAuMVwiXTo6Om9sZFxuICBlbmRcbiAgZDVfSUNFWyhcIkljZWJlcmcgYnJvbnplIHRhYmxlc1wiKV06OjpvbGRcbiAgZDVfR0VOIC0tPnxcImdlbmVyYXRlXCJ8IGQ1X0ZJTEVTXG4gIGQ1X0ZJTEVTIC0tPnxcImxvYWRcInwgZDVfSUNFXG4gIGQ1X0dFTiAtLT58XCJzdHJlYW0gLS1zcGVlZFwifCBkNV9LXG4gIGQ1X0sgLS0-IGQ1X1M0MFxuICBkNV9TNDAgLS0-IGQ1X0lDRVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ1X0dFTltcIi4vbGFrZWhvdXNlIHRlc3RkYXRhPGJyLz4oZ2hvc3Qta2l0Y2hlbiBnZW5lcmF0b3IpXCJdOjo6bmV3XG4gIGQ1X0ZJTEVTWy9cIlBhcnF1ZXQgaGlzdG9yeTxici8-Li9kYXRhXCIvXTo6Om5ld1xuICBzdWJncmFwaCBkNV9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkNV9LW1wiS2Fma2EgOjkwOTJcIl06OjpvbGRcbiAgICBkNV9TNDBbXCJTcGFyayA0LjAuMVwiXTo6Om9sZFxuICBlbmRcbiAgZDVfSUNFWyhcIkljZWJlcmcgYnJvbnplIHRhYmxlc1wiKV06OjpvbGRcbiAgZDVfR0VOIC0tPnxcImdlbmVyYXRlXCJ8IGQ1X0ZJTEVTXG4gIGQ1X0ZJTEVTIC0tPnxcImxvYWRcInwgZDVfSUNFXG4gIGQ1X0dFTiAtLT58XCJzdHJlYW0gLS1zcGVlZFwifCBkNV9LXG4gIGQ1X0sgLS0-IGQ1X1M0MFxuICBkNV9TNDAgLS0-IGQ1X0lDRVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 5" width="1093" height="238"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./lakehouse testdata generate &lt;span class="nt"&gt;--days&lt;/span&gt; 7     &lt;span class="c"&gt;# history as Parquet&lt;/span&gt;
./lakehouse testdata load                  &lt;span class="c"&gt;# into Iceberg bronze tables&lt;/span&gt;
./lakehouse testdata stream &lt;span class="nt"&gt;--speed&lt;/span&gt; 60     &lt;span class="c"&gt;# replay into Kafka, an hour per minute&lt;/span&gt;
./lakehouse testdata stats
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The duplicates and late events are there because the interesting failures only show up when the data misbehaves&lt;/strong&gt;, and months later, when I tested AUTO CDC, cancellations and duplicate creation events were exactly the cases the feature existed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spark, twice
&lt;/h2&gt;

&lt;p&gt;The point of all of this was testing Spark versions, and that's the part I designed most deliberately. Instead of upgrading 4.0 to 4.1 in place, the stack runs them &lt;strong&gt;side by side&lt;/strong&gt;: each version gets its own compose file, its own container names, its own image (with the Java version the official image ships), and its own block of ports, so both clusters can be up at once and the same job can be submitted to either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQ2X0hPU1RbXCJob3N0XCJdXG4gICAgZDZfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkNl9TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkNl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkNl9LW1wiS2Fma2EgOjkwOTJcIl06OjpvbGRcbiAgICBkNl9TNDBbXCJTcGFyayA0LjAuMSBcdTAwYjcgSmF2YSAxNzxici8-bWFzdGVyIDo3MDc3IFx1MDBiNyBVSSA6ODA4MFwiXTo6Om9sZFxuICAgIGQ2X1M0MVtcIlNwYXJrIDQuMS4wIFx1MDBiNyBKYXZhIDIxPGJyLz5tYXN0ZXIgOjcwNzggXHUwMGI3IFVJIDo4MDgyPGJyLz53b3JrZXIgVUkgOjgwODNcIl06OjpuZXdcbiAgICBkNl9VQ1tcIlVuaXR5IENhdGFsb2cgOjgwODFcIl06OjpvbGRcbiAgZW5kXG4gIGQ2X0sgLS0-IGQ2X1M0MFxuICBkNl9LIC0tPiBkNl9TNDFcbiAgZDZfUzQwICYgZDZfUzQxIC0tPiBkNl9QR1xuICBkNl9TNDAgJiBkNl9TNDEgLS0-IGQ2X1NXXG4gIGQ2X1M0MCAmIGQ2X1M0MSAtLT4gZDZfVUNcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQ2X0hPU1RbXCJob3N0XCJdXG4gICAgZDZfUEdbKFwiUG9zdGdyZVNRTCA6NTQzMlwiKV06OjpvbGRcbiAgICBkNl9TV1soXCJTZWF3ZWVkRlMgOjgzMzNcIildOjo6b2xkXG4gIGVuZFxuICBzdWJncmFwaCBkNl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkNl9LW1wiS2Fma2EgOjkwOTJcIl06OjpvbGRcbiAgICBkNl9TNDBbXCJTcGFyayA0LjAuMSBcdTAwYjcgSmF2YSAxNzxici8-bWFzdGVyIDo3MDc3IFx1MDBiNyBVSSA6ODA4MFwiXTo6Om9sZFxuICAgIGQ2X1M0MVtcIlNwYXJrIDQuMS4wIFx1MDBiNyBKYXZhIDIxPGJyLz5tYXN0ZXIgOjcwNzggXHUwMGI3IFVJIDo4MDgyPGJyLz53b3JrZXIgVUkgOjgwODNcIl06OjpuZXdcbiAgICBkNl9VQ1tcIlVuaXR5IENhdGFsb2cgOjgwODFcIl06OjpvbGRcbiAgZW5kXG4gIGQ2X0sgLS0-IGQ2X1M0MFxuICBkNl9LIC0tPiBkNl9TNDFcbiAgZDZfUzQwICYgZDZfUzQxIC0tPiBkNl9QR1xuICBkNl9TNDAgJiBkNl9TNDEgLS0-IGQ2X1NXXG4gIGQ2X1M0MCAmIGQ2X1M0MSAtLT4gZDZfVUNcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 6" width="764" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Spark 4.0.1&lt;/th&gt;
&lt;th&gt;Spark 4.1.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compose file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker-compose-spark41.yml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache/spark:4.0.1-scala2.13-java17-…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache/spark:4.1.0-scala2.13-java21-…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Master&lt;/td&gt;
&lt;td&gt;7077&lt;/td&gt;
&lt;td&gt;7078&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Master UI&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;8082&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./lakehouse start spark                  &lt;span class="c"&gt;# Spark 4.0.1&lt;/span&gt;
./lakehouse start spark &lt;span class="nt"&gt;--version&lt;/span&gt; 4.1    &lt;span class="c"&gt;# Spark 4.1.0, alongside it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turned out to be the organising idea for a lot of what followed. Spark Declarative Pipelines arrived in 4.1, so the workshop I built on the stack became a pair of notebooks: the same medallion pipeline written imperatively on 4.0 and declaratively on 4.1, running against the same Iceberg tables at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orchestration: Airflow
&lt;/h2&gt;

&lt;p&gt;Pipelines that only run when I type &lt;code&gt;spark-submit&lt;/code&gt; aren't really pipelines, so in January the stack got &lt;strong&gt;Apache Airflow&lt;/strong&gt;. The first version was Airflow 2.8, and within a week I upgraded it to 3.1, which was educational in the way major versions are: the webserver became an &lt;code&gt;api-server&lt;/code&gt;, &lt;code&gt;schedule_interval&lt;/code&gt; became &lt;code&gt;schedule&lt;/code&gt;, &lt;code&gt;BashOperator&lt;/code&gt; moved into a providers package, and the health endpoint moved.&lt;/p&gt;

&lt;p&gt;In the middle of that upgrade, the Java version in one image flipped from 17 to 21 and back again, and my reaction at the time was correct, if not patient: "why do we keep switching java versions? this is a fundamental change we can't keep expecting users to keep up with". The rule that came out of it is that &lt;strong&gt;Java versions are whatever the official images ship, and nobody changes them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The more useful correction was conceptual. The first architecture diagrams drew Airflow talking to Iceberg, and I asked the question that should have been asked earlier: "does airflow being linked to the iceberg api really make sense in the architecture diagrams? what is airflow really orchestrating in ths stack? just spark pipelines right?" It was. &lt;strong&gt;Airflow doesn't touch the data; it tells Spark when to&lt;/strong&gt;, and the diagram got simpler once it said so:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ3X0FGW1wiQWlyZmxvdyAzLjE8YnIvPmFwaS1zZXJ2ZXIgOjgwODU8YnIvPnNjaGVkdWxlclwiXTo6Om5ld1xuICBzdWJncmFwaCBkN19ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkN19TNDBbXCJTcGFyayA0LjAuMSA6NzA3N1wiXTo6Om9sZFxuICAgIGQ3X1M0MVtcIlNwYXJrIDQuMS4wIDo3MDc4XCJdOjo6b2xkXG4gIGVuZFxuICBkN19JQ0VbKFwiSWNlYmVyZyB0YWJsZXM8YnIvPihQb3N0Z3JlcyArIFNlYXdlZWRGUylcIildOjo6b2xkXG4gIGQ3X0FGIC0tPnxcImRvY2tlciBleGVjIFx1MjAyNiBzcGFyay1zdWJtaXRcInwgZDdfUzQwXG4gIGQ3X0FGIC0tPnxcImRvY2tlciBleGVjIFx1MjAyNiBzcGFyay1zdWJtaXQ8YnIvPm9yIHNwYXJrLXBpcGVsaW5lc1wifCBkN19TNDFcbiAgZDdfUzQwIC0tPiBkN19JQ0VcbiAgZDdfUzQxIC0tPiBkN19JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ3X0FGW1wiQWlyZmxvdyAzLjE8YnIvPmFwaS1zZXJ2ZXIgOjgwODU8YnIvPnNjaGVkdWxlclwiXTo6Om5ld1xuICBzdWJncmFwaCBkN19ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkN19TNDBbXCJTcGFyayA0LjAuMSA6NzA3N1wiXTo6Om9sZFxuICAgIGQ3X1M0MVtcIlNwYXJrIDQuMS4wIDo3MDc4XCJdOjo6b2xkXG4gIGVuZFxuICBkN19JQ0VbKFwiSWNlYmVyZyB0YWJsZXM8YnIvPihQb3N0Z3JlcyArIFNlYXdlZWRGUylcIildOjo6b2xkXG4gIGQ3X0FGIC0tPnxcImRvY2tlciBleGVjIFx1MjAyNiBzcGFyay1zdWJtaXRcInwgZDdfUzQwXG4gIGQ3X0FGIC0tPnxcImRvY2tlciBleGVjIFx1MjAyNiBzcGFyay1zdWJtaXQ8YnIvPm9yIHNwYXJrLXBpcGVsaW5lc1wifCBkN19TNDFcbiAgZDdfUzQwIC0tPiBkN19JQ0VcbiAgZDdfUzQxIC0tPiBkN19JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 7" width="922" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stack ships three DAGs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DAG&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lakehouse_medallion_pipeline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;checks Kafka is reachable, &lt;em&gt;branches on which Spark version to use&lt;/em&gt;, runs the medallion job on 4.0 or 4.1, then verifies the tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sdp_pipeline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;preflight, runs a declarative pipeline from its &lt;code&gt;spark-pipeline.yml&lt;/code&gt; spec, verifies, then maintains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceberg_maintenance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the chores nobody remembers: &lt;code&gt;rewrite_data_files&lt;/code&gt; to compact small files, &lt;code&gt;expire_snapshots&lt;/code&gt;, &lt;code&gt;remove_orphan_files&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The branch in the first DAG is my favourite detail in the repo, because the version-testing idea reaches all the way into orchestration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;choose_spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BranchPythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choose_spark_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;choose_spark_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run_pipeline_spark41&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BashOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_pipeline_spark41&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bash_command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        docker exec spark-master-41 /opt/spark/bin/spark-submit &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;            /scripts/pipelines/pipeline_spark41.py
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;check_kafka&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;choose_spark&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;run_pipeline_spark41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_pipeline_spark40&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;run_pipeline_spark41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_pipeline_spark40&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;verify_tables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running declarative pipelines from Airflow was clumsier at first, because there was no operator for them; that changed in March, and I'll come back to it once SDP itself has been introduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  One CLI, built for agents
&lt;/h2&gt;

&lt;p&gt;By now there were two Spark clusters, a streaming stack, two catalogs, an orchestrator and a pile of native services, and remembering which compose file, which ports and which container names went with which version had become a job in itself. So everything went behind a single entry point, &lt;code&gt;./lakehouse&lt;/code&gt;, a bash CLI that wraps Docker Compose and hides the bookkeeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./lakehouse setup                        &lt;span class="c"&gt;# prerequisites, Postgres catalog, JARs&lt;/span&gt;
./lakehouse preflight                    &lt;span class="c"&gt;# do .env, Spark config and the catalog agree?&lt;/span&gt;
./lakehouse start all &lt;span class="nt"&gt;--version&lt;/span&gt; 4.1      &lt;span class="c"&gt;# Spark + Kafka, then wait until they answer&lt;/span&gt;
./lakehouse start airflow                &lt;span class="c"&gt;# optional services, one at a time&lt;/span&gt;
./lakehouse status &lt;span class="nt"&gt;--json&lt;/span&gt;                &lt;span class="c"&gt;# machine-readable, for scripts and agents&lt;/span&gt;
./lakehouse logs spark-master
./lakehouse stop all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with no arguments and it explains itself, including one line that would matter later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./lakehouse help
Lakehouse Management CLI

Usage: lakehouse &amp;lt;command&amp;gt; [options] [--version 4.0|4.1]

Commands:
  setup               Validate environment and install dependencies
  check-config        Validate credentials between .env and spark-defaults.conf
  preflight           Run preflight checks before starting services
  migrate [--dry-run] Run database schema migrations
  status              Show all services status
  start [service]     Start service (spark|kafka|airflow|unity-catalog|all)
  ...
  testdata &amp;lt;cmd&amp;gt;      Test data generation (generate|stream|load|stats|clean)
  browsedata [table]  Browse Iceberg table rows (default: iceberg.bronze.orders)

Services:
  ...
  all            Spark + Kafka (not Unity Catalog or Airflow)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;all&lt;/code&gt; means the core services, which I discovered the hard way on the morning of a recording, after a reboot, when &lt;code&gt;./lakehouse start all&lt;/code&gt; brought back Spark and Kafka and not the catalog or MLflow I was about to demo.&lt;/p&gt;

&lt;p&gt;The deeper reason for one CLI was that I wanted the stack to be &lt;strong&gt;agent-first&lt;/strong&gt;. Early on I wrote down the goal: "I want people to feed an agent the repo, and be able to get up and running locally or on a cloud provider like AWS with minimal hitches." At the time, coding agents were far more reliable driving a well-behaved command line than reconstructing a sequence of &lt;code&gt;docker compose&lt;/code&gt; invocations from a README, so the CLI was designed for them as much as for people. Commands fail with real exit codes and say what to do next, and &lt;code&gt;status --json&lt;/code&gt; exists so an agent can read the state of the stack instead of scraping coloured terminal output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ4X0FHRU5UW1wiY29kaW5nIGFnZW50PGJyLz4ob3IgYSBwZXJzb24pXCJdOjo6bmV3XG4gIHN1YmdyYXBoIGQ4X0RPQ1NbXCJhZ2VudCBkb2NzXCJdXG4gICAgZDhfQ01bXCJDTEFVREUubWQ8YnIvPnByb2plY3QgZ3VpZGU6IGNvbW1hbmRzLDxici8-cG9ydHMsIGNyaXRpY2FsIHZlcnNpb25zXCJdOjo6bmV3XG4gICAgZDhfQU1bXCJBR0VOVFMubWQ8YnIvPmFsd2F5cy1pbi1jb250ZXh0PGJyLz5TcGFyayA0LjEgcmVmZXJlbmNlXCJdOjo6bmV3XG4gICAgZDhfU0tbXCJsYWtlaG91c2Utc2tpbGxzPGJyLz5TRFAgXHUwMGI3IFB5U3BhcmsgXHUwMGI3IFN0cmVhbWluZyBcdTAwYjc8YnIvPkljZWJlcmcgXHUwMGI3IGNhdGFsb2dzXCJdOjo6bmV3XG4gIGVuZFxuICBkOF9DTElbXCIuL2xha2Vob3VzZSBDTEk8YnIvPnNldHVwIFx1MDBiNyBwcmVmbGlnaHQgXHUwMGI3IHN0YXJ0IFx1MDBiNyBzdGF0dXMgLS1qc29uIFx1MDBiNzxici8-dGVzdGRhdGEgXHUwMGI3IHRlc3QgXHUwMGI3IG1pZ3JhdGVcIl06OjpuZXdcbiAgZDhfU1RBQ0tbXCJTcGFyayBcdTAwZDcyIFx1MDBiNyBLYWZrYSBcdTAwYjcgQWlyZmxvdyBcdTAwYjc8YnIvPlVuaXR5IENhdGFsb2cgXHUwMGI3IFBvc3RncmVzIFx1MDBiNyBTZWF3ZWVkRlNcIl06OjpvbGRcbiAgZDhfQUdFTlQgLS0-IGQ4X0RPQ1NcbiAgZDhfQUdFTlQgLS0-IGQ4X0NMSVxuICBkOF9DTEkgLS0-fFwiZG9ja2VyIGNvbXBvc2UsIHBvcnQgY2hlY2tzLDxici8-cmVhZGluZXNzIHdhaXRzXCJ8IGQ4X1NUQUNLXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ4X0FHRU5UW1wiY29kaW5nIGFnZW50PGJyLz4ob3IgYSBwZXJzb24pXCJdOjo6bmV3XG4gIHN1YmdyYXBoIGQ4X0RPQ1NbXCJhZ2VudCBkb2NzXCJdXG4gICAgZDhfQ01bXCJDTEFVREUubWQ8YnIvPnByb2plY3QgZ3VpZGU6IGNvbW1hbmRzLDxici8-cG9ydHMsIGNyaXRpY2FsIHZlcnNpb25zXCJdOjo6bmV3XG4gICAgZDhfQU1bXCJBR0VOVFMubWQ8YnIvPmFsd2F5cy1pbi1jb250ZXh0PGJyLz5TcGFyayA0LjEgcmVmZXJlbmNlXCJdOjo6bmV3XG4gICAgZDhfU0tbXCJsYWtlaG91c2Utc2tpbGxzPGJyLz5TRFAgXHUwMGI3IFB5U3BhcmsgXHUwMGI3IFN0cmVhbWluZyBcdTAwYjc8YnIvPkljZWJlcmcgXHUwMGI3IGNhdGFsb2dzXCJdOjo6bmV3XG4gIGVuZFxuICBkOF9DTElbXCIuL2xha2Vob3VzZSBDTEk8YnIvPnNldHVwIFx1MDBiNyBwcmVmbGlnaHQgXHUwMGI3IHN0YXJ0IFx1MDBiNyBzdGF0dXMgLS1qc29uIFx1MDBiNzxici8-dGVzdGRhdGEgXHUwMGI3IHRlc3QgXHUwMGI3IG1pZ3JhdGVcIl06OjpuZXdcbiAgZDhfU1RBQ0tbXCJTcGFyayBcdTAwZDcyIFx1MDBiNyBLYWZrYSBcdTAwYjcgQWlyZmxvdyBcdTAwYjc8YnIvPlVuaXR5IENhdGFsb2cgXHUwMGI3IFBvc3RncmVzIFx1MDBiNyBTZWF3ZWVkRlNcIl06OjpvbGRcbiAgZDhfQUdFTlQgLS0-IGQ4X0RPQ1NcbiAgZDhfQUdFTlQgLS0-IGQ4X0NMSVxuICBkOF9DTEkgLS0-fFwiZG9ja2VyIGNvbXBvc2UsIHBvcnQgY2hlY2tzLDxici8-cmVhZGluZXNzIHdhaXRzXCJ8IGQ4X1NUQUNLXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 8" width="661" height="868"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some features came straight from me asking the obvious question, like "is there a cli test process in setup that checks if ports are available for the services?"; there wasn't, so now &lt;code&gt;start&lt;/code&gt; checks every port it's about to need and refuses to run if something else is holding one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check_port_available&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;expected_container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; nc &lt;span class="nt"&gt;-z&lt;/span&gt; localhost &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        return &lt;/span&gt;0                      &lt;span class="c"&gt;# port is free&lt;/span&gt;
    &lt;span class="k"&gt;fi&lt;/span&gt;
    &lt;span class="c"&gt;# port is in use: fine only if it's the container we expect&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expected_container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expected_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        return &lt;/span&gt;0
    &lt;span class="k"&gt;fi
    return &lt;/span&gt;1                          &lt;span class="c"&gt;# blocked by something else&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documentation is split the same way. &lt;code&gt;CLAUDE.md&lt;/code&gt; works as an agent's guide to the project. &lt;code&gt;AGENTS.md&lt;/code&gt; describes itself as a "compressed reference for AI agents. Always in context - no skill invocation required": Spark 4.1's breaking changes, standard imports, API quick references, the things an agent gets wrong when it's working from older training data. Deeper, task-specific knowledge went into &lt;strong&gt;skills&lt;/strong&gt;, which grew into their own repo, &lt;a href="https://github.com/lisancao/lakehouse-skills" rel="noopener noreferrer"&gt;lakehouse-skills&lt;/a&gt;. That started because the agent kept stumbling over SDP's API, and I asked it, fairly pointedly, to write down what it had learned ("you had some struggles using the official API. can you create an SDP.md skills file"). Then I tested the skill the only way that means anything: a fresh agent session authored pipelines against the stack using nothing but that file, and wherever its output was wrong, the skill got fixed.&lt;/p&gt;

&lt;p&gt;There's one design choice in the CLI I'd make differently now. Its definition of a healthy service is the one almost every homelab script settles on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;is_service_healthy&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
    nc &lt;span class="nt"&gt;-z&lt;/span&gt; localhost &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service is up if &lt;em&gt;something&lt;/em&gt; answers on its port. Here's what &lt;code&gt;status&lt;/code&gt; says on my machine as I write this, with only some of the stack running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./lakehouse status
================================
LAKEHOUSE STATUS
================================

System Services:
✓ PostgreSQL (port 5432)
✓ SeaweedFS (port 8333)

Unity Catalog (optional):
✓ unity-catalog
✓ Unity Catalog REST API (port 8081)

Spark 4.1 Containers:
✗ spark-master-41
✗ spark-worker-41

Airflow Containers:
✓ airflow-webserver
✗ Airflow UI (port 8085)
✓ airflow-scheduler

Ports:
  Unity Catalog:    8081 (REST API + Iceberg endpoint)
  Spark 4.0:        7077, 8080 (UI), 8081 (Worker UI)
  Spark 4.1:        7078, 8082 (UI), 8083 (Worker UI)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there are the whole post in miniature. The port table lists &lt;strong&gt;8081 twice&lt;/strong&gt;: Unity Catalog moved there to get out of the Spark UI's way, and Spark 4.0's worker UI has been sitting on 8081 by default the entire time. And Airflow's container is up while its UI isn't answering, which the human-readable status reports honestly and the JSON version doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"airflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"webserver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scheduler"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"all_healthy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A port check tells you something is listening, not that it's the thing you think it is, at the version you think it is&lt;/strong&gt;, and a container check tells you something is running, not that it's serving. On a machine where every service shares one set of ports, that distinction eventually matters. It mattered in June, when a Spark "bug" I'd had written up with a minimal reproduction turned out to be an old Spark Connect server that had been answering on the port the whole time. That one gets its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credentials, everywhere
&lt;/h2&gt;

&lt;p&gt;A lakehouse at home has fewer secrets than a real one, but it has the same &lt;em&gt;shape&lt;/em&gt; of secrets, and they multiply faster than you'd expect. The Iceberg catalog needs a Postgres username and password. Spark needs S3 keys for SeaweedFS. Unity Catalog needs its own copy of the storage credentials so it can hand them out. Airflow has an admin login, MLflow has a database, and every one of those values lives in more than one place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ5X0VOVltcIi5lbnY8YnIvPihnaXRpZ25vcmVkKVwiXTo6Om5ld1xuICBkOV9TRFtcInNwYXJrLWRlZmF1bHRzLmNvbmY8YnIvPihnaXRpZ25vcmVkLCBmcm9tIC5leGFtcGxlKVwiXTo6Om5ld1xuICBkOV9VQ1BbXCJ1bml0eS1jYXRhbG9nL3NlcnZlci5wcm9wZXJ0aWVzPGJyLz4oZ2l0aWdub3JlZClcIl06OjpuZXdcbiAgc3ViZ3JhcGggZDlfTU9VTlRbXCJtb3VudGVkIGludG9cIl1cbiAgICBkOV9TTVtcIlNwYXJrIG1hc3RlclwiXTo6Om9sZFxuICAgIGQ5X1NXS1tcImV2ZXJ5IFNwYXJrIHdvcmtlclwiXTo6Om9sZFxuICBlbmRcbiAgZDlfUEdbKFwiUG9zdGdyZXM8YnIvPmNhdGFsb2cgbG9naW5cIildOjo6b2xkXG4gIGQ5X1NXWyhcIlNlYXdlZWRGUzxici8-UzMga2V5c1wiKV06OjpvbGRcbiAgZDlfVUNbXCJVbml0eSBDYXRhbG9nPGJyLz4odmVuZHMgUzMgY3JlZGVudGlhbHMpXCJdOjo6b2xkXG4gIGQ5X0VOViAtLT58XCJDTEksIEFpcmZsb3csIHNjcmlwdHNcInwgZDlfUEdcbiAgZDlfU0QgLS0-IGQ5X1NNICYgZDlfU1dLXG4gIGQ5X1NNICYgZDlfU1dLIC0tPiBkOV9QRyAmIGQ5X1NXXG4gIGQ5X1VDUCAtLT4gZDlfVUMgLS0-IGQ5X1NXXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQ5X0VOVltcIi5lbnY8YnIvPihnaXRpZ25vcmVkKVwiXTo6Om5ld1xuICBkOV9TRFtcInNwYXJrLWRlZmF1bHRzLmNvbmY8YnIvPihnaXRpZ25vcmVkLCBmcm9tIC5leGFtcGxlKVwiXTo6Om5ld1xuICBkOV9VQ1BbXCJ1bml0eS1jYXRhbG9nL3NlcnZlci5wcm9wZXJ0aWVzPGJyLz4oZ2l0aWdub3JlZClcIl06OjpuZXdcbiAgc3ViZ3JhcGggZDlfTU9VTlRbXCJtb3VudGVkIGludG9cIl1cbiAgICBkOV9TTVtcIlNwYXJrIG1hc3RlclwiXTo6Om9sZFxuICAgIGQ5X1NXS1tcImV2ZXJ5IFNwYXJrIHdvcmtlclwiXTo6Om9sZFxuICBlbmRcbiAgZDlfUEdbKFwiUG9zdGdyZXM8YnIvPmNhdGFsb2cgbG9naW5cIildOjo6b2xkXG4gIGQ5X1NXWyhcIlNlYXdlZWRGUzxici8-UzMga2V5c1wiKV06OjpvbGRcbiAgZDlfVUNbXCJVbml0eSBDYXRhbG9nPGJyLz4odmVuZHMgUzMgY3JlZGVudGlhbHMpXCJdOjo6b2xkXG4gIGQ5X0VOViAtLT58XCJDTEksIEFpcmZsb3csIHNjcmlwdHNcInwgZDlfUEdcbiAgZDlfU0QgLS0-IGQ5X1NNICYgZDlfU1dLXG4gIGQ5X1NNICYgZDlfU1dLIC0tPiBkOV9QRyAmIGQ5X1NXXG4gIGQ5X1VDUCAtLT4gZDlfVUMgLS0-IGQ5X1NXXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 9" width="885" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern the repo settled on is boring on purpose. Real values live in files that are never committed (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;spark-defaults.conf&lt;/code&gt;, the Unity Catalog &lt;code&gt;server.properties&lt;/code&gt;), each with a checked-in &lt;code&gt;.example&lt;/code&gt; template full of obvious placeholders like &lt;code&gt;your_access_key&lt;/code&gt;, and &lt;code&gt;.gitignore&lt;/code&gt; opens with a section literally titled &lt;em&gt;Credentials and Secrets&lt;/em&gt;. The part that took longer to learn was that &lt;strong&gt;the same secret stored in two files will eventually disagree&lt;/strong&gt;, and the error you get when it does never says so; Spark reports an S3 failure, or Iceberg reports a missing catalog. So the CLI grew a command that checks the copies against each other, and against the placeholders nobody replaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./lakehouse check-config
================================
CONFIGURATION CHECK
================================

1. Checking .env file...
   ✓ .env configuration valid

2. Checking spark-defaults.conf...
   ✓ Credentials consistent

3. Testing database connection...
   ✓ PostgreSQL connection successful

4. Testing S3 endpoint...
   ✓ S3 endpoint responding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's also where I found my own worst habit in the codebase. An early version of the CLI connected to the catalog as a hardcoded Postgres user, and the user was &lt;em&gt;me&lt;/em&gt;: my own username, baked into a script I was about to tell strangers to run. Reading credentials from &lt;code&gt;.env&lt;/code&gt; instead of hardcoding them was one of the first fixes anyone would need to run the stack at all, and &lt;code&gt;detect-secrets&lt;/code&gt; now sits in the pre-commit chain so the next version of that mistake doesn't make it into a commit.&lt;/p&gt;

&lt;p&gt;The catalogs raise the stakes, because a catalog's job is partly to &lt;em&gt;hold&lt;/em&gt; credentials so that engines don't have to. Unity Catalog can vend short-lived storage credentials to whoever asks for a table, which is the whole point of running it, but it needs a complete set of storage credentials to vend; in one setup it quietly skipped configuring a bucket until every S3 field was filled in, including a placeholder session token that a local SeaweedFS has no use for. And the classic Spark layout has the opposite problem: because every node that runs a driver needs the catalog, &lt;strong&gt;the catalog password and the S3 keys end up mounted into the master and every worker.&lt;/strong&gt; That, more than anything, is why Spark Connect ended up in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it, and CI
&lt;/h2&gt;

&lt;p&gt;A stack whose whole purpose is catching breakage needs to catch its own. The repo's tests come in two layers: fast unit tests (project structure, DAG definitions, pipeline transformations, the CLI's security guarantees) and integration tests that run against real services, one file per component: Iceberg CRUD, SeaweedFS, Kafka streaming, Unity Catalog, Airflow, the declarative pipelines across file and table formats, and a &lt;code&gt;test_fresh_install&lt;/code&gt; that checks the thing I kept promising people, that it works from a clean clone. The integration tests read like a lakehouse tutorial, which is roughly the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.integration&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestIcebergCRUD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_create_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spark_with_postgres&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark_with_postgres&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE NAMESPACE IF NOT EXISTS iceberg.test_crud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            CREATE TABLE IF NOT EXISTS iceberg.test_crud.orders (
                order_id STRING, customer_id STRING,
                amount DOUBLE, created_at TIMESTAMP
            ) USING iceberg
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SHOW TABLES IN iceberg.test_crud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tableName&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI runs on &lt;strong&gt;GitHub Actions&lt;/strong&gt;, as three workflows that hand off to each other (with a copy of the CI workflow on a self-hosted Gitea, so the same checks can run on my own hardware):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBnaCBmaWxsOiMyZjVmYjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMyZjVmYjMsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBldiBmaWxsOiM0YjU1NjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM0YjU1NjMsY29sb3I6I2ZmZmZmZlxuICBkMTBfRkJbXCJwdXNoIHRvIGZlYXR1cmUgYnJhbmNoXCJdOjo6ZXYgLS0-IGQxMF9QUltcInB1bGwgcmVxdWVzdCBcdTIxOTIgZGV2ZWxvcFwiXTo6OmV2XG4gIGQxMF9QUiAtLT4gZDEwX0NJMVtcImNpLnltbDxici8-KEdpdEh1YiBBY3Rpb25zKVwiXTo6OmdoXG4gIGQxMF9DSTEgLS0-fFwiZ3JlZW5cInwgZDEwX0RFVltcIm1lcmdlIHRvIGRldmVsb3BcIl06OjpldlxuICBkMTBfREVWIC0tPiBkMTBfUFIyW1wicHVsbCByZXF1ZXN0IGRldmVsb3AgXHUyMTkyIG1hc3RlclwiXTo6OmV2XG4gIGQxMF9QUjIgLS0-IGQxMF9DSTJbXCJjaS55bWwgcnVucyBhZ2FpblwiXTo6OmdoXG4gIGQxMF9DSTIgLS0-fFwiZ3JlZW5cInwgZDEwX01BU1RFUltcIm1lcmdlIHRvIG1hc3RlclwiXTo6OmV2XG4gIGQxMF9NQVNURVIgLS0-IGQxMF9SUFtcInJlbGVhc2UtcGxlYXNlLnltbDxici8-b3BlbnMgb3IgdXBkYXRlcyBhIHJlbGVhc2UgUFI8YnIvPmZyb20gY29udmVudGlvbmFsIGNvbW1pdHNcIl06OjpnaFxuICBkMTBfUlAgLS0-fFwicmVsZWFzZSBQUiBtZXJnZWQsPGJyLz50YWcgdiogcHVzaGVkXCJ8IGQxMF9SRUxbXCJyZWxlYXNlLnltbDxici8-Z2VuZXJhdGVzIG5vdGVzLDxici8-Y3JlYXRlcyB0aGUgR2l0SHViIFJlbGVhc2VcIl06OjpnaFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBnaCBmaWxsOiMyZjVmYjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMyZjVmYjMsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBldiBmaWxsOiM0YjU1NjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM0YjU1NjMsY29sb3I6I2ZmZmZmZlxuICBkMTBfRkJbXCJwdXNoIHRvIGZlYXR1cmUgYnJhbmNoXCJdOjo6ZXYgLS0-IGQxMF9QUltcInB1bGwgcmVxdWVzdCBcdTIxOTIgZGV2ZWxvcFwiXTo6OmV2XG4gIGQxMF9QUiAtLT4gZDEwX0NJMVtcImNpLnltbDxici8-KEdpdEh1YiBBY3Rpb25zKVwiXTo6OmdoXG4gIGQxMF9DSTEgLS0-fFwiZ3JlZW5cInwgZDEwX0RFVltcIm1lcmdlIHRvIGRldmVsb3BcIl06OjpldlxuICBkMTBfREVWIC0tPiBkMTBfUFIyW1wicHVsbCByZXF1ZXN0IGRldmVsb3AgXHUyMTkyIG1hc3RlclwiXTo6OmV2XG4gIGQxMF9QUjIgLS0-IGQxMF9DSTJbXCJjaS55bWwgcnVucyBhZ2FpblwiXTo6OmdoXG4gIGQxMF9DSTIgLS0-fFwiZ3JlZW5cInwgZDEwX01BU1RFUltcIm1lcmdlIHRvIG1hc3RlclwiXTo6OmV2XG4gIGQxMF9NQVNURVIgLS0-IGQxMF9SUFtcInJlbGVhc2UtcGxlYXNlLnltbDxici8-b3BlbnMgb3IgdXBkYXRlcyBhIHJlbGVhc2UgUFI8YnIvPmZyb20gY29udmVudGlvbmFsIGNvbW1pdHNcIl06OjpnaFxuICBkMTBfUlAgLS0-fFwicmVsZWFzZSBQUiBtZXJnZWQsPGJyLz50YWcgdiogcHVzaGVkXCJ8IGQxMF9SRUxbXCJyZWxlYXNlLnltbDxici8-Z2VuZXJhdGVzIG5vdGVzLDxici8-Y3JlYXRlcyB0aGUgR2l0SHViIFJlbGVhc2VcIl06OjpnaFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 10" width="276" height="1166"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;ci.yml&lt;/code&gt;, the jobs run in stages that each gate the next:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgZDExX0xbXCJsaW50ICYgdmFsaWRhdGU8YnIvPnJ1ZmYgXHUwMGI3IGJsYWNrIFx1MDBiNyBzaGVsbGNoZWNrIFx1MDBiNzxici8-Y29tcG9zZSBmaWxlc1wiXTo6Om5ldyAtLT4gZDExX1VbXCJ1bml0IHRlc3RzPGJyLz4rIGNvdmVyYWdlXCJdOjo6bmV3XG4gIGQxMV9VIC0tPiBkMTFfQ1tcImNvbnRhaW5lciBzdGFydHVwPGJyLz5wdWxsIFNwYXJrIDQuMCArIDQuMSBpbWFnZXMsPGJyLz5zdGFydCBLYWZrYVwiXTo6Om5ld1xuICBkMTFfVSAtLT4gZDExX0lcbiAgZDExX0MgLS0-IGQxMV9JW1wiaW50ZWdyYXRpb24gdGVzdHM8YnIvPlBvc3RncmVzIHNlcnZpY2UgY29udGFpbmVyLDxici8-ZnJlc2ggaW5zdGFsbCwgS2Fma2FcIl06OjpuZXdcbiAgZDExX0kgLS0-IGQxMV9NW1wiU3BhcmsgbWF0cml4PGJyLz40LjAgXHUwMGI3IDQuMVwiXTo6Om5ld1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgZDExX0xbXCJsaW50ICYgdmFsaWRhdGU8YnIvPnJ1ZmYgXHUwMGI3IGJsYWNrIFx1MDBiNyBzaGVsbGNoZWNrIFx1MDBiNzxici8-Y29tcG9zZSBmaWxlc1wiXTo6Om5ldyAtLT4gZDExX1VbXCJ1bml0IHRlc3RzPGJyLz4rIGNvdmVyYWdlXCJdOjo6bmV3XG4gIGQxMV9VIC0tPiBkMTFfQ1tcImNvbnRhaW5lciBzdGFydHVwPGJyLz5wdWxsIFNwYXJrIDQuMCArIDQuMSBpbWFnZXMsPGJyLz5zdGFydCBLYWZrYVwiXTo6Om5ld1xuICBkMTFfVSAtLT4gZDExX0lcbiAgZDExX0MgLS0-IGQxMV9JW1wiaW50ZWdyYXRpb24gdGVzdHM8YnIvPlBvc3RncmVzIHNlcnZpY2UgY29udGFpbmVyLDxici8-ZnJlc2ggaW5zdGFsbCwgS2Fma2FcIl06OjpuZXdcbiAgZDExX0kgLS0-IGQxMV9NW1wiU3BhcmsgbWF0cml4PGJyLz40LjAgXHUwMGI3IDQuMVwiXTo6Om5ld1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 11" width="1247" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The last stage is the version-testing idea again, this time for the stack itself: the same checks run against both Spark images.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spark-matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Spark ${{ matrix.spark-version }} Tests&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;4.0'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;4.1'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;spark-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;4.0'&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/spark:4.0.1-scala2.13-java17-python3-r-ubuntu&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;spark-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;4.1'&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/spark:4.1.0-scala2.13-java21-python3-r-ubuntu&lt;/span&gt;
          &lt;span class="na"&gt;compose-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose-spark41.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around the tests sits the less glamorous machinery that makes a repo safe to hand to strangers (and their agents). Work flows from feature branches into &lt;code&gt;develop&lt;/code&gt;, where CI runs on every pull request, and only then into &lt;code&gt;master&lt;/code&gt;; releases are cut by release-please from conventional commit messages. The CI workflow pins its actions to commit SHAs rather than tags (going back through it for this post, I noticed two exceptions I should fix: the Codecov upload in CI, and the release workflows, which still use tags like &lt;code&gt;@v4&lt;/code&gt;), the workflow's default permissions are read-only, and a pre-commit hook chain runs &lt;code&gt;detect-secrets&lt;/code&gt;, &lt;code&gt;bandit&lt;/code&gt;, &lt;code&gt;shellcheck&lt;/code&gt;, &lt;code&gt;hadolint&lt;/code&gt;, &lt;code&gt;ruff&lt;/code&gt; and &lt;code&gt;black&lt;/code&gt; before anything reaches a branch. That hardening came out of the same January week where I admitted I was scared I was going too fast; the test suite's own &lt;code&gt;test_security.py&lt;/code&gt; still guards the fixes it produced, like removing an &lt;code&gt;eval&lt;/code&gt; from the CLI and closing a SQL-injection path in &lt;code&gt;lakehouse migrate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;CI also taught me one thing about the outside world. The Airflow image downloads a Spark release tarball, and once Spark 4.1.1 superseded 4.1.0, the old tarball moved from Apache's fast mirrors to the slow archive server, and the build started timing out. Nothing in my repo had changed; the internet had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declarative pipelines and real time
&lt;/h2&gt;

&lt;p&gt;The last layer is the reason I needed all the others: the new Spark features I actually demo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQxMl9OQltcIkp1cHl0ZXJMYWIgOjg4ODk8YnIvPklERSBcdTAwYjcgb3RoZXIgbWFjaGluZXNcIl06OjpuZXdcbiAgZDEyX1NQRUNbXCJzcGFyay1waXBlbGluZS55bWw8YnIvPisgcGlwZWxpbmVfc2RwLnB5XCJdOjo6bmV3XG4gIHN1YmdyYXBoIGQxMl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkMTJfU0NbXCJTcGFyayBDb25uZWN0IHNlcnZlcjxici8-OjE1MDAyXCJdOjo6bmV3XG4gICAgZDEyX1M0MVtcIlNwYXJrIDQuMS4wIDo3MDc4XCJdOjo6b2xkXG4gICAgZDEyX1M1MFtcIlNwYXJrIDUuMCBzbmFwc2hvdDxici8-KHNvdXJjZSBidWlsZCkgOjcwNzk8YnIvPkFVVE8gQ0RDXCJdOjo6bmV3XG4gICAgZDEyX0tbXCJLYWZrYSA6OTA5MlwiXTo6Om9sZFxuICBlbmRcbiAgZDEyX0lDRVsoXCJJY2ViZXJnIGJyb256ZSBcdTAwYjcgc2lsdmVyIFx1MDBiNyBnb2xkXCIpXTo6Om9sZFxuICBkMTJfTkIgLS0-fFwic2M6Ly9sb2NhbGhvc3Q6MTUwMDJcInwgZDEyX1NDXG4gIGQxMl9TUEVDIC0tPnxcInNwYXJrLXBpcGVsaW5lcyBydW5cInwgZDEyX1M0MVxuICBkMTJfU0MgLS0-IGQxMl9TNDFcbiAgZDEyX0sgLS0-fFwiUmVhbC1UaW1lIE1vZGVcInwgZDEyX1M0MVxuICBkMTJfUzQxIC0tPiBkMTJfSUNFXG4gIGQxMl9TNTAgLS0-IGQxMl9JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQxMl9OQltcIkp1cHl0ZXJMYWIgOjg4ODk8YnIvPklERSBcdTAwYjcgb3RoZXIgbWFjaGluZXNcIl06OjpuZXdcbiAgZDEyX1NQRUNbXCJzcGFyay1waXBlbGluZS55bWw8YnIvPisgcGlwZWxpbmVfc2RwLnB5XCJdOjo6bmV3XG4gIHN1YmdyYXBoIGQxMl9ET0NLRVJbXCJEb2NrZXJcIl1cbiAgICBkMTJfU0NbXCJTcGFyayBDb25uZWN0IHNlcnZlcjxici8-OjE1MDAyXCJdOjo6bmV3XG4gICAgZDEyX1M0MVtcIlNwYXJrIDQuMS4wIDo3MDc4XCJdOjo6b2xkXG4gICAgZDEyX1M1MFtcIlNwYXJrIDUuMCBzbmFwc2hvdDxici8-KHNvdXJjZSBidWlsZCkgOjcwNzk8YnIvPkFVVE8gQ0RDXCJdOjo6bmV3XG4gICAgZDEyX0tbXCJLYWZrYSA6OTA5MlwiXTo6Om9sZFxuICBlbmRcbiAgZDEyX0lDRVsoXCJJY2ViZXJnIGJyb256ZSBcdTAwYjcgc2lsdmVyIFx1MDBiNyBnb2xkXCIpXTo6Om9sZFxuICBkMTJfTkIgLS0-fFwic2M6Ly9sb2NhbGhvc3Q6MTUwMDJcInwgZDEyX1NDXG4gIGQxMl9TUEVDIC0tPnxcInNwYXJrLXBpcGVsaW5lcyBydW5cInwgZDEyX1M0MVxuICBkMTJfU0MgLS0-IGQxMl9TNDFcbiAgZDEyX0sgLS0-fFwiUmVhbC1UaW1lIE1vZGVcInwgZDEyX1M0MVxuICBkMTJfUzQxIC0tPiBkMTJfSUNFXG4gIGQxMl9TNTAgLS0-IGQxMl9JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 12" width="1333" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spark Declarative Pipelines&lt;/strong&gt; arrived in 4.1, which is exactly why two Sparks mattered. Instead of writing the order of operations yourself, you declare the tables you want and how each is derived, and SDP works out the graph. In this stack a small spec points at a Python file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lakehouse_pipeline&lt;/span&gt;
&lt;span class="na"&gt;libraries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline_sdp.py&lt;/span&gt;
&lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iceberg&lt;/span&gt;
&lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bronze&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the Python file is a set of decorated functions, from a streaming bronze table fed by Kafka up to gold aggregates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pipelines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;

&lt;span class="nd"&gt;@dp.table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze.orders_streaming&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;orders_streaming&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# the Kafka readStream + from_json from earlier
&lt;/span&gt;
&lt;span class="nd"&gt;@dp.materialized_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gold.hourly_metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hourly_metrics&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iceberg.silver.orders_enriched&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_order_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also turned into a benchmark: on the stack's own Iceberg, Postgres, SeaweedFS and Kafka, a million-row pipeline took 11.73s through &lt;code&gt;spark-pipelines&lt;/code&gt; against 7.75s as a raw &lt;code&gt;spark-submit&lt;/code&gt;, a fixed four seconds or so of orchestration that's noticeable on small jobs and irrelevant on large ones. &lt;strong&gt;SDP is a pipeline abstraction, not a performance tool&lt;/strong&gt;, and I'd rather know that than guess at it on stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduling SDP: &lt;code&gt;SparkPipelinesOperator&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Running a pipeline by hand is a demo; running it every night is a platform, which brings Airflow back in. My first SDP DAG did what everyone did in early 2026: it wrapped the &lt;code&gt;spark-pipelines&lt;/code&gt; CLI in a &lt;code&gt;SparkSubmitOperator&lt;/code&gt;, pretending the CLI was a Spark application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: SDP smuggled through spark-submit
&lt;/span&gt;&lt;span class="nc"&gt;SparkSubmitOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/spark-pipelines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;application_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/scripts/pipelines/spark-pipeline.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;conn_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark_default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It mostly worked, and when it didn't, it failed in confusing ways, because SDP pipelines don't really run through &lt;code&gt;spark-submit&lt;/code&gt;. The &lt;code&gt;spark-pipelines&lt;/code&gt; CLI dispatches through Spark Connect when &lt;code&gt;SPARK_REMOTE&lt;/code&gt; is set and through &lt;code&gt;spark-submit&lt;/code&gt; otherwise, and in Spark 4.1 its shell wrapper took the JVM path even in Connect mode: it tried to start a &lt;em&gt;second&lt;/em&gt; Spark Connect server on port 15002, next to the one already there, and rejected the &lt;code&gt;--master&lt;/code&gt; and &lt;code&gt;--deploy-mode&lt;/code&gt; flags the operator was passing. (Yes, 15002 again.)&lt;/p&gt;

&lt;p&gt;In late March a dedicated &lt;a href="https://github.com/apache/airflow/pull/61681" rel="noopener noreferrer"&gt;&lt;code&gt;SparkPipelinesOperator&lt;/code&gt;&lt;/a&gt; landed in Airflow's Spark provider, and it's genuinely much easier. You hand it the pipeline spec and a command, and it works out the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# After: an operator that knows what a pipeline is
&lt;/span&gt;&lt;span class="nc"&gt;SparkPipelinesOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pipeline_spec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/scripts/pipelines/spark-pipeline.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pipeline_command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# or "dry-run" to validate the graph first
&lt;/span&gt;    &lt;span class="n"&gt;conn_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark_default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxM19CRUZPUkVbXCJiZWZvcmVcIl1cbiAgICBkMTNfQTFbXCJBaXJmbG93IHRhc2tcIl06OjpvbGQgLS0-fFwic3Bhcmstc3VibWl0PGJyLz5hcHBsaWNhdGlvbj1zcGFyay1waXBlbGluZXNcInwgZDEzX1cxW1wic3BhcmstcGlwZWxpbmVzPGJyLz5zaGVsbCB3cmFwcGVyXCJdOjo6b2xkXG4gICAgZDEzX1cxIC0tPnxcIkpWTSBTcGFya1N1Ym1pdCBwYXRoXCJ8IGQxM19YMVtcInNlY29uZCBDb25uZWN0IHNlcnZlcjxici8-dHJpZXMgdG8gYmluZCA6MTUwMDIgXHUyNzE3XCJdOjo6b2xkXG4gICAgZDEzX1cxIC0uLT58XCJyZWplY3RzIC0tbWFzdGVyLDxici8-LS1kZXBsb3ktbW9kZVwifCBkMTNfQTFcbiAgZW5kXG4gIHN1YmdyYXBoIGQxM19BRlRFUltcImFmdGVyXCJdXG4gICAgZDEzX0EyW1wiQWlyZmxvdyB0YXNrPGJyLz5waXBlbGluZV9zcGVjICsgY29tbWFuZFwiXTo6Om5ldyAtLT58XCJweXRob24gLW08YnIvPnB5c3BhcmsucGlwZWxpbmVzLmNsaVwifCBkMTNfQzJbXCJTRFAgY2xpZW50XCJdOjo6bmV3XG4gICAgZDEzX0MyIC0tPnxcIlNQQVJLX1JFTU9URSBzZXQ6PGJyLz5zYzovL1x1MjAyNjoxNTAwMlwifCBkMTNfUzJbXCJleGlzdGluZyBDb25uZWN0IHNlcnZlclwiXTo6Om5ld1xuICAgIGQxM19DMiAtLT58XCJubyBTUEFSS19SRU1PVEU6PGJyLz5zcGFyay1zdWJtaXRcInwgZDEzX1MzW1wiU3BhcmsgY2x1c3RlclwiXTo6Om5ld1xuICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxM19CRUZPUkVbXCJiZWZvcmVcIl1cbiAgICBkMTNfQTFbXCJBaXJmbG93IHRhc2tcIl06OjpvbGQgLS0-fFwic3Bhcmstc3VibWl0PGJyLz5hcHBsaWNhdGlvbj1zcGFyay1waXBlbGluZXNcInwgZDEzX1cxW1wic3BhcmstcGlwZWxpbmVzPGJyLz5zaGVsbCB3cmFwcGVyXCJdOjo6b2xkXG4gICAgZDEzX1cxIC0tPnxcIkpWTSBTcGFya1N1Ym1pdCBwYXRoXCJ8IGQxM19YMVtcInNlY29uZCBDb25uZWN0IHNlcnZlcjxici8-dHJpZXMgdG8gYmluZCA6MTUwMDIgXHUyNzE3XCJdOjo6b2xkXG4gICAgZDEzX1cxIC0uLT58XCJyZWplY3RzIC0tbWFzdGVyLDxici8-LS1kZXBsb3ktbW9kZVwifCBkMTNfQTFcbiAgZW5kXG4gIHN1YmdyYXBoIGQxM19BRlRFUltcImFmdGVyXCJdXG4gICAgZDEzX0EyW1wiQWlyZmxvdyB0YXNrPGJyLz5waXBlbGluZV9zcGVjICsgY29tbWFuZFwiXTo6Om5ldyAtLT58XCJweXRob24gLW08YnIvPnB5c3BhcmsucGlwZWxpbmVzLmNsaVwifCBkMTNfQzJbXCJTRFAgY2xpZW50XCJdOjo6bmV3XG4gICAgZDEzX0MyIC0tPnxcIlNQQVJLX1JFTU9URSBzZXQ6PGJyLz5zYzovL1x1MjAyNjoxNTAwMlwifCBkMTNfUzJbXCJleGlzdGluZyBDb25uZWN0IHNlcnZlclwiXTo6Om5ld1xuICAgIGQxM19DMiAtLT58XCJubyBTUEFSS19SRU1PVEU6PGJyLz5zcGFyay1zdWJtaXRcInwgZDEzX1MzW1wiU3BhcmsgY2x1c3RlclwiXTo6Om5ld1xuICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 13" width="545" height="1080"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Under the hood it skips the shell wrapper and calls SDP's Python entry point directly, so it takes the right path in both modes. On top of that it adds the things you'd want from any Airflow operator. The spec, command, Spark conf and environment variables are all templated, so one DAG can run a pipeline per date partition. &lt;code&gt;pipeline_command&lt;/code&gt; is validated, so a typo fails at parse time instead of at 2 a.m. And its logs say which Spark it actually talked to, which, given everything else in this post, I appreciate more than I can say.&lt;/p&gt;

&lt;p&gt;That also made patterns cheap that used to be fiddly. The stack's demo DAGs include a &lt;code&gt;dry-run&lt;/code&gt; → &lt;code&gt;run&lt;/code&gt; chain, so a broken dependency graph is caught before any cluster time is spent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SparkPipelinesOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;validate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pipeline_spec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SPEC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pipeline_command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dry-run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SparkPipelinesOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="n"&gt;pipeline_spec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SPEC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pipeline_command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;alongside multi-pipeline chains, sensor-triggered runs that wait for data to land in Kafka, and per-pipeline resource tuning. The repo vendors a copy of the operator so its DAGs work regardless of which provider version is installed, with an import that prefers the upstream one when it's there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Converging streaming and batch
&lt;/h3&gt;

&lt;p&gt;The usual answer to "we need real time" is a second engine. Spark does the batch work, Flink does the low-latency streaming, and now there are two runtimes, two codebases, two sets of semantics for the same business logic, and two things to keep upgraded. For a stack on one desktop, whose whole purpose is testing &lt;em&gt;Spark&lt;/em&gt;, I wanted exactly one engine, and I wanted batch and streaming to be the same code wherever possible.&lt;/p&gt;

&lt;p&gt;Spark gets surprisingly close. In the declarative pipeline above, the Kafka-fed bronze table is a streaming table and the silver and gold layers are materialized views, in the same file, over the same Iceberg catalog, run by the same &lt;code&gt;spark-pipelines run&lt;/code&gt;. Outside of SDP, the same DataFrame logic can sit anywhere on the latency spectrum by changing one line, the trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;parsed_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;availableNow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# incremental batch: process what's there, then stop
&lt;/span&gt;  &lt;span class="c1"&gt;# .trigger(processingTime="1 second")   # micro-batch: latency measured in seconds
&lt;/span&gt;  &lt;span class="c1"&gt;# Real-Time Mode:                       # continuous: latency measured in milliseconds
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB0IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGQxNF9RW1wic2FtZSBEYXRhRnJhbWUgbG9naWM8YnIvPihwYXJzZSBcdTAwYjcgZmlsdGVyIFx1MDBiNyBlbnJpY2gpXCJdIC0tPiBkMTRfQVtcImF2YWlsYWJsZU5vdzxici8-aW5jcmVtZW50YWwgYmF0Y2g8YnIvPm1pbnV0ZXMgdG8gaG91cnNcIl06Ojp0XG4gIGQxNF9RIC0tPiBkMTRfQltcInByb2Nlc3NpbmdUaW1lPGJyLz5taWNyby1iYXRjaDxici8-c2Vjb25kc1wiXTo6OnRcbiAgZDE0X1EgLS0-IGQxNF9DW1wiUmVhbC1UaW1lIE1vZGU8YnIvPmNvbnRpbnVvdXM8YnIvPm1pbGxpc2Vjb25kc1wiXTo6OnRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB0IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGQxNF9RW1wic2FtZSBEYXRhRnJhbWUgbG9naWM8YnIvPihwYXJzZSBcdTAwYjcgZmlsdGVyIFx1MDBiNyBlbnJpY2gpXCJdIC0tPiBkMTRfQVtcImF2YWlsYWJsZU5vdzxici8-aW5jcmVtZW50YWwgYmF0Y2g8YnIvPm1pbnV0ZXMgdG8gaG91cnNcIl06Ojp0XG4gIGQxNF9RIC0tPiBkMTRfQltcInByb2Nlc3NpbmdUaW1lPGJyLz5taWNyby1iYXRjaDxici8-c2Vjb25kc1wiXTo6OnRcbiAgZDE0X1EgLS0-IGQxNF9DW1wiUmVhbC1UaW1lIE1vZGU8YnIvPmNvbnRpbnVvdXM8YnIvPm1pbGxpc2Vjb25kc1wiXTo6OnRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 14" width="473" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-Time Mode&lt;/strong&gt; is what makes the one-engine argument hold at the low-latency end. Instead of scheduling a new micro-batch every interval, it keeps long-running tasks processing records as they arrive and only checkpoints periodically. With the stack's Kafka producing 500 events a second across 50 keys, a stateless Kafka-to-Kafka pipeline on Spark 4.1, on this desktop, measured a median latency of 17 milliseconds, steady across six runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Percentile&lt;/th&gt;
&lt;th&gt;Latency (6 runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;16 to 18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;20 to 24 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;21 to 26 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the latency range people usually reach for a second engine to get. But in 4.1 there were real limits, and the stack found all of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;4.1 limitation&lt;/th&gt;
&lt;th&gt;What it meant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stateless only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the RTM operator allowlist permits projections, filters and broadcast joins, but not aggregations, shuffles or state stores; no running counts, no deduplication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No Python UDFs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Python UDF execution isn't on the allowlist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No PySpark keyword&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;trigger(realTime=...)&lt;/code&gt; didn't exist in PySpark 4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Console sink doesn't flush&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;use a Kafka sink to see output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;current_timestamp()&lt;/code&gt; is per checkpoint, not per row&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;latency has to be measured outside Spark, by a consumer comparing wall clocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Every stage runs at once&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a 16-partition Kafka source needs 16 task slots just to read; stateful plans need far more&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The PySpark gap was the most awkward for me, since everything else in the stack is Python. The quickstart got around it by reaching through Py4J into the JVM and setting Scala's trigger directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_real_time_trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checkpoint_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Apply RealTimeTrigger to a DataStreamWriter via the JVM gateway.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;jvm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jvm&lt;/span&gt;
    &lt;span class="n"&gt;jTrigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jvm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RealTimeTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;checkpoint_interval&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jwrite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jwrite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jTrigger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which works on a classic Spark session and is impossible over Spark Connect, where the client has no JVM to reach into; one more reason the Connect migration needed care.&lt;/p&gt;

&lt;p&gt;Spark 4.2 added the native PySpark trigger, and 4.3 adds stateful operators to Real-Time Mode. That upgrade gets a post of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Iceberg for a Spark that doesn't exist
&lt;/h2&gt;

&lt;p&gt;In May, the contributor who wrote AUTO CDC (SDP's new change-data-capture flow) asked me to put it through its paces before it went public. The feature had landed on Spark's &lt;code&gt;master&lt;/code&gt; branch, and that meant it existed in exactly one place. &lt;code&gt;master&lt;/code&gt; is where development for Spark's &lt;em&gt;next major version&lt;/em&gt; happens, so it builds as &lt;code&gt;5.0.0-SNAPSHOT&lt;/code&gt;: not a release, not even a preview, just whatever is on the branch that day. Before any Apache Spark release, the project cuts a &lt;strong&gt;release candidate&lt;/strong&gt; (an "RC"), a build the community downloads, tests and votes on; only a passing vote turns it into a release. AUTO CDC hadn't made it onto a release branch yet, so there wasn't even a release candidate to try. The stack grew a third Spark, and it had to be built from source first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/spark-src
./dev/make-distribution.sh &lt;span class="nt"&gt;--pip&lt;/span&gt; &lt;span class="nt"&gt;--tgz&lt;/span&gt; &lt;span class="nt"&gt;--connect&lt;/span&gt; &lt;span class="nt"&gt;-Phive&lt;/span&gt; &lt;span class="nt"&gt;-Phive-thriftserver&lt;/span&gt;   &lt;span class="c"&gt;# 30 to 45 minutes&lt;/span&gt;
./build/mvn &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-DskipTests&lt;/span&gt; &lt;span class="nt"&gt;-Phive&lt;/span&gt; &lt;span class="nt"&gt;-Phive-thriftserver&lt;/span&gt;                    &lt;span class="c"&gt;# publish to ~/.m2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the easy half. AUTO CDC writes by merging changes into its target row by row, so the target needs a table format that supports row-level operations; point it at plain Parquet and it refuses with &lt;code&gt;AUTOCDC_TARGET_DOES_NOT_SUPPORT_MERGE&lt;/code&gt;. I needed Iceberg. But Iceberg's Spark integration is built separately for each Spark minor version, and the newest one in Iceberg's repository was &lt;code&gt;spark/v4.1&lt;/code&gt;. The released 4.1 runtime didn't just lack support for Spark 5.0; &lt;strong&gt;it failed to load at all&lt;/strong&gt;, because Spark 5.0 had deleted an interface Iceberg compiles against. There was nothing to download, so I built one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQxNV9TUkNbXCJhcGFjaGUvc3BhcmsgbWFzdGVyPGJyLz41LjAuMC1TTkFQU0hPVFwiXTo6Om5ldyAtLT58XCJtdm4gaW5zdGFsbFwifCBkMTVfTTJbKFwifi8ubTI8YnIvPmxvY2FsIE1hdmVuIHJlcG9cIildOjo6bmV3XG4gIGQxNV9JQ0VbXCJhcGFjaGUvaWNlYmVyZyBtYWluPGJyLz5zcGFyay92NC4xIG1vZHVsZVwiXTo6Om9sZCAtLT58XCJwb3J0IHBhdGNoPGJyLz4oKzU0IC8gXHUyMjEyNTM0KVwifCBkMTVfUEFUQ0hFRFtcInNwYXJrL3Y0LjEgbW9kdWxlLDxici8-Y29tcGlsZWQgYWdhaW5zdCA1LjBcIl06OjpuZXdcbiAgZDE1X00yIC0tPiBkMTVfUEFUQ0hFRFxuICBkMTVfUEFUQ0hFRCAtLT58XCJncmFkbGUgc2hhZG93SmFyXCJ8IGQxNV9KQVJbXCJpY2ViZXJnLXNwYXJrLXJ1bnRpbWUtNC4xXzIuMTM8YnIvPih0aGUgbmFtZSBsaWVzOiBidWlsdCBmb3IgNS4wKVwiXTo6Om5ld1xuICBkMTVfSkFSIC0tPiBkMTVfUzUwW1wiU3BhcmsgNS4wIHNuYXBzaG90IGNsdXN0ZXI8YnIvPjo3MDc5IFx1MDBiNyBVSSA6ODA4NlwiXTo6Om5ld1xuICBkMTVfSEFEW1wiaGFkb29wLWF3cyAzLjUuMCBcdTAwYjc8YnIvPkFXUyBTREsgMi4zNS40IFx1MDBiNzxici8-YW5hbHl0aWNzYWNjZWxlcmF0b3ItczNcIl06OjpuZXcgLS0-IGQxNV9TNTBcbiAgZDE1X1M1MCAtLT4gZDE1X1NXWyhcIlNlYXdlZWRGU1wiKV06OjpvbGRcbiAgZDE1X1M1MCAtLT4gZDE1X1BHWyhcIlBvc3RncmVzIGNhdGFsb2dcIildOjo6b2xkXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGQxNV9TUkNbXCJhcGFjaGUvc3BhcmsgbWFzdGVyPGJyLz41LjAuMC1TTkFQU0hPVFwiXTo6Om5ldyAtLT58XCJtdm4gaW5zdGFsbFwifCBkMTVfTTJbKFwifi8ubTI8YnIvPmxvY2FsIE1hdmVuIHJlcG9cIildOjo6bmV3XG4gIGQxNV9JQ0VbXCJhcGFjaGUvaWNlYmVyZyBtYWluPGJyLz5zcGFyay92NC4xIG1vZHVsZVwiXTo6Om9sZCAtLT58XCJwb3J0IHBhdGNoPGJyLz4oKzU0IC8gXHUyMjEyNTM0KVwifCBkMTVfUEFUQ0hFRFtcInNwYXJrL3Y0LjEgbW9kdWxlLDxici8-Y29tcGlsZWQgYWdhaW5zdCA1LjBcIl06OjpuZXdcbiAgZDE1X00yIC0tPiBkMTVfUEFUQ0hFRFxuICBkMTVfUEFUQ0hFRCAtLT58XCJncmFkbGUgc2hhZG93SmFyXCJ8IGQxNV9KQVJbXCJpY2ViZXJnLXNwYXJrLXJ1bnRpbWUtNC4xXzIuMTM8YnIvPih0aGUgbmFtZSBsaWVzOiBidWlsdCBmb3IgNS4wKVwiXTo6Om5ld1xuICBkMTVfSkFSIC0tPiBkMTVfUzUwW1wiU3BhcmsgNS4wIHNuYXBzaG90IGNsdXN0ZXI8YnIvPjo3MDc5IFx1MDBiNyBVSSA6ODA4NlwiXTo6Om5ld1xuICBkMTVfSEFEW1wiaGFkb29wLWF3cyAzLjUuMCBcdTAwYjc8YnIvPkFXUyBTREsgMi4zNS40IFx1MDBiNzxici8-YW5hbHl0aWNzYWNjZWxlcmF0b3ItczNcIl06OjpuZXcgLS0-IGQxNV9TNTBcbiAgZDE1X1M1MCAtLT4gZDE1X1NXWyhcIlNlYXdlZWRGU1wiKV06OjpvbGRcbiAgZDE1X1M1MCAtLT4gZDE1X1BHWyhcIlBvc3RncmVzIGNhdGFsb2dcIildOjo6b2xkXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 15" width="635" height="878"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The port started by pointing Iceberg's build at the snapshot instead of the last release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; # gradle/libs.versions.toml
 spark40 = "4.0.2"
&lt;span class="gd"&gt;-spark41 = "4.1.1"
&lt;/span&gt;&lt;span class="gi"&gt;+spark41 = "5.0.0-SNAPSHOT"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then compiling, reading the errors, and fixing what Spark had changed underneath it. Most of the work fell into three kinds of API drift:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What changed on &lt;code&gt;master&lt;/code&gt; (the 5.0 snapshot)&lt;/th&gt;
&lt;th&gt;What broke in Iceberg&lt;/th&gt;
&lt;th&gt;What the port did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The DSv2 &lt;code&gt;View&lt;/code&gt; connector interface was removed (replaced by &lt;code&gt;ViewInfo&lt;/code&gt; and &lt;code&gt;TableViewCatalog&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Iceberg's view support, its catalogs' view methods, and its SQL extensions, which are built on views&lt;/td&gt;
&lt;td&gt;removed view support entirely: a &lt;strong&gt;tables-only&lt;/strong&gt; port, which is most of the 534 deleted lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New abstract methods: &lt;code&gt;Reducer.resultType()&lt;/code&gt;, and nanosecond timestamp getters on &lt;code&gt;SpecializedGetters&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Iceberg's partition functions and row readers no longer compiled&lt;/td&gt;
&lt;td&gt;implemented them (integer result types; nanosecond timestamps throw)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;StagedTable&lt;/code&gt; and &lt;code&gt;TruncatableTable&lt;/code&gt; both gained a &lt;em&gt;default&lt;/em&gt; &lt;code&gt;reportDriverMetrics()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Java refuses a class that inherits two unrelated defaults&lt;/td&gt;
&lt;td&gt;overrode it explicitly in Iceberg's staged tables&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one is my favourite, because nothing was wrong with either interface on its own; Iceberg's staged tables implement both, and the moment both grew a default method with the same name, Java couldn't choose between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CustomTaskMetric&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;reportDriverMetrics&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomTaskMetric&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the storage layer did to me exactly what it had done in December. Spark 5.0 moved to Hadoop 3.5.0, so the carefully pinned &lt;code&gt;s3a&lt;/code&gt; stack from the first week was wrong again, and the new combination needed &lt;code&gt;hadoop-aws-3.5.0&lt;/code&gt;, AWS SDK bundle 2.35.4, and a dependency that hadn't existed before, &lt;code&gt;analyticsaccelerator-s3&lt;/code&gt;. Even Kafka needed its JARs copied into the distribution's own &lt;code&gt;jars/&lt;/code&gt; directory before the new server would register it as a data source. &lt;strong&gt;Every new Spark reopens the dependency graph from the first week&lt;/strong&gt;, and the only question is whether you find out on your own machine or on stage.&lt;/p&gt;

&lt;p&gt;The runtime I ended up with kept the name &lt;code&gt;iceberg-spark-runtime-4.1_2.13&lt;/code&gt; (the module wiring was reused), which is the most misleading filename I own. It can't do Iceberg views or SQL procedures. It could do exactly one thing well, native row-level &lt;code&gt;MERGE&lt;/code&gt; into Iceberg tables, which is the one thing AUTO CDC needed, and with it I ran the feature against Kafka streams, Debezium change feeds and the ghost-kitchen orders, cancellations and all, weeks before it shipped. (I also asked whether I should port Delta the same way. The answer was no: AUTO CDC needs a table that implements Spark's row-level operations interface, and Delta does its merges through its own command instead, so a ported Delta still couldn't have been a target.) The findings are in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/auto-cdc-declarative-pipelines" rel="noopener noreferrer"&gt;AUTO CDC in Spark Declarative Pipelines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The coda is that this wasn't a one-off. When Spark 4.2 shipped for real in July, Maven Central's Iceberg runtimes still stopped at 4.1, and I built Iceberg locally again to run the stack on it. Testing a new Spark release, it turns out, very often means building a little of the ecosystem that hasn't caught up to it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why run Spark Connect at home at all
&lt;/h2&gt;

&lt;p&gt;Spark Connect splits Spark in two: a thin client that builds query plans and sends them over gRPC, and a server that owns the driver and actually runs them. On a single desktop that sounds like complexity for its own sake, since the client and the server are the same machine. I ran it anyway, because the classic layout had three problems that got worse the bigger the stack got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code had to live beside the driver.&lt;/strong&gt; A pipeline couldn't be submitted from my laptop or from CI; it had to be copied into the Spark master container first, which is why the CLI grew copy, run and delete wrappers around &lt;code&gt;spark-submit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials lived everywhere a driver could.&lt;/strong&gt; The catalog password and S3 keys were mounted into the master and every worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The application and the platform shared one environment.&lt;/strong&gt; Upgrading Spark changed the Python environment and JARs every job ran in, which is exactly backwards for a stack whose purpose is upgrading Spark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxNl9DTEFTU0lDW1wiY2xhc3NpY1wiXVxuICAgIGQxNl9MMVtcImxhcHRvcFwiXTo6Om9sZCAtLT58XCJkb2NrZXIgY3AgKyBkb2NrZXIgZXhlY1wifCBkMTZfTTFbXCJTcGFyayBtYXN0ZXI8YnIvPmRyaXZlciArIHlvdXIgY29kZTxici8-KyBjYXRhbG9nIGNyZWRzXCJdOjo6b2xkXG4gICAgZDE2X00xIC0tPiBkMTZfVzFbXCJ3b3JrZXJzPGJyLz4rIGNhdGFsb2cgY3JlZHNcIl06OjpvbGRcbiAgZW5kXG4gIHN1YmdyYXBoIGQxNl9DT05ORUNUW1wiU3BhcmsgQ29ubmVjdFwiXVxuICAgIGQxNl9MMltcImxhcHRvcCBcdTAwYjcgSURFIFx1MDBiNyBDSSBcdTAwYjcgbm90ZWJvb2s8YnIvPnRoaW4gY2xpZW50LCBubyBKVk0sPGJyLz5ubyBjcmVkZW50aWFsc1wiXTo6Om5ldyAtLT58XCJnUlBDIHBsYW48YnIvPnNjOi8vaG9zdDoxNTAwMlwifCBkMTZfTTJbXCJDb25uZWN0IHNlcnZlcjxici8-ZHJpdmVyICsgY2F0YWxvZyBjcmVkc1wiXTo6Om5ld1xuICAgIGQxNl9NMiAtLT4gZDE2X1cyW1wid29ya2Vyc1wiXTo6Om9sZFxuICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxNl9DTEFTU0lDW1wiY2xhc3NpY1wiXVxuICAgIGQxNl9MMVtcImxhcHRvcFwiXTo6Om9sZCAtLT58XCJkb2NrZXIgY3AgKyBkb2NrZXIgZXhlY1wifCBkMTZfTTFbXCJTcGFyayBtYXN0ZXI8YnIvPmRyaXZlciArIHlvdXIgY29kZTxici8-KyBjYXRhbG9nIGNyZWRzXCJdOjo6b2xkXG4gICAgZDE2X00xIC0tPiBkMTZfVzFbXCJ3b3JrZXJzPGJyLz4rIGNhdGFsb2cgY3JlZHNcIl06OjpvbGRcbiAgZW5kXG4gIHN1YmdyYXBoIGQxNl9DT05ORUNUW1wiU3BhcmsgQ29ubmVjdFwiXVxuICAgIGQxNl9MMltcImxhcHRvcCBcdTAwYjcgSURFIFx1MDBiNyBDSSBcdTAwYjcgbm90ZWJvb2s8YnIvPnRoaW4gY2xpZW50LCBubyBKVk0sPGJyLz5ubyBjcmVkZW50aWFsc1wiXTo6Om5ldyAtLT58XCJnUlBDIHBsYW48YnIvPnNjOi8vaG9zdDoxNTAwMlwifCBkMTZfTTJbXCJDb25uZWN0IHNlcnZlcjxici8-ZHJpdmVyICsgY2F0YWxvZyBjcmVkc1wiXTo6Om5ld1xuICAgIGQxNl9NMiAtLT4gZDE2X1cyW1wid29ya2Vyc1wiXTo6Om9sZFxuICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 16" width="338" height="1056"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;

&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://localhost:15002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iceberg.gold.hourly_metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was also a practical reason: SDP leans on Connect's machinery even when you never type &lt;code&gt;sc://&lt;/code&gt; yourself, so Spark's newest pipeline features were going to pull me toward it regardless.&lt;/p&gt;

&lt;p&gt;Running it locally was not free. Things that had quietly worked stopped working, and the failures were instructive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What fixed it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Driver-only APIs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark.sparkContext.setLogLevel("WARN")&lt;/code&gt; fails with &lt;code&gt;JVM_ATTRIBUTE_NOT_SUPPORTED&lt;/code&gt;; the client has no &lt;code&gt;sparkContext&lt;/code&gt;, &lt;code&gt;_jvm&lt;/code&gt; or &lt;code&gt;_jsc&lt;/code&gt;. An audit of one pipeline found 21 blocking calls, every one of them &lt;code&gt;setLogLevel&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;log levels belong to the server; set them there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config the client can't set&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark.jars&lt;/code&gt; and &lt;code&gt;spark.sql.extensions&lt;/code&gt; are fixed when the server starts, so a client can't add Iceberg's extensions (and without them there's no &lt;code&gt;MERGE INTO&lt;/code&gt; or &lt;code&gt;CALL iceberg.system.*&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;put the JARs and extensions on the server; check capabilities at client startup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A scary, harmless warning&lt;/td&gt;
&lt;td&gt;the server logs &lt;code&gt;ClassNotFoundException: …IcebergSparkSessionExtensions&lt;/code&gt; at startup, for its bootstrap session, before the JARs are loaded&lt;/td&gt;
&lt;td&gt;verify by &lt;em&gt;using&lt;/em&gt; &lt;code&gt;MERGE INTO&lt;/code&gt; and &lt;code&gt;CALL&lt;/code&gt;, not by reading the log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connectors not registering&lt;/td&gt;
&lt;td&gt;Kafka's JARs on &lt;code&gt;extraClassPath&lt;/code&gt; weren't enough for a standalone Connect server to find the data source&lt;/td&gt;
&lt;td&gt;copy them into the server's own &lt;code&gt;jars/&lt;/code&gt; directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkpoints&lt;/td&gt;
&lt;td&gt;streaming checkpoints over S3A against SeaweedFS failed&lt;/td&gt;
&lt;td&gt;keep checkpoints on &lt;code&gt;file://&lt;/code&gt; paths on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One port, many servers&lt;/td&gt;
&lt;td&gt;Connect's default port is 15002, and more than one Spark on the same machine wants it; Spark 4.1's shell wrapper could even start a second Connect server that fought the first&lt;/td&gt;
&lt;td&gt;one server per port, explicitly; and check &lt;em&gt;which&lt;/em&gt; server answered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When I finally moved the stack's own medallion pipeline to Connect, on Spark 4.2 with a locally built Iceberg runtime, every one of its ten Iceberg tables came out identical to the classic run, down to the content hashes (1,027,129 rows in &lt;code&gt;bronze.orders&lt;/code&gt; alone). The last row of that table is the one that bit hardest, and it's the story of the next post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experimental agents
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;OverArchitected&lt;/em&gt; episode had a premise I took more seriously than it deserved: &lt;em&gt;we're lazy; can AI manage the lakehouse for us?&lt;/em&gt; So the stack grew three experimental agents, each a small service built on MLflow, with every model call routed through MLflow's AI Gateway and every step recorded by MLflow Tracing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxN19BR0VOVFNbXCJhZ2VudHNcIl1cbiAgICBkMTdfR1tcIlBpcGVsaW5lIEd1YXJkaWFuPGJyLz4oY29tcGFjdGlvbiBhZ2VudClcIl06OjpuZXdcbiAgICBkMTdfQVtcIkFuYWx5c3Q8YnIvPihxdWVzdGlvbnMgXHUyMTkyIFNwYXJrIFNRTClcIl06OjpuZXdcbiAgICBkMTdfUFtcIkF1dG9waWxvdDxici8-KG1vbml0b3IgXHUyMTkyIGRpYWdub3NlIFx1MjE5MiBmaXgpXCJdOjo6bmV3XG4gIGVuZFxuICBkMTdfR1dbXCJNTGZsb3cgQUkgR2F0ZXdheSA6NTAwMVwiXTo6Om5ld1xuICBkMTdfVFJbXCJNTGZsb3cgdHJhY2tpbmcgKyB0cmFjaW5nIDo1MDAwXCJdOjo6bmV3XG4gIGQxN19PTFtcIk9sbGFtYSAobG9jYWwgR1BVKTxici8-Z3B0LW9zczoyMGIgXHUwMGI3IG5lbW90cm9uLWNhc2NhZGUtMiBcdTAwYjc8YnIvPnF3ZW4zLWNvZGVyOjMwYlwiXTo6Om5ld1xuICBkMTdfUzQxW1wiU3BhcmsgNC4xXCJdOjo6b2xkXG4gIGQxN19JQ0VbKFwiSWNlYmVyZyB0YWJsZXNcIildOjo6b2xkXG4gIGQxN19HICYgZDE3X0EgJiBkMTdfUCAtLT58XCJMTE0gY2FsbHNcInwgZDE3X0dXIC0tPiBkMTdfT0xcbiAgZDE3X0cgJiBkMTdfQSAmIGQxN19QIC0uLT58XCJ0cmFjZXNcInwgZDE3X1RSXG4gIGQxN19HICYgZDE3X0EgJiBkMTdfUCAtLT58XCJ0b29sczogU3BhcmsgU1FMLDxici8-SWNlYmVyZyBwcm9jZWR1cmVzXCJ8IGQxN19TNDEgLS0-IGQxN19JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGQxN19BR0VOVFNbXCJhZ2VudHNcIl1cbiAgICBkMTdfR1tcIlBpcGVsaW5lIEd1YXJkaWFuPGJyLz4oY29tcGFjdGlvbiBhZ2VudClcIl06OjpuZXdcbiAgICBkMTdfQVtcIkFuYWx5c3Q8YnIvPihxdWVzdGlvbnMgXHUyMTkyIFNwYXJrIFNRTClcIl06OjpuZXdcbiAgICBkMTdfUFtcIkF1dG9waWxvdDxici8-KG1vbml0b3IgXHUyMTkyIGRpYWdub3NlIFx1MjE5MiBmaXgpXCJdOjo6bmV3XG4gIGVuZFxuICBkMTdfR1dbXCJNTGZsb3cgQUkgR2F0ZXdheSA6NTAwMVwiXTo6Om5ld1xuICBkMTdfVFJbXCJNTGZsb3cgdHJhY2tpbmcgKyB0cmFjaW5nIDo1MDAwXCJdOjo6bmV3XG4gIGQxN19PTFtcIk9sbGFtYSAobG9jYWwgR1BVKTxici8-Z3B0LW9zczoyMGIgXHUwMGI3IG5lbW90cm9uLWNhc2NhZGUtMiBcdTAwYjc8YnIvPnF3ZW4zLWNvZGVyOjMwYlwiXTo6Om5ld1xuICBkMTdfUzQxW1wiU3BhcmsgNC4xXCJdOjo6b2xkXG4gIGQxN19JQ0VbKFwiSWNlYmVyZyB0YWJsZXNcIildOjo6b2xkXG4gIGQxN19HICYgZDE3X0EgJiBkMTdfUCAtLT58XCJMTE0gY2FsbHNcInwgZDE3X0dXIC0tPiBkMTdfT0xcbiAgZDE3X0cgJiBkMTdfQSAmIGQxN19QIC0uLT58XCJ0cmFjZXNcInwgZDE3X1RSXG4gIGQxN19HICYgZDE3X0EgJiBkMTdfUCAtLT58XCJ0b29sczogU3BhcmsgU1FMLDxici8-SWNlYmVyZyBwcm9jZWR1cmVzXCJ8IGQxN19TNDEgLS0-IGQxN19JQ0VcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 17" width="1079" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Tools it gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline Guardian&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the compaction agent: inspects Iceberg table health and decides when maintenance is due&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;inspect_table&lt;/code&gt; (row counts, snapshots, file counts), &lt;code&gt;check_quality&lt;/code&gt; (null rates, freshness, duplicates), &lt;code&gt;run_maintenance&lt;/code&gt;, &lt;code&gt;query_table&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Analyst&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;answers business questions over the gold layer ("what's the delivery performance in San Francisco this week?")&lt;/td&gt;
&lt;td&gt;natural language to Spark SQL, then back to prose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Autopilot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;watches the streaming pipeline and tries to fix it: throughput, latency, null spikes, schema drift, compaction when file counts climb&lt;/td&gt;
&lt;td&gt;all of the above, in a monitoring loop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Guardian is the one I find most interesting, because table maintenance is exactly the kind of chore people forget. Iceberg tables fed by streams accumulate small files and old snapshots until reads slow down, and deciding &lt;em&gt;when&lt;/em&gt; to compact is a judgement call about file counts, table size and query patterns. The agent makes the judgement; the tool it calls is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_maintenance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run Iceberg maintenance: compact, expire_snapshots, remove_orphans.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_spark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CALL iceberg.system.rewrite_data_files(table =&amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expire_snapshots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H:%M:%S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CALL iceberg.system.expire_snapshots(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table =&amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;older_than =&amp;gt; TIMESTAMP &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, retain_last =&amp;gt; 5)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handing that judgement to a model, with a narrow set of tools and a full trace of what it looked at and why, is a reasonable experiment. It is also, and I want to be clear about this, an experiment: an agent with &lt;code&gt;run_maintenance&lt;/code&gt; and an arbitrary-SQL tool can do real damage to real tables, which is why these live under &lt;code&gt;scripts/demos&lt;/code&gt; and nowhere near anything I care about.&lt;/p&gt;

&lt;p&gt;They were also the part of the stack where I pushed hardest on keeping everything open. The first version routed calls to a hosted model, and before the recording I asked for the opposite: "Can you hook up a local model into our lakehouse-stack instead of anthropic api? we go full oss if we can it will be a fun story. A US model is.. preferred". The gateway now points at models served locally through Ollama on the same desktop's GPU, and a hosted model is a one-line route change if you want one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chat&lt;/span&gt;
    &lt;span class="na"&gt;endpoint_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm/v1/chat&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai&lt;/span&gt;          &lt;span class="c1"&gt;# Ollama speaks the OpenAI API&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-oss:20b&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;openai_api_base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:11434/v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An open lakehouse whose agents phone a closed API for every decision isn't really an open lakehouse&lt;/strong&gt;, and on a desktop with a decent GPU, it doesn't have to be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Someone else's machine
&lt;/h2&gt;

&lt;p&gt;In April the repo got its first outside contributor, and he immediately found everything that only worked because it was &lt;em&gt;my&lt;/em&gt; machine. Denis Arnaud opened &lt;a href="https://github.com/lisancao/lakehouse-at-home/pull/37" rel="noopener noreferrer"&gt;four&lt;/a&gt; &lt;a href="https://github.com/lisancao/lakehouse-at-home/pull/38" rel="noopener noreferrer"&gt;pull&lt;/a&gt; &lt;a href="https://github.com/lisancao/lakehouse-at-home/pull/39" rel="noopener noreferrer"&gt;requests&lt;/a&gt; &lt;a href="https://github.com/lisancao/lakehouse-at-home/pull/40" rel="noopener noreferrer"&gt;in two days&lt;/a&gt;: the Unity Catalog image tag had vanished from Docker Hub; the CLI assumed GNU &lt;code&gt;df&lt;/code&gt;, which macOS doesn't have; and the host networking and the &lt;code&gt;/data/spark&lt;/code&gt; volume path (which exists because of how I partitioned my own drives) didn't translate to a Mac at all. He replaced them with named volumes and the very &lt;code&gt;host.docker.internal&lt;/code&gt; gateway I'd commented out in December, and fixed a timestamp parse that Spark 4's ANSI mode rejects along the way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: under ANSI mode a failed parse raises instead of returning null,
# so the coalesce never reaches its second format.
&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HH:mm:ss.SSSSSS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HH:mm:ss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After: try_to_timestamp returns null on a mismatch.
&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HH:mm:ss.SSSSSS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HH:mm:ss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While I was reviewing them, the coding agent helpfully opened its own versions of the same fixes. My message to it was not diplomatic ("why are you creating all these new PRs without my consent either. its getting kind of messy"), and then, more to the point, "in the OSS community we give credit and encourage people's PRs, not.. just take them and implement it ourselves. lol". I closed its copies and merged his.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;Put together, it looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Flakehouse-at-home-architecture.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsystemsnull.net%2Fposts%2Fdata-engineering-ai%2Flakehouse-at-home-architecture.svg" alt="lakehouse-at-home architecture: clients, orchestration and agents drive Spark through Spark Connect; the order generator feeds Kafka and Parquet into Spark's ingest jobs; Spark writes Iceberg and Delta tables through the Postgres and Unity Catalog catalogs into SeaweedFS" width="800" height="1010"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;./lakehouse&lt;/code&gt; CLI starts, checks and stops every box above, and GitHub Actions runs the test suite against Spark 4.0 and 4.1 on every pull request.&lt;/p&gt;

&lt;p&gt;And for anyone who wants to find their way around the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lakehouse-at-home/
├── lakehouse                     the CLI: setup · check-config · start/stop/status · testdata · test · migrate
├── docker-compose*.yml           one compose file per service: Spark 4.0, Spark 4.1, Kafka,
│                                   Unity Catalog, Airflow, MLflow, notebooks
├── config/                       Spark, Unity Catalog, Airflow, MLflow configs (.example only; live ones are gitignored)
├── dags/                         Airflow DAGs: medallion pipeline, SDP pipeline, Iceberg maintenance
├── scripts/
│   ├── pipelines/                the medallion pipeline, imperative (4.0) and declarative (4.1)
│   ├── testdata/                 the ghost-kitchen generator, with chaos
│   ├── demos/                    showcase demos and the experimental MLflow agents
│   ├── quickstarts/              Iceberg, Kafka streaming, and friends
│   ├── connectivity/             smoke tests for each service
│   └── tools/                    Kafka producer, helpers
├── notebooks/                    the 4.0 vs 4.1 workshop pair
├── benchmarks/                   pipeline, source and UDF benchmarks
├── tests/                        pytest: unit + integration (one file per service)
├── docs/                         getting started, guides, deployment, troubleshooting
├── terraform/                    AWS deployment (VPC, S3, RDS, EMR)
├── terraform-databricks/         Databricks as a managed destination
├── .github/ · .gitea/            CI and release workflows
├── CLAUDE.md                     agent guide to the project
├── AGENTS.md                     always-in-context Spark 4.1 reference for agents
└── .claude/skills/               agent skills, loaded on demand (SDP first; the rest grew into lakehouse-skills)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In April I reframed it from a hobby into a composable reference architecture, the pieces you'd need for a Databricks-equivalent lakehouse with each one swappable, and in May a stripped-down, Spark Connect-first copy became &lt;a href="https://github.com/open-lakehouse/open-lakehouse" rel="noopener noreferrer"&gt;open-lakehouse&lt;/a&gt; for team demos on AWS. The original stays the messier workbench, and it's still where new versions land first.&lt;/p&gt;

&lt;p&gt;Some of it is unfinished, and I'd rather say so. The Terraform for AWS and Databricks has never been applied end to end, and the IoT and personal-data half of the plan is still a plan. But the thing I built it for, it does. AUTO CDC shipped in Spark 4.2 in July; by then I'd already run it on a Spark that didn't officially exist yet, against data designed to misbehave, on my own desktop, and I knew where its edges were.&lt;/p&gt;

</description>
      <category>spark</category>
      <category>iceberg</category>
      <category>lakehouse</category>
      <category>opensource</category>
    </item>
    <item>
      <title>All About Iceberg's File Format API: simplifying the engine/format interface and Parquet's multimodal future</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:21:01 +0000</pubDate>
      <link>https://dev.to/lisancao/all-about-icebergs-file-format-api-simplifying-the-engineformat-interface-and-parquets-i66</link>
      <guid>https://dev.to/lisancao/all-about-icebergs-file-format-api-simplifying-the-engineformat-interface-and-parquets-i66</guid>
      <description>&lt;h2&gt;
  
  
  What is the file format API?
&lt;/h2&gt;

&lt;p&gt;Apache Iceberg traditionally exposed its data files through three libraries, one each for Parquet, Avro and ORC, and until this year every engine that spoke Iceberg had its own way of calling them. Spark had &lt;code&gt;SparkParquetReaders&lt;/code&gt;, Flink had &lt;code&gt;FlinkParquetReaders&lt;/code&gt;, the generic Java API had &lt;code&gt;GenericParquetReaders&lt;/code&gt;, and each engine chose its own path through the format's read options, filter pushdown, split handling, encryption and metrics. The practical effect was that a feature added to one format for one engine (a new v3 type, a default value, a shredded variant column) tended to arrive for the other engines later or not at all, because the work had to be redone per engine. &lt;/p&gt;

&lt;p&gt;The File Format API, which shipped in 1.11.0 and was &lt;a href="https://iceberg.apache.org/blog/apache-iceberg-file-format-api/" rel="noopener noreferrer"&gt;announced&lt;/a&gt; in February, is the project's attempt to streamline all this: a single registry of "format models," each one the pairing of a file format with an engine's in-memory row type, and a single set of builders (&lt;code&gt;ReadBuilder&lt;/code&gt;, &lt;code&gt;FileWriterBuilder&lt;/code&gt; and its delete-file siblings) that every engine now goes through. It was also originally framed as the way to try out newer formats such as Vortex and Lance, which are fundamentally designed very differently than Parquet and meant specifically for vector work and multimodal data storage. &lt;/p&gt;

&lt;p&gt;Note for Rust/Python users: this is a Java API. The registry lives in &lt;code&gt;iceberg-core&lt;/code&gt;, so it reaches exactly the engines that link that jar and reuse its readers (Spark, Flink, Hive, the plain Java &lt;code&gt;Record&lt;/code&gt; path). DuckDB, DataFusion via iceberg-rust, PyIceberg and Polars each implement the Iceberg spec with their own readers and leverage Arrow readers instead. For Trino, which is Java, it instead uses Iceberg's core for metadata and its own Parquet and ORC readers for data. DuckDB, DataFusion, Polars and PyIceberg all read into Arrow or something Arrow can hand over without copying, so the row-type axis collapses to one and a new format just needs an Arrow reader (Lance and Vortex both ship one). Java implementations have three row types and no shared memory format, which is why it needed a registry to play the role Arrow plays everywhere else.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Reads Iceberg data files with&lt;/th&gt;
&lt;th&gt;Goes through the registry?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spark, Flink, Hive, plain Java API&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Iceberg's own readers from &lt;code&gt;iceberg-core&lt;/code&gt; and the format modules&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trino&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;its own Parquet and ORC readers; Iceberg core for metadata only&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyIceberg&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;its own readers on PyArrow&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DataFusion (via iceberg-rust)&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;its own Arrow-based Parquet reader&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB&lt;/td&gt;
&lt;td&gt;C++&lt;/td&gt;
&lt;td&gt;its own Parquet reader&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polars&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Arrow&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 1. Which engines the File Format API actually reaches.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reducing Format Model x Reader Complexity
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;file format&lt;/strong&gt; is how bytes are laid out on disk: Parquet, Avro and ORC each have their own encoding, compression, statistics and footer, and Iceberg doesn't care which one a given data file uses as long as it can read it. An &lt;strong&gt;engine in-memory row type&lt;/strong&gt;, which the project calls the &lt;em&gt;object model&lt;/em&gt;, is how one row looks in memory once it's been read, and that depends on the engine, not the file: Spark wants an &lt;code&gt;InternalRow&lt;/code&gt;, Flink a &lt;code&gt;RowData&lt;/code&gt;, the plain Java API an Iceberg &lt;code&gt;Record&lt;/code&gt;. The same Parquet file can be read into any of them, and the same &lt;code&gt;InternalRow&lt;/code&gt; can be written out as Parquet or ORC. A format model is one cell in that grid: one format, one row type.&lt;/p&gt;

&lt;p&gt;Here is the grid as it stands at 1.12.0, with a mark wherever a format model exists:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row type (object model)&lt;/th&gt;
&lt;th&gt;Parquet&lt;/th&gt;
&lt;th&gt;Avro&lt;/th&gt;
&lt;th&gt;ORC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Record&lt;/code&gt; (plain Java API)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;InternalRow&lt;/code&gt; (Spark, row at a time)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ColumnarBatch&lt;/code&gt; (Spark, vectorized)&lt;/td&gt;
&lt;td&gt;read only&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;read only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ColumnarBatch&lt;/code&gt; (Arrow)&lt;/td&gt;
&lt;td&gt;read only&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RowData&lt;/code&gt; (Flink)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 2. The format-by-row-type grid. Each "yes" is one registered format model.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBmbXQgZmlsbDojYzA2NjFhLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojYzA2NjFhLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZW5nIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIFBbXCJvbmUgUGFycXVldCBmaWxlPGJyLz4oYnl0ZXMgb24gZGlzaylcIl06OjpmbXQgLS0-IE0xW1wiZm9ybWF0IG1vZGVsPGJyLz4oUEFSUVVFVCwgSW50ZXJuYWxSb3cpXCJdIC0tPiBTW1wiSW50ZXJuYWxSb3c8YnIvPmluIFNwYXJrJ3MgbWVtb3J5XCJdOjo6ZW5nXG4gIFAgLS0-IE0yW1wiZm9ybWF0IG1vZGVsPGJyLz4oUEFSUVVFVCwgUm93RGF0YSlcIl0gLS0-IEZbXCJSb3dEYXRhPGJyLz5pbiBGbGluaydzIG1lbW9yeVwiXTo6OmVuZ1xuICBQIC0tPiBNM1tcImZvcm1hdCBtb2RlbDxici8-KFBBUlFVRVQsIFJlY29yZClcIl0gLS0-IEdbXCJSZWNvcmQ8YnIvPmluIHRoZSBKYXZhIEFQSVwiXTo6OmVuZ1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBmbXQgZmlsbDojYzA2NjFhLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojYzA2NjFhLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZW5nIGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIFBbXCJvbmUgUGFycXVldCBmaWxlPGJyLz4oYnl0ZXMgb24gZGlzaylcIl06OjpmbXQgLS0-IE0xW1wiZm9ybWF0IG1vZGVsPGJyLz4oUEFSUVVFVCwgSW50ZXJuYWxSb3cpXCJdIC0tPiBTW1wiSW50ZXJuYWxSb3c8YnIvPmluIFNwYXJrJ3MgbWVtb3J5XCJdOjo6ZW5nXG4gIFAgLS0-IE0yW1wiZm9ybWF0IG1vZGVsPGJyLz4oUEFSUVVFVCwgUm93RGF0YSlcIl0gLS0-IEZbXCJSb3dEYXRhPGJyLz5pbiBGbGluaydzIG1lbW9yeVwiXTo6OmVuZ1xuICBQIC0tPiBNM1tcImZvcm1hdCBtb2RlbDxici8-KFBBUlFVRVQsIFJlY29yZClcIl0gLS0-IEdbXCJSZWNvcmQ8YnIvPmluIHRoZSBKYXZhIEFQSVwiXTo6OmVuZ1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scale this complexity further, per engine and per format:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBlbmcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZm10IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFNbXCJTcGFya1wiXTo6OmVuZyAtLT4gU1BbXCJTcGFya1BhcnF1ZXRSZWFkZXJzXCJdIC0tPiBQW1wiUGFycXVldFwiXTo6OmZtdFxuICBTIC0tPiBTQVtcIlNwYXJrQXZyb1JlYWRlclwiXSAtLT4gQVtcIkF2cm9cIl06OjpmbXRcbiAgUyAtLT4gU09bXCJTcGFya09yY1JlYWRlclwiXSAtLT4gT1tcIk9SQ1wiXTo6OmZtdFxuICBGW1wiRmxpbmtcIl06OjplbmcgLS0-IEZQW1wiRmxpbmtQYXJxdWV0UmVhZGVyc1wiXSAtLT4gUFxuICBGIC0tPiBGQVtcIkZsaW5rQXZyb1JlYWRlclwiXSAtLT4gQVxuICBGIC0tPiBGT1tcIkZsaW5rT3JjUmVhZGVyXCJdIC0tPiBPXG4gIEdbXCJKYXZhIEFQSVwiXTo6OmVuZyAtLT4gR1BbXCJHZW5lcmljUGFycXVldFJlYWRlcnNcIl0gLS0-IFBcbiAgRyAtLT4gR0FbXCIuLi5cIl0gLS0-IEFcbiAgRyAtLT4gR09bXCIuLi5cIl0gLS0-IE9cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBlbmcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZm10IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIFNbXCJTcGFya1wiXTo6OmVuZyAtLT4gU1BbXCJTcGFya1BhcnF1ZXRSZWFkZXJzXCJdIC0tPiBQW1wiUGFycXVldFwiXTo6OmZtdFxuICBTIC0tPiBTQVtcIlNwYXJrQXZyb1JlYWRlclwiXSAtLT4gQVtcIkF2cm9cIl06OjpmbXRcbiAgUyAtLT4gU09bXCJTcGFya09yY1JlYWRlclwiXSAtLT4gT1tcIk9SQ1wiXTo6OmZtdFxuICBGW1wiRmxpbmtcIl06OjplbmcgLS0-IEZQW1wiRmxpbmtQYXJxdWV0UmVhZGVyc1wiXSAtLT4gUFxuICBGIC0tPiBGQVtcIkZsaW5rQXZyb1JlYWRlclwiXSAtLT4gQVxuICBGIC0tPiBGT1tcIkZsaW5rT3JjUmVhZGVyXCJdIC0tPiBPXG4gIEdbXCJKYXZhIEFQSVwiXTo6OmVuZyAtLT4gR1BbXCJHZW5lcmljUGFycXVldFJlYWRlcnNcIl0gLS0-IFBcbiAgRyAtLT4gR0FbXCIuLi5cIl0gLS0-IEFcbiAgRyAtLT4gR09bXCIuLi5cIl0gLS0-IE9cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 2" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After, through the File Format API's registry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBlbmcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcmVnIGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGZtdCBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBTW1wiU3BhcmtcIl06OjplbmcgLS0-IFJbXCJGb3JtYXRNb2RlbFJlZ2lzdHJ5PGJyLz5yZWFkQnVpbGRlcihmb3JtYXQsIHJvd1R5cGUsIGZpbGUpXCJdOjo6cmVnXG4gIEZbXCJGbGlua1wiXTo6OmVuZyAtLT4gUlxuICBHW1wiSmF2YSBBUElcIl06OjplbmcgLS0-IFJcbiAgUiAtLT4gUFtcIlBhcnF1ZXRGb3JtYXRNb2RlbFwiXTo6OmZtdFxuICBSIC0tPiBBW1wiQXZyb0Zvcm1hdE1vZGVsXCJdOjo6Zm10XG4gIFIgLS0-IE9bXCJPUkNGb3JtYXRNb2RlbFwiXTo6OmZtdFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBlbmcgZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcmVnIGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGZtdCBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBTW1wiU3BhcmtcIl06OjplbmcgLS0-IFJbXCJGb3JtYXRNb2RlbFJlZ2lzdHJ5PGJyLz5yZWFkQnVpbGRlcihmb3JtYXQsIHJvd1R5cGUsIGZpbGUpXCJdOjo6cmVnXG4gIEZbXCJGbGlua1wiXTo6OmVuZyAtLT4gUlxuICBHW1wiSmF2YSBBUElcIl06OjplbmcgLS0-IFJcbiAgUiAtLT4gUFtcIlBhcnF1ZXRGb3JtYXRNb2RlbFwiXTo6OmZtdFxuICBSIC0tPiBBW1wiQXZyb0Zvcm1hdE1vZGVsXCJdOjo6Zm10XG4gIFIgLS0-IE9bXCJPUkNGb3JtYXRNb2RlbFwiXTo6OmZtdFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 3" width="709" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;**The API makes the engine's row type pluggable over a fixed list of file formats, such that any engine can register its own representation and read every Iceberg table through the shared builders, while a new format remains a change inside the Iceberg project; and because the formats are fixed, multimodal support is arriving as new column types defined on the Parquet side (see FILE type that was shipped in parquet-format 2.14.0, VECTOR type currently in proposal stage) and mirrored into the next table spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Into the weeds
&lt;/h2&gt;

&lt;p&gt;Everything in the API is built on one map, which makes it the ideal place to start digging into in the codebase to gain a better understanding. If you only want to read and write files through the registry, skip ahead to "Usage." Read this section to understand &lt;em&gt;why&lt;/em&gt; the API behaves the way it does, and come back to it when something in a later section needs the detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The registry key
&lt;/h3&gt;

&lt;p&gt;A &lt;em&gt;map&lt;/em&gt; here is the ordinary programming kind: a lookup table from a key to a value. In &lt;code&gt;FormatModelRegistry&lt;/code&gt;, the key is a pair (which file format, which row type) and the value is a &lt;em&gt;format model&lt;/em&gt;, the object that knows how to read and write that one combination. Here is the method that puts entries into the map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// core/src/main/java/org/apache/iceberg/formats/FormatModelRegistry.java (1.12.0)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FormatModel&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;formatModel&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;Pair&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FileFormat&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pair&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;formatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;formatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="no"&gt;MODELS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;formatModel&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second half of the key, &lt;code&gt;Class&amp;lt;?&amp;gt;&lt;/code&gt;, is the Java class of the row the engine wants back. For the plain Java API it is Iceberg's own &lt;code&gt;Record&lt;/code&gt;; for Spark it is &lt;code&gt;InternalRow&lt;/code&gt; (one row at a time) or &lt;code&gt;ColumnarBatch&lt;/code&gt; (a block of rows); for Flink it is &lt;code&gt;RowData&lt;/code&gt;. The Iceberg project calls this the &lt;em&gt;object model&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!note] Object model versus schema&lt;br&gt;
The object model is the engine's in-memory shape for a row. It says nothing about the file and nothing about the table's schema. The &lt;em&gt;schema&lt;/em&gt; says which columns a table has; the &lt;em&gt;object model&lt;/em&gt; says what Java class a row of that table becomes once it is read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first half of the key, &lt;code&gt;FileFormat&lt;/code&gt;, is an &lt;em&gt;enum&lt;/em&gt;: a type whose allowed values are a fixed list written into the source code. You cannot add a value at runtime. This enum lives in &lt;code&gt;iceberg-api&lt;/code&gt;, the module that holds Iceberg's public interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/src/main/java/org/apache/iceberg/FileFormat.java (1.12.0)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;FileFormat&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="no"&gt;PUFFIN&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"puffin"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="no"&gt;ORC&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orc"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="no"&gt;PARQUET&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parquet"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="no"&gt;AVRO&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"avro"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="no"&gt;METADATA&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"metadata.json"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Puffin and metadata appear because the enum names every kind of file Iceberg writes, not only data files. Puffin files hold statistics and deletion vectors; &lt;code&gt;metadata.json&lt;/code&gt; is the table's root file. The three data formats are the ones in the middle: ORC, Parquet and Avro.&lt;/p&gt;

&lt;p&gt;The two halves of the key are not the same kind of thing, and that difference organizes the rest of this post:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Half of the key&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Allowed values&lt;/th&gt;
&lt;th&gt;Who can add one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FileFormat&lt;/code&gt; enum in &lt;code&gt;iceberg-api&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the five names above&lt;/td&gt;
&lt;td&gt;the Iceberg project, by changing the enum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row type&lt;/td&gt;
&lt;td&gt;any &lt;code&gt;Class&amp;lt;?&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;anything&lt;/td&gt;
&lt;td&gt;any engine, by calling &lt;code&gt;register()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 3. The two halves of the registry key.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The announcement says the registry "decouples engines from specific formats and allows new formats to be added without modifying engine code." That is true as far as engine code goes. What a new format has to modify is &lt;code&gt;iceberg-api&lt;/code&gt;, the enum itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens on a read
&lt;/h3&gt;

&lt;p&gt;A &lt;em&gt;scan task&lt;/em&gt; is Iceberg's description of one file to read, including which format it is in. From the task to the rows, a read goes like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine asks the registry for a read builder, passing the task's format, its own row type, and the file.&lt;/li&gt;
&lt;li&gt;The registry looks up the (format, row type) pair.&lt;/li&gt;
&lt;li&gt;If the pair is registered, the model returns a builder. The engine sets the columns it wants, a filter, and the byte range of the file it was assigned, then calls &lt;code&gt;build()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the pair is not registered, the registry throws an error that names the pair it could not find.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFRbXCJzY2FuIHRhc2s8YnIvPmZpbGUuZm9ybWF0KCkgPSBQQVJRVUVUXCJdIC0tPiBSW1wiRm9ybWF0TW9kZWxSZWdpc3RyeS5yZWFkQnVpbGRlcig8YnIvPlBBUlFVRVQsIEludGVybmFsUm93LmNsYXNzLCBpbnB1dEZpbGUpXCJdXG4gICAgUiAtLT4gS3tcIihQQVJRVUVULCBJbnRlcm5hbFJvdyk8YnIvPmluIE1PREVMUz9cIn1cbiAgICBLIC0tPnx5ZXN8IE1bXCJQYXJxdWV0Rm9ybWF0TW9kZWw8YnIvPnJlZ2lzdGVyZWQgYnkgU3BhcmtGb3JtYXRNb2RlbHNcIl1cbiAgICBNIC0tPiBCW1wiUmVhZEJ1aWxkZXI6IHByb2plY3QsIGZpbHRlciw8YnIvPnNwbGl0LCBwYXJ0aXRpb24gY29uc3RhbnRzXCJdXG4gICAgQiAtLT4gSVtcIkNsb3NlYWJsZUl0ZXJhYmxlJmx0O0ludGVybmFsUm93Jmd0O1wiXVxuICAgIEsgLS0-fG5vfCBFW1wiSWxsZWdhbEFyZ3VtZW50RXhjZXB0aW9uOjxici8-Rm9ybWF0IG1vZGVsIGlzIG5vdCByZWdpc3RlcmVkPGJyLz5mb3IgZm9ybWF0IFBBUlFVRVQgYW5kIHR5cGUgLi4uXCJdXG4gICAgRltcIkZpbGVGb3JtYXQgZW51bTxici8-UFVGRklOLCBPUkMsIFBBUlFVRVQsIEFWUk8sIE1FVEFEQVRBXCJdIC0uLT58XCJmb3JtYXQ6IG9uZSBvZiB0aGVzZVwifCBLXG4gICAgQ1tcIkNsYXNzJmx0Oz8mZ3Q7PGJyLz5SZWNvcmQsIEludGVybmFsUm93LCBDb2x1bW5hckJhdGNoLCBSb3dEYXRhLCAuLi5cIl0gLS4tPnxcInR5cGU6IGFueXRoaW5nXCJ8IEtcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFRbXCJzY2FuIHRhc2s8YnIvPmZpbGUuZm9ybWF0KCkgPSBQQVJRVUVUXCJdIC0tPiBSW1wiRm9ybWF0TW9kZWxSZWdpc3RyeS5yZWFkQnVpbGRlcig8YnIvPlBBUlFVRVQsIEludGVybmFsUm93LmNsYXNzLCBpbnB1dEZpbGUpXCJdXG4gICAgUiAtLT4gS3tcIihQQVJRVUVULCBJbnRlcm5hbFJvdyk8YnIvPmluIE1PREVMUz9cIn1cbiAgICBLIC0tPnx5ZXN8IE1bXCJQYXJxdWV0Rm9ybWF0TW9kZWw8YnIvPnJlZ2lzdGVyZWQgYnkgU3BhcmtGb3JtYXRNb2RlbHNcIl1cbiAgICBNIC0tPiBCW1wiUmVhZEJ1aWxkZXI6IHByb2plY3QsIGZpbHRlciw8YnIvPnNwbGl0LCBwYXJ0aXRpb24gY29uc3RhbnRzXCJdXG4gICAgQiAtLT4gSVtcIkNsb3NlYWJsZUl0ZXJhYmxlJmx0O0ludGVybmFsUm93Jmd0O1wiXVxuICAgIEsgLS0-fG5vfCBFW1wiSWxsZWdhbEFyZ3VtZW50RXhjZXB0aW9uOjxici8-Rm9ybWF0IG1vZGVsIGlzIG5vdCByZWdpc3RlcmVkPGJyLz5mb3IgZm9ybWF0IFBBUlFVRVQgYW5kIHR5cGUgLi4uXCJdXG4gICAgRltcIkZpbGVGb3JtYXQgZW51bTxici8-UFVGRklOLCBPUkMsIFBBUlFVRVQsIEFWUk8sIE1FVEFEQVRBXCJdIC0uLT58XCJmb3JtYXQ6IG9uZSBvZiB0aGVzZVwifCBLXG4gICAgQ1tcIkNsYXNzJmx0Oz8mZ3Q7PGJyLz5SZWNvcmQsIEludGVybmFsUm93LCBDb2x1bW5hckJhdGNoLCBSb3dEYXRhLCAuLi5cIl0gLS4tPnxcInR5cGU6IGFueXRoaW5nXCJ8IEtcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 4" width="1904" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dotted lines draw the open-versus-closed distinction: one input to the lookup comes from a fixed list, the other is whatever the engine says it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  How models get registered
&lt;/h3&gt;

&lt;p&gt;When the registry class is first loaded, it tries to call a &lt;code&gt;register()&lt;/code&gt; method on four classes by name: the generic models, and the Arrow, Flink and Spark ones. If one of those classes, or something it depends on, is not on the &lt;em&gt;classpath&lt;/em&gt; (the set of jars the program was started with), the registry logs a line and moves on. The practical consequence is that the contents of the map depend on which Iceberg jars you ship. You will see this directly in the "Usage" section. Everything the project registers at 1.12.0 is an engine's row type against a format Iceberg already had (Table 2); none of it is a new format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;p&gt;It would be a terrible user experience to construct format models yourself; so instead you'll ask the registry for a builder and configure it. A &lt;em&gt;builder&lt;/em&gt; is an object you set options on one at a time and then finish with &lt;code&gt;build()&lt;/code&gt;. The registry exposes two of them, and together they are the whole surface an engine touches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;You pass&lt;/th&gt;
&lt;th&gt;You set&lt;/th&gt;
&lt;th&gt;You get back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;readBuilder(format, rowType, inputFile)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the file's format, your row type, the file&lt;/td&gt;
&lt;td&gt;columns to project, a filter, a byte range (split)&lt;/td&gt;
&lt;td&gt;something you can iterate over, yielding rows of your type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dataWriteBuilder(format, rowType, outputFile)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the format to write, your row type, the output&lt;/td&gt;
&lt;td&gt;the schema and partition spec&lt;/td&gt;
&lt;td&gt;a writer; closing it yields a &lt;code&gt;DataFile&lt;/code&gt;, the record Iceberg stores in its manifests, with column statistics already filled in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 4. The two registry calls an engine uses.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this code lives
&lt;/h3&gt;

&lt;p&gt;If your experience of Iceberg so far is &lt;code&gt;spark.sql("SELECT * FROM demo.orders")&lt;/code&gt; or a REST catalog behind a &lt;code&gt;/v1/namespaces&lt;/code&gt; endpoint, the first question is where code like this even goes. It goes nowhere near either of those. There are three different places you can stand when you use Iceberg, and the File Format API only exists at the lowest one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where you stand&lt;/th&gt;
&lt;th&gt;What you type&lt;/th&gt;
&lt;th&gt;What Iceberg does for you&lt;/th&gt;
&lt;th&gt;Sees the Format API?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An engine (Spark SQL, Flink SQL, Trino)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;MERGE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;catalog lookup, planning, file reads and writes, commits&lt;/td&gt;
&lt;td&gt;No. The engine calls it for you.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A catalog client (the REST catalog, PyIceberg, a Spark catalog config)&lt;/td&gt;
&lt;td&gt;load a table, list snapshots, commit metadata&lt;/td&gt;
&lt;td&gt;metadata only; no data files are opened&lt;/td&gt;
&lt;td&gt;No. Files are not touched at this level.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Java library directly (&lt;code&gt;iceberg-core&lt;/code&gt;, &lt;code&gt;iceberg-data&lt;/code&gt;, &lt;code&gt;iceberg-parquet&lt;/code&gt; on a classpath)&lt;/td&gt;
&lt;td&gt;Java against &lt;code&gt;Table&lt;/code&gt;, &lt;code&gt;Schema&lt;/code&gt;, &lt;code&gt;FormatModelRegistry&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;only what you call&lt;/td&gt;
&lt;td&gt;Yes. This is where the registry is a public class you can call.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 5. Three levels of using Iceberg. The Format API is reachable only from the bottom one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The program below is a plain Java &lt;code&gt;main&lt;/code&gt; method with the Iceberg jars as dependencies. It is not a Spark job, it is not submitted to anything, and it does not need a catalog or even a table. It writes one Parquet file with Iceberg's own writer and reads it back with Iceberg's own reader, which is the single step every engine performs thousands of times per query and the only step the Format API is responsible for.&lt;/p&gt;

&lt;p&gt;You would never run something like this in production. You run it when you want to see what the registry contains on a given classpath, or when you are writing a new format model and want the shortest possible loop between a change and a result. Here is the whole setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make an empty directory with two files in it: &lt;code&gt;pom.xml&lt;/code&gt; and &lt;code&gt;src/main/java/demo/Main.java&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;pom.xml&lt;/code&gt; (Maven is Java's package manager, and this is its dependency list), declare the Iceberg modules you want on the classpath. The set is deliberately minimal: &lt;code&gt;iceberg-api&lt;/code&gt;, &lt;code&gt;iceberg-core&lt;/code&gt;, &lt;code&gt;iceberg-data&lt;/code&gt; and &lt;code&gt;iceberg-parquet&lt;/code&gt;, plus the Hadoop client jars Parquet needs and a logger. There is no &lt;code&gt;iceberg-orc&lt;/code&gt;, and no Spark.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;mvn -q compile exec:java&lt;/code&gt;. Maven downloads the jars from Maven Central, compiles &lt;code&gt;Main.java&lt;/code&gt;, and runs it on the JVM in your terminal. The output below is what it prints.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The program
&lt;/h3&gt;

&lt;p&gt;I ran this against Iceberg 1.12.0 from Maven Central on Java 17, with nothing timed. The program does four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prints the &lt;code&gt;FileFormat&lt;/code&gt; enum.&lt;/li&gt;
&lt;li&gt;Reaches into the registry and prints what is in the map.&lt;/li&gt;
&lt;li&gt;Writes three records through the write builder, then reads them back through the read builder.&lt;/li&gt;
&lt;li&gt;Asks for a (format, row type) pair that cannot be there.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;demo&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.io.File&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.lang.reflect.Method&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Map&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.DataFile&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.FileFormat&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.Files&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.PartitionSpec&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.Schema&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.data.GenericRecord&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.data.Record&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.encryption.EncryptionUtil&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.formats.FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.io.CloseableIterable&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.io.DataWriter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.iceberg.types.Types&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Main&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. The format half of the key: a fixed list, nothing is added at runtime.&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"== FileFormat enum values"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FileFormat&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FileFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" splittable="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSplittable&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Peek inside the registry (reflection, since the map is private).&lt;/span&gt;
    &lt;span class="c1"&gt;//    Shows what the jars on the classpath registered before main() ran.&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"== registered format models on this classpath (core + data + parquet)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Method&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDeclaredMethod&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"models"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAccessible&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;)&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invoke&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;Object:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;sorted&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;forEach&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// An Iceberg schema. Columns are matched by the numeric field id, not by name.&lt;/span&gt;
    &lt;span class="nc"&gt;Schema&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NestedField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LongType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()),&lt;/span&gt;
        &lt;span class="nc"&gt;Types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NestedField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;optional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;StringType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

    &lt;span class="c1"&gt;// A local file to write to; an engine would get an object store path from FileIO.&lt;/span&gt;
    &lt;span class="nc"&gt;File&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/tmp/ffa-demo.parquet"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 3a. Write. (PARQUET, Record.class) is the registry key; build() does the lookup&lt;/span&gt;
    &lt;span class="c1"&gt;//     and hands back the Parquet model's writer, or throws if the pair is missing.&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"== write via FormatModelRegistry.dataWriteBuilder(PARQUET, Record.class, ...)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;DataWriter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dataWriteBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;FileFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARQUET&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nc"&gt;EncryptionUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;plainAsEncryptedOutput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;localOutput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PartitionSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unpartitioned&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// required even without partitioning&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;overwrite&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Record is Iceberg's generic row type; copy(name, value, ...) makes a row.&lt;/span&gt;
    &lt;span class="nc"&gt;GenericRecord&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenericRecord&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"row-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// toDataFile() returns the manifest entry an engine would commit, statistics included.&lt;/span&gt;
    &lt;span class="nc"&gt;DataFile&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toDataFile&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  wrote "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" format="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" records="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" bytes="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fileSizeInBytes&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" valueCounts="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueCounts&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="c1"&gt;// 3b. Read. Same key, other direction. An engine would also set a filter and a split.&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"== read via FormatModelRegistry.readBuilder(PARQUET, Record.class, ...)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CloseableIterable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;readBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;FileFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARQUET&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;localInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
      &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. A valid format and a valid row type, but iceberg-orc is not on the&lt;/span&gt;
    &lt;span class="c1"&gt;//    classpath, so nobody registered the pair. The error names it.&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"== ask for a model that is not registered (ORC with generic Record, no iceberg-orc on classpath)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FileFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ORC&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;localInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And its output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== FileFormat enum values
  PUFFIN splittable=false
  ORC splittable=true
  PARQUET splittable=true
  AVRO splittable=true
  METADATA splittable=false
== registered format models on this classpath (core + data + parquet)
  (AVRO, class org.apache.iceberg.deletes.PositionDelete)
  (AVRO, interface org.apache.iceberg.data.Record)
  (PARQUET, class org.apache.iceberg.deletes.PositionDelete)
  (PARQUET, interface org.apache.iceberg.data.Record)
== write via FormatModelRegistry.dataWriteBuilder(PARQUET, Record.class, ...)
  wrote .../out.parquet format=PARQUET records=3 bytes=684 valueCounts={1=3, 2=3}
== read via FormatModelRegistry.readBuilder(PARQUET, Record.class, ...)
  Record(1, row-1)
  Record(2, row-2)
  Record(3, row-3)
== ask for a model that is not registered (ORC with generic Record, no iceberg-orc on classpath)
  Format model is not registered for format ORC and type interface org.apache.iceberg.data.Record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the output top to bottom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The map holds Parquet and Avro&lt;/strong&gt;, each paired with the generic &lt;code&gt;Record&lt;/code&gt; and with the position-delete row type, and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avro is present without being asked for&lt;/strong&gt;, because its model ships inside &lt;code&gt;iceberg-core&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORC is absent&lt;/strong&gt; because the &lt;code&gt;iceberg-orc&lt;/code&gt; jar is not on the classpath, and the error names the exact pair the registry could not find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The write returned &lt;code&gt;valueCounts&lt;/code&gt;&lt;/strong&gt;: the writer filled in the &lt;code&gt;DataFile&lt;/code&gt; statistics without the program doing anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One thing the program cannot express at all&lt;/strong&gt; is a request for a format outside the five names. There is no &lt;code&gt;FileFormat.LANCE&lt;/code&gt; to pass, and converting the string &lt;code&gt;"lance"&lt;/code&gt; to a &lt;code&gt;FileFormat&lt;/code&gt; throws "Invalid file format: lance" before the registry is ever reached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The row-type side is open. The format side is closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same call, from Spark
&lt;/h3&gt;

&lt;p&gt;To connect this back to the level you already know: when you run &lt;code&gt;spark.read.table("demo.orders").show()&lt;/code&gt; with the Iceberg connector, Spark loads the table through the catalog, plans which data files each task should scan, and then, for every file, makes exactly the call the program made by hand. Here is the Spark connector's version at 1.12.0, from &lt;code&gt;BaseRowReader&lt;/code&gt; in the &lt;code&gt;iceberg-spark&lt;/code&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same call as the program above, with Spark's row type and all the options set.&lt;/span&gt;
&lt;span class="nc"&gt;ReadBuilder&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InternalRow&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InternalRow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projection&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// columns the query asked for&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idToConstant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idToConstant&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// partition values, not stored in the file&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reuseContainers&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// this task's byte range&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;caseSensitive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caseSensitive&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;residual&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;// what is left of the WHERE clause&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withNameMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nameMapping&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;format&lt;/code&gt; variable is the &lt;code&gt;FileFormat&lt;/code&gt; recorded in the manifest entry for that file, and &lt;code&gt;InternalRow.class&lt;/code&gt; is the row type Spark registered for itself in &lt;code&gt;SparkFormatModels&lt;/code&gt; at startup. The query you type at the top level becomes, at the bottom level, one registry lookup per file. Everything between the two is planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exploring pluggability
&lt;/h2&gt;

&lt;p&gt;The extension point is relatively easy to grokk and doesn't take a ton of code. This section covers what an engine supplies, what the format model owns, and what "pluggable" therefore means in practice.&lt;/p&gt;

&lt;p&gt;Here is how the generic Parquet model is registered, with each argument labeled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/src/main/java/org/apache/iceberg/data/GenericFormatModels.java (1.12.0)&lt;/span&gt;
&lt;span class="nc"&gt;FormatModelRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ParquetFormatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// the row type&lt;/span&gt;
        &lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// the engine schema type&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icebergSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engineSchema&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;              &lt;span class="c1"&gt;// how to write a Record&lt;/span&gt;
            &lt;span class="nc"&gt;GenericParquetWriter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icebergSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileSchema&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icebergSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engineSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idToConstant&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="c1"&gt;// how to read one&lt;/span&gt;
            &lt;span class="nc"&gt;GenericParquetReaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buildReader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icebergSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileSchema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idToConstant&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RecordVariantShreddingAnalyzer&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;                      &lt;span class="c1"&gt;// see below&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Function&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UnaryOperator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;)&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nl"&gt;Record:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// how to copy a row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine supplies two functions. The &lt;strong&gt;writer function&lt;/strong&gt;, given a schema, builds something that writes a row's values into Parquet. The &lt;strong&gt;reader function&lt;/strong&gt; builds something that reads values out of Parquet into a row; its &lt;code&gt;idToConstant&lt;/code&gt; argument is a map from column ids to values that come from metadata rather than from the file, mostly partition columns. &lt;code&gt;ParquetFormatModel&lt;/code&gt; wraps those two functions in the builders every caller uses, and everything else lives in the model, once, for every engine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The engine supplies&lt;/th&gt;
&lt;th&gt;The format model owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the row class and the engine schema type&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;schema conversion&lt;/em&gt; between Iceberg's schema and the file's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a writer function (row values into the file)&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;filter pushdown&lt;/em&gt;: skipping chunks of the file that cannot match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a reader function (file values into a row)&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;splits&lt;/em&gt;: reading one byte range of a large file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a shredding analyzer (which variant fields to shred)&lt;/td&gt;
&lt;td&gt;encryption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a row-copy function&lt;/td&gt;
&lt;td&gt;the statistics that end up in the &lt;code&gt;DataFile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;the shredded write path itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 6. Division of labour between an engine and a format model.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The engine never touches anything in the right-hand column.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!note] The analyzer argument&lt;br&gt;
&lt;em&gt;Variant&lt;/em&gt; is Iceberg's type for semi-structured data such as JSON. &lt;em&gt;Shredding&lt;/em&gt; is Parquet's technique of storing some of a variant's fields as ordinary columns so they can be filtered and read quickly. The Parquet model owns the shredded write path; each engine supplies an analyzer that says which fields to shred for its row type. There is one for &lt;code&gt;Record&lt;/code&gt; here, and a Spark one and a Flink one in their modules. It is a small example of how the labour divides: the format owns the mechanism, the engine owns the choice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So if you maintain an engine, or anything with its own idea of a row, this is your plugin point, and it is a good one. Register your row type against Parquet, Avro and ORC, and your engine reads any Iceberg table through the same builders Spark uses, without changing anything in the Iceberg project.&lt;/p&gt;

&lt;p&gt;And the engines really do all go through it now, which is the part of the announcement that is simply true. Every reader in the 1.12.0 source that opens a data file (in Spark, Flink, the generic Java path, the MapReduce input format and the Arrow reader) gets its reader from the registry; none of them calls the old per-format &lt;code&gt;Parquet.read&lt;/code&gt;, &lt;code&gt;ORC.read&lt;/code&gt; or &lt;code&gt;Avro.read&lt;/code&gt; directly any more. One implementation per format, shared by every engine, chosen by (format, row type).&lt;/p&gt;

&lt;p&gt;Put that next to the dotted lines in the read diagram and "pluggable" comes into focus. What was scattered and is now unified is the engine side. What was fixed and is still fixed is the format side. That is not a flaw so much as a boundary, and the next section is about what sits on the other side of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where formats come into play and the compatibility kit
&lt;/h2&gt;

&lt;p&gt;This section answers two questions: how does a new file format get into Iceberg today, and what does it have to pass once it is there?&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a format: the Vortex example
&lt;/h3&gt;

&lt;p&gt;A new format enters Iceberg the way the project's own showcase does. &lt;em&gt;Vortex&lt;/em&gt; is a newer columnar file format, and its integration (&lt;a href="https://github.com/apache/iceberg/issues/15416" rel="noopener noreferrer"&gt;issue 15416&lt;/a&gt;) has a pull request (&lt;a href="https://github.com/apache/iceberg/pull/15915" rel="noopener noreferrer"&gt;#15915&lt;/a&gt;) that shows every step. The PR has been open since April, and the table shows where it lands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What a new format needs there&lt;/th&gt;
&lt;th&gt;Does the Vortex PR touch it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceberg-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a new &lt;code&gt;FileFormat&lt;/code&gt; value&lt;/td&gt;
&lt;td&gt;yes, first file in the change list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a format module&lt;/td&gt;
&lt;td&gt;the format model, readers and writers&lt;/td&gt;
&lt;td&gt;yes, new &lt;code&gt;iceberg-vortex&lt;/code&gt; module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;each engine&lt;/td&gt;
&lt;td&gt;a registration of its row type against the new format&lt;/td&gt;
&lt;td&gt;yes, Spark readers and tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the compatibility kit&lt;/td&gt;
&lt;td&gt;an entry so the whole test matrix runs against it&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the table spec&lt;/td&gt;
&lt;td&gt;a fifth name in the &lt;code&gt;file_format&lt;/code&gt; sentence&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 7. Where a new file format has to land, and how far the Vortex PR gets.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first file in the PR's change list is &lt;code&gt;FileFormat.java&lt;/code&gt;, adding &lt;code&gt;VORTEX&lt;/code&gt; to &lt;code&gt;iceberg-api&lt;/code&gt;, the module with the strongest compatibility promises in the project; every change there is checked by a tool called Revapi and has to be acknowledged on purpose. What the PR does &lt;em&gt;not&lt;/em&gt; touch is as telling as what it does. Iceberg's &lt;em&gt;table spec&lt;/em&gt; is the document every implementation follows, in Java or any other language. It lists each data file in a manifest with a &lt;code&gt;file_format&lt;/code&gt; field, and the &lt;a href="https://github.com/apache/iceberg/blob/apache-iceberg-1.12.0/format/spec.md" rel="noopener noreferrer"&gt;spec&lt;/a&gt; at 1.12.0 defines that field as "String file format name, &lt;code&gt;avro&lt;/code&gt;, &lt;code&gt;orc&lt;/code&gt;, &lt;code&gt;parquet&lt;/code&gt;, or &lt;code&gt;puffin&lt;/code&gt;." The Vortex PR changes nothing in the spec. The Java enum is moving ahead of the spec text, which is a normal order of events in this project, but it means adding a format has two halves: the constant in &lt;code&gt;iceberg-api&lt;/code&gt;, and the spec sentence that would need a fifth name before PyIceberg or iceberg-rust were obliged to know about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a format has to pass
&lt;/h3&gt;

&lt;p&gt;Behind the implementation sits the &lt;a href="https://github.com/apache/iceberg/issues/15415" rel="noopener noreferrer"&gt;Technology Compatibility Kit&lt;/a&gt; (TCK). A &lt;em&gt;compatibility kit&lt;/em&gt; is a test suite that a new component must pass to count as equivalent to the existing ones. Here, it is the set of tests a new format has to pass to be held to the same standard as Parquet. The announcement lists the TCK as in progress; the issue's author said in August that it is "more-or-less finished" apart from some v3 types and vectorized paths, which I'll take at face value and no further, since it's one comment.&lt;/p&gt;

&lt;p&gt;The path for a format today is therefore:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get into the enum.&lt;/li&gt;
&lt;li&gt;Implement a format model.&lt;/li&gt;
&lt;li&gt;Get every engine to register its row type against it.&lt;/li&gt;
&lt;li&gt;Pass the TCK.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a project roadmap item, not a plugin. A closed enum is also how the &lt;code&gt;DataFile&lt;/code&gt; metadata, the manifest field above, and every reader that switches on format stay consistent with one another, so I'd be surprised if it opened casually.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inside the kit
&lt;/h3&gt;

&lt;p&gt;Since the kit is what a format or an engine actually has to pass, it is worth looking at what it is, because it is smaller and more concrete than the name suggests.&lt;/p&gt;

&lt;p&gt;The kit is two &lt;em&gt;abstract&lt;/em&gt; test classes in the test folder of the &lt;code&gt;iceberg-data&lt;/code&gt; module. Abstract means they are templates: they contain every test but leave a few blanks for an engine to fill in. &lt;code&gt;ReadFormatModelTests&lt;/code&gt; covers reading, and &lt;code&gt;BaseFormatModelTests&lt;/code&gt; extends it with writing. An engine joins by writing a small subclass that fills in the blanks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What its row class is.&lt;/li&gt;
&lt;li&gt;How to turn an Iceberg schema into its own kind of schema.&lt;/li&gt;
&lt;li&gt;How to turn a generic &lt;code&gt;Record&lt;/code&gt; into one of its rows.&lt;/li&gt;
&lt;li&gt;How to compare two of its rows.&lt;/li&gt;
&lt;li&gt;How to convert a single constant value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Spark's subclass is about a page, Flink's about the same. Everything else is inherited from the template.&lt;/p&gt;

&lt;p&gt;What gets inherited is every test, run for every file format the kit knows about, times every &lt;em&gt;data generator&lt;/em&gt; (a small class that produces a schema and some random rows for it). The generators cover primitives, UUID, fixed, binary, decimals, a struct, a list and a map of primitives, nanosecond timestamps and variant. The tests cover round trips in both directions (including delete files), every read builder option, default values at every nesting, statistics in each collection mode, the metadata columns, schema evolution and name mapping.&lt;/p&gt;

&lt;p&gt;The part I find most useful is that the kit is also where the exceptions get written down. Each engine's subclass declares the Iceberg types it cannot hold, and each format declares the features it is missing. Tests for those are skipped rather than failed, so the kit doubles as the honest table of who supports what:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cannot hold / is missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spark&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;time&lt;/code&gt;, &lt;code&gt;timestamp_ns&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flink&lt;/td&gt;
&lt;td&gt;&lt;code&gt;time&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avro&lt;/td&gt;
&lt;td&gt;filter pushdown, splits, column statistics, native encryption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORC&lt;/td&gt;
&lt;td&gt;reader-side defaults, variant, container reuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parquet&lt;/td&gt;
&lt;td&gt;AES stream encryption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 8. Exclusions declared in the compatibility kit at 1.12.0.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The nanosecond timestamp I mentioned above is a one-line entry in Spark's exclusion set, which is the clearest statement anywhere in the repository that the gap is in the engine and not in the format or the API.&lt;/p&gt;

&lt;p&gt;The kit was not in 1.11.0. It arrived as a series of separate PRs through the year, several from contributors outside the original design group, and the last open piece is vectorized reads.&lt;/p&gt;

&lt;p&gt;That is what sits behind the "more-or-less finished" comment, and it also tells you how a new format gets in: add an entry for it to the kit's list of formats, run the whole matrix, and write down what it cannot do in the missing-features map. That list is the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implications for native multimodal in Iceberg
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Multimodal&lt;/em&gt; data means images, audio, video and documents, plus the embedding vectors a model produces from them. If formats are fixed, then multimodal data has to come into Iceberg through the formats it already has, and that is exactly what is happening, one level down and one project over. Two new column types are being defined in the Parquet format spec first, and then mirrored into Iceberg's table spec as part of the &lt;em&gt;v4&lt;/em&gt; revision. (The table spec is versioned; v3 is current, and v4 is being drafted in the open PRs below.)&lt;/p&gt;

&lt;p&gt;Both new types are Parquet &lt;em&gt;logical types&lt;/em&gt;. A logical type is a label on top of a physical storage type that says how to interpret the bytes: a string is bytes labeled as UTF-8, a date is an integer labeled as days since the epoch.&lt;/p&gt;

&lt;h3&gt;
  
  
  FILE
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;FILE&lt;/code&gt; is a column type for "bytes that live somewhere." Parquet got a &lt;code&gt;FILE&lt;/code&gt; logical type in &lt;a href="https://github.com/apache/parquet-format/pull/585" rel="noopener noreferrer"&gt;parquet-format PR 585&lt;/a&gt;, merged in July and released in &lt;a href="https://github.com/apache/parquet-format/releases/tag/apache-parquet-format-2.14.0" rel="noopener noreferrer"&gt;2.14.0&lt;/a&gt; in September. Iceberg's &lt;a href="https://github.com/apache/iceberg/issues/17919" rel="noopener noreferrer"&gt;FILE type proposal&lt;/a&gt; (Talat Uyarer, Alex Stephen, Sung Yun and Gaurav Soni; spec in &lt;a href="https://github.com/apache/iceberg/pull/17918" rel="noopener noreferrer"&gt;PR 17918&lt;/a&gt;) defines the Iceberg &lt;code&gt;file&lt;/code&gt; type by reference to it. A value is a reference with optional fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uri&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;where the bytes live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;offset&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a byte range inside that object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;what the bytes are&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checksum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integrity check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the bytes themselves, for small payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 9. Sub-fields of the proposed Iceberg &lt;code&gt;file&lt;/code&gt; type.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So a small image can sit inline inside the data file, and a large one can be a byte range inside an external object. The table carries the reference, statistics on the sub-fields, and mappings for Parquet, Avro and ORC.&lt;/p&gt;

&lt;p&gt;Whether an engine can actually go and fetch those bytes without holding cloud credentials is a separate question, handled on the catalog side with &lt;em&gt;pre-signed URLs&lt;/em&gt;: links that carry their own temporary permission. &lt;a href="https://github.com/apache/iceberg/pull/18080" rel="noopener noreferrer"&gt;PR 18080&lt;/a&gt; adds that to the REST catalog spec, and &lt;a href="https://github.com/apache/iceberg/pull/18079" rel="noopener noreferrer"&gt;PR 18079&lt;/a&gt; teaches the readers to use the links the catalog hands back (both by Sung Yun, both open). That is where the design is still moving.&lt;/p&gt;

&lt;h3&gt;
  
  
  VECTOR
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;VECTOR&lt;/code&gt; follows the same pattern, a few months behind. &lt;a href="https://github.com/apache/parquet-format/pull/624" rel="noopener noreferrer"&gt;parquet-format PR 624&lt;/a&gt; proposes a &lt;code&gt;VECTOR(D)&lt;/code&gt; logical type, meaning a list of exactly D numbers, on top of Parquet's existing list storage. Iceberg's &lt;a href="https://github.com/apache/iceberg/issues/18297" rel="noopener noreferrer"&gt;vector type proposal&lt;/a&gt; with its &lt;a href="https://github.com/apache/iceberg/pull/18416" rel="noopener noreferrer"&gt;spec PR 18416&lt;/a&gt; defines &lt;code&gt;vector(E, D)&lt;/code&gt;, a fixed-length list of D numbers of element type E, and maps it onto the three formats Iceberg has:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Iceberg v4 type&lt;/th&gt;
&lt;th&gt;Parquet&lt;/th&gt;
&lt;th&gt;Avro&lt;/th&gt;
&lt;th&gt;ORC&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vector(E, D)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parquet's standard nested list encoding, annotated &lt;code&gt;VECTOR(D)&lt;/code&gt; (per parquet-format #624); element required, Parquet mapping for &lt;code&gt;E&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;array&lt;/code&gt;, elements non-null&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;array&lt;/code&gt;, elements non-null&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;E&lt;/code&gt; in &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;long&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;; exactly &lt;code&gt;D&lt;/code&gt; non-null elements; no NaN or infinities; no type promotion to or from &lt;code&gt;vector&lt;/code&gt;; no lower/upper bounds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 10. The proposed &lt;code&gt;vector&lt;/code&gt; type and its format mappings.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the two proposals have in common
&lt;/h3&gt;

&lt;p&gt;Read those two proposals next to the registry and the shape is clear. Neither one routes embeddings or media around Parquet. Both extend Parquet to carry them, and then teach Iceberg's schema, statistics and three existing format mappings about the new type. Neither needs a new &lt;code&gt;FileFormat&lt;/code&gt;. That was the part I didn't expect going in: the announcement talks about new formats, and the multimodal column types are being written into Parquet, the format the new formats were meant to sit beside.&lt;/p&gt;

&lt;p&gt;The File Format API helps here, but less than you might hope, and it is worth being exact about how much. What the Parquet model shares across engines is the schema conversion, splits, filters, encryption and statistics. Turning a &lt;code&gt;vector&lt;/code&gt; column into a Spark row field or a Flink row field is still the job of each engine's own reader and writer function, the two functions from the registration code in "Exploring pluggability." So a new type is one change in the Parquet module plus one in each engine's value readers, not one change total. (Both spec PRs are open; if either is withdrawn, or Iceberg defines the type without reference to Parquet, this section is dated.)&lt;/p&gt;

&lt;p&gt;A useful way to hold all of this: a column type has to clear three layers before you can use it from a given engine, and the File Format API only touches the middle one. The table spec has to define it; each file format has to store it; each engine has to have somewhere to put the value. Table 11 shows four types at 1.12.0, two shipped and two proposed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Table spec&lt;/th&gt;
&lt;th&gt;Parquet&lt;/th&gt;
&lt;th&gt;Avro&lt;/th&gt;
&lt;th&gt;ORC&lt;/th&gt;
&lt;th&gt;Spark&lt;/th&gt;
&lt;th&gt;Flink&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timestamp_ns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;geometry&lt;/code&gt;, &lt;code&gt;geography&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;4.1 and later&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v4, proposed&lt;/td&gt;
&lt;td&gt;released in parquet-format 2.14.0&lt;/td&gt;
&lt;td&gt;mapping in the proposal&lt;/td&gt;
&lt;td&gt;mapping in the proposal&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v4, proposed&lt;/td&gt;
&lt;td&gt;proposed&lt;/td&gt;
&lt;td&gt;mapping in the proposal&lt;/td&gt;
&lt;td&gt;mapping in the proposal&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 11. Where four column types stand across the three layers. A "no" in the engine columns means the engine has no in-memory type to hold the value, whatever the file can store.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The nanosecond timestamp row is a keen example. The spec has it, every format stores it, and Spark's own timestamp is microseconds, so Iceberg's converter has no case for it. No change to the registry could fix that, meaning unsupported types in an engine don't suddenly become supported by this new API. Spark is in the middle of fixing it from its own side: an open proposal, &lt;a href="https://issues.apache.org/jira/browse/SPARK-56822" rel="noopener noreferrer"&gt;SPIP: Timestamps with nanosecond precision&lt;/a&gt;, adds nanosecond timestamp types to Spark SQL, the types are already on Spark's main branch, and a steady stream of PRs is teaching each function about them. Once a Spark release ships them, the Iceberg side is a converter case and a one-line removal from that exclusion set, so do expect this row to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  State of the work and future
&lt;/h2&gt;

&lt;p&gt;Everything above is a reading of the Java implementation at 1.12.0 and of spec PRs that are open on the day I'm writing. It says nothing about PyIceberg or iceberg-rust, which I didn't read, and it benchmarks nothing; the run exists to show what the registry contains and what it says when a model is missing, not how fast anything is. The Vortex PR could merge in any form, and the Java enum could grow a value before the spec sentence does, as that PR already shows. If the enum is opened in some more general way, the "formats are a project change" half of this is dated. The types half stands on its own, because the FILE and vector types are defined in Parquet regardless of what the Java registry does.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Where to look&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File Format API&lt;/td&gt;
&lt;td&gt;shipped in 1.11.0; every engine in 1.12.0 reads through it&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://iceberg.apache.org/blog/apache-iceberg-file-format-api/" rel="noopener noreferrer"&gt;announcement&lt;/a&gt;, &lt;a href="https://github.com/apache/iceberg/issues/12225" rel="noopener noreferrer"&gt;proposal&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compatibility kit&lt;/td&gt;
&lt;td&gt;"more-or-less finished" per its author; vectorized reads open&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/iceberg/issues/15415" rel="noopener noreferrer"&gt;issue 15415&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vortex as a new format&lt;/td&gt;
&lt;td&gt;open PR, adds &lt;code&gt;VORTEX&lt;/code&gt; to the enum&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/iceberg/pull/15915" rel="noopener noreferrer"&gt;PR 15915&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parquet &lt;code&gt;FILE&lt;/code&gt; type&lt;/td&gt;
&lt;td&gt;released in parquet-format 2.14.0&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/parquet-format/pull/585" rel="noopener noreferrer"&gt;PR 585&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg &lt;code&gt;file&lt;/code&gt; type (v4)&lt;/td&gt;
&lt;td&gt;open spec PR&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/apache/iceberg/issues/17919" rel="noopener noreferrer"&gt;issue 17919&lt;/a&gt;, &lt;a href="https://github.com/apache/iceberg/pull/17918" rel="noopener noreferrer"&gt;PR 17918&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-signed data access&lt;/td&gt;
&lt;td&gt;open spec and reader PRs&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/apache/iceberg/pull/18080" rel="noopener noreferrer"&gt;PR 18080&lt;/a&gt;, &lt;a href="https://github.com/apache/iceberg/pull/18079" rel="noopener noreferrer"&gt;PR 18079&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parquet &lt;code&gt;VECTOR&lt;/code&gt; type&lt;/td&gt;
&lt;td&gt;open proposal&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/parquet-format/pull/624" rel="noopener noreferrer"&gt;PR 624&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg &lt;code&gt;vector&lt;/code&gt; type (v4)&lt;/td&gt;
&lt;td&gt;open spec PR&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/apache/iceberg/issues/18297" rel="noopener noreferrer"&gt;issue 18297&lt;/a&gt;, &lt;a href="https://github.com/apache/iceberg/pull/18416" rel="noopener noreferrer"&gt;PR 18416&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table 12. Status of every moving part, as of writing.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping up with the Joneses: What your next step is
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have images or embeddings in Iceberg today.&lt;/strong&gt; Keep the reference as a string and the embedding as a list, which the current readers handle, and plan a schema migration to &lt;code&gt;file&lt;/code&gt; and &lt;code&gt;vector&lt;/code&gt; when v4 is adopted rather than a storage migration to another format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to follow the decisions.&lt;/strong&gt; Watch the two Iceberg spec PRs, the Parquet VECTOR PR, and the two pre-signing PRs, because that is where the capability is being decided.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are an engine author with your own row type.&lt;/strong&gt; Register it. The API is for you, it works, and the run above is the whole recipe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a new format in Iceberg.&lt;/strong&gt; Start from the TCK issue and the Vortex PR, and budget for it as a contribution to the project, because that is what it is.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://iceberg.apache.org/blog/apache-iceberg-file-format-api/" rel="noopener noreferrer"&gt;Introducing the Apache Iceberg File Format API&lt;/a&gt;, the announcement, and the &lt;a href="https://github.com/apache/iceberg/issues/12225" rel="noopener noreferrer"&gt;proposal issue&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/apache/iceberg/issues/17919" rel="noopener noreferrer"&gt;FILE type proposal&lt;/a&gt; and &lt;a href="https://github.com/apache/iceberg/pull/17918" rel="noopener noreferrer"&gt;spec PR&lt;/a&gt;; &lt;a href="https://github.com/apache/parquet-format/pull/585" rel="noopener noreferrer"&gt;parquet-format FILE&lt;/a&gt;; pre-signed data access &lt;a href="https://github.com/apache/iceberg/pull/18080" rel="noopener noreferrer"&gt;PR 18080&lt;/a&gt; and &lt;a href="https://github.com/apache/iceberg/pull/18079" rel="noopener noreferrer"&gt;PR 18079&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/apache/iceberg/issues/18297" rel="noopener noreferrer"&gt;Vector type proposal&lt;/a&gt; and &lt;a href="https://github.com/apache/iceberg/pull/18416" rel="noopener noreferrer"&gt;spec PR&lt;/a&gt;; &lt;a href="https://github.com/apache/parquet-format/pull/624" rel="noopener noreferrer"&gt;parquet-format VECTOR&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/apache/iceberg/pull/15915" rel="noopener noreferrer"&gt;Vortex integration PR&lt;/a&gt; and the &lt;a href="https://github.com/apache/iceberg/issues/15415" rel="noopener noreferrer"&gt;TCK issue&lt;/a&gt;; the kit itself is &lt;a href="https://github.com/apache/iceberg/blob/apache-iceberg-1.12.0/data/src/test/java/org/apache/iceberg/data/ReadFormatModelTests.java" rel="noopener noreferrer"&gt;&lt;code&gt;ReadFormatModelTests&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/apache/iceberg/blob/apache-iceberg-1.12.0/data/src/test/java/org/apache/iceberg/data/BaseFormatModelTests.java" rel="noopener noreferrer"&gt;&lt;code&gt;BaseFormatModelTests&lt;/code&gt;&lt;/a&gt;, first merged in &lt;a href="https://github.com/apache/iceberg/pull/15441" rel="noopener noreferrer"&gt;PR 15441&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The run: Iceberg 1.12.0, &lt;code&gt;iceberg-core&lt;/code&gt; + &lt;code&gt;iceberg-data&lt;/code&gt; + &lt;code&gt;iceberg-parquet&lt;/code&gt;, Java 17; the full program and its output are in the Usage section above&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>iceberg</category>
      <category>parquet</category>
      <category>multimodal</category>
      <category>lakehouse</category>
    </item>
    <item>
      <title>An Idiot's Guide to Spark Connect Clients</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:20:55 +0000</pubDate>
      <link>https://dev.to/lisancao/an-idiots-guide-to-spark-connect-clients-24h8</link>
      <guid>https://dev.to/lisancao/an-idiots-guide-to-spark-connect-clients-24h8</guid>
      <description>&lt;p&gt;&lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Spark Connect&lt;/a&gt; is a new concept for many of us, especially given the long history of how managing Spark clusters has traditionally worked. Spark Connect in a nutshell separates your application from Spark: your code becomes a small client, Spark runs as a server somewhere else, and the two talk over the network. The primary benefit of Connect for most users is getting rid of the headaches around &lt;code&gt;SparkContext&lt;/code&gt;, installing a package from your notebook, your Python version not mattering, or a typo failing on the line you typed it, which Connect very kindly obfuscates for us. Honestly, it's a pretty overdue upgrade for Spark as an engine and a pretty intuitive direction: decouple client sessions from the server, and make the server and its configuration as dead easy as possible for your customers.&lt;/p&gt;

&lt;p&gt;The mindset shift away from having your PySpark setup share the same process as your Spark driver is not a super hard jump, but it does mean there are some adjustments to make in how you set things like configurations or write jobs. This becomes even more intense once you get into the meat of Spark Connect, which is the language agnosticism and running multiple clients and servers, particularly for those of us dabbling in Rust or Go.&lt;/p&gt;

&lt;p&gt;So this guide, written for idiots by an idiot, starts with a tour of how Spark itself works and how that changes with a Connect setup. From there it's practical: how to run a server, how to connect to it from Python and other languages, what changes in your code, and how to do some basic debugging. Note that after the inclusion of session tracing in 4.3, and hopefully an eventual Spark Connect Gateway, the latter will be much, much easier.&lt;/p&gt;

&lt;p&gt;Every example comes from &lt;a href="https://github.com/lisancao/spark-demos/tree/main/demos/02_spark_connect" rel="noopener noreferrer"&gt;spark-demos&lt;/a&gt;, where each one is a runnable script, and I ran every snippet here against Spark 4.2.0, the current release.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Spark works
&lt;/h2&gt;

&lt;p&gt;Spark lets you write code as if you were working with one table on one machine, and then runs it across however many machines you have; Connect's whole trick is moving part of the machinery that makes that possible somewhere else, so it's worth having a picture of that machinery first. A running Spark application has a &lt;a href="https://spark.apache.org/docs/latest/cluster-overview.html" rel="noopener noreferrer"&gt;few kinds of pieces&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The driver&lt;/strong&gt; is the brain. It holds your &lt;code&gt;SparkSession&lt;/code&gt;, works out what your code actually means, decides how to run it, and coordinates everything else. Inside it:

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;analyzer&lt;/strong&gt;, which resolves the tables and columns you named, using the &lt;strong&gt;catalog&lt;/strong&gt; (the registry of what tables exist and what's in them);&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;&lt;a href="https://www.databricks.com/glossary/catalyst-optimizer" rel="noopener noreferrer"&gt;Catalyst optimizer&lt;/a&gt;&lt;/strong&gt;, which rewrites your query into a cheaper equivalent (skipping columns you never use, filtering before joining, and so on);&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;planner&lt;/strong&gt;, which turns the optimized query into physical operations;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;schedulers&lt;/strong&gt;, which cut that physical plan into &lt;strong&gt;stages&lt;/strong&gt; (at every point where data has to be reshuffled between machines) and each stage into &lt;strong&gt;tasks&lt;/strong&gt;, one per slice (&lt;strong&gt;partition&lt;/strong&gt;) of the data.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cluster manager&lt;/strong&gt; hands out machines: Spark's own standalone master, YARN, or Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers&lt;/strong&gt; are the machines (or containers) the cluster manager gives you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Executors&lt;/strong&gt; are the processes on those workers that actually run tasks, read and write data, hold cached data, and exchange data with each other during a shuffle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBkcnYgZmlsbDojYzA2NjFhLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojYzA2NjFhLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY20gZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZXggZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggYTFfRFtcImRyaXZlclwiXVxuICAgIGExX1NTW1wiU3BhcmtTZXNzaW9uXCJdOjo6ZHJ2IC0tPiBhMV9BTltcImFuYWx5emVyICsgY2F0YWxvZ1wiXTo6OmRydlxuICAgIGExX0FOIC0tPiBhMV9DQVRbXCJDYXRhbHlzdCBvcHRpbWl6ZXJcIl06OjpkcnZcbiAgICBhMV9DQVQgLS0-IGExX1NDSFtcInBsYW5uZXIgKyBzY2hlZHVsZXJzXCJdOjo6ZHJ2XG4gIGVuZFxuICBhMV9TQ0ggLS0-fFwiYXNrcyBmb3IgcmVzb3VyY2VzXCJ8IGExX0NNW1wiY2x1c3RlciBtYW5hZ2VyXCJdOjo6Y21cbiAgYTFfU0NIIC0tPnxcInNlbmRzIHRhc2tzXCJ8IGExX0UxW1wiZXhlY3V0b3I8YnIvPihvbiBhIHdvcmtlcilcIl06OjpleFxuICBhMV9TQ0ggLS0-fFwic2VuZHMgdGFza3NcInwgYTFfRTJbXCJleGVjdXRvcjxici8-KG9uIGEgd29ya2VyKVwiXTo6OmV4XG4gIGExX0UxIDwtLT58XCJzaHVmZmxlXCJ8IGExX0UyXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBkcnYgZmlsbDojYzA2NjFhLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojYzA2NjFhLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY20gZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZXggZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggYTFfRFtcImRyaXZlclwiXVxuICAgIGExX1NTW1wiU3BhcmtTZXNzaW9uXCJdOjo6ZHJ2IC0tPiBhMV9BTltcImFuYWx5emVyICsgY2F0YWxvZ1wiXTo6OmRydlxuICAgIGExX0FOIC0tPiBhMV9DQVRbXCJDYXRhbHlzdCBvcHRpbWl6ZXJcIl06OjpkcnZcbiAgICBhMV9DQVQgLS0-IGExX1NDSFtcInBsYW5uZXIgKyBzY2hlZHVsZXJzXCJdOjo6ZHJ2XG4gIGVuZFxuICBhMV9TQ0ggLS0-fFwiYXNrcyBmb3IgcmVzb3VyY2VzXCJ8IGExX0NNW1wiY2x1c3RlciBtYW5hZ2VyXCJdOjo6Y21cbiAgYTFfU0NIIC0tPnxcInNlbmRzIHRhc2tzXCJ8IGExX0UxW1wiZXhlY3V0b3I8YnIvPihvbiBhIHdvcmtlcilcIl06OjpleFxuICBhMV9TQ0ggLS0-fFwic2VuZHMgdGFza3NcInwgYTFfRTJbXCJleGVjdXRvcjxici8-KG9uIGEgd29ya2VyKVwiXTo6OmV4XG4gIGExX0UxIDwtLT58XCJzaHVmZmxlXCJ8IGExX0UyXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="499" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Following one line of code through all of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Your code describes the query: filter, group, count&lt;/td&gt;
&lt;td&gt;your program&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;That becomes an &lt;em&gt;unresolved logical plan&lt;/em&gt;: a tree of operations naming tables and columns that haven't been checked yet&lt;/td&gt;
&lt;td&gt;the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;The analyzer checks &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt; and &lt;code&gt;region&lt;/code&gt; against the catalog and fills in types&lt;/td&gt;
&lt;td&gt;the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Catalyst optimizes the plan (here it reads only the two columns it needs)&lt;/td&gt;
&lt;td&gt;the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;The planner picks physical operators and generates code for them&lt;/td&gt;
&lt;td&gt;the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;The schedulers split the work into stages (before and after the shuffle &lt;code&gt;groupBy&lt;/code&gt; needs) and tasks&lt;/td&gt;
&lt;td&gt;the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Executors run the tasks against their slices of the data&lt;/td&gt;
&lt;td&gt;the executors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;The counts come back to the driver, and on to you&lt;/td&gt;
&lt;td&gt;the driver, then your program&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing actually runs until step 8 is needed; until you call something that wants a result (&lt;code&gt;show()&lt;/code&gt;, &lt;code&gt;count()&lt;/code&gt;, writing a table), Spark is only building the plan, which is what people mean by lazy evaluation. Classic Spark does run step 3 straight away (it checks your names the moment you define a DataFrame), and that small difference ends up mattering a lot once Connect is involved, so keep it in your back pocket for the debugging section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The detail that matters most for this guide is where the driver runs.&lt;/strong&gt; In classic PySpark (in the default "client" mode) the driver runs inside your program: your Python process starts a Java virtual machine next to it and talks to it through a bridge called &lt;a href="https://www.py4j.org/" rel="noopener noreferrer"&gt;Py4J&lt;/a&gt;, and that JVM &lt;em&gt;is&lt;/em&gt; the driver, with the analyzer, the catalog connection, Catalyst and the schedulers all living in it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZHJ2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGEyX1BbXCJ5b3VyIHByb2Nlc3NcIl1cbiAgICBhMl9QWVtcInlvdXIgUHl0aG9uIGNvZGVcIl06Ojp5b3UgPC0tPnxcIlB5NEpcInwgYTJfSlZNW1wiZHJpdmVyIEpWTTxici8-YW5hbHl6ZXIgXHUwMGI3IENhdGFseXN0IFx1MDBiNzxici8-c2NoZWR1bGVyc1wiXTo6OmRydlxuICBlbmRcbiAgYTJfSlZNIC0tPiBhMl9FMVtcImV4ZWN1dG9yXCJdOjo6ZXhcbiAgYTJfSlZNIC0tPiBhMl9FMltcImV4ZWN1dG9yXCJdOjo6ZXhcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZHJ2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGEyX1BbXCJ5b3VyIHByb2Nlc3NcIl1cbiAgICBhMl9QWVtcInlvdXIgUHl0aG9uIGNvZGVcIl06Ojp5b3UgPC0tPnxcIlB5NEpcInwgYTJfSlZNW1wiZHJpdmVyIEpWTTxici8-YW5hbHl6ZXIgXHUwMGI3IENhdGFseXN0IFx1MDBiNzxici8-c2NoZWR1bGVyc1wiXTo6OmRydlxuICBlbmRcbiAgYTJfSlZNIC0tPiBhMl9FMVtcImV4ZWN1dG9yXCJdOjo6ZXhcbiAgYTJfSlZNIC0tPiBhMl9FMltcImV4ZWN1dG9yXCJdOjo6ZXhcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 2" width="708" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your code and Spark's brain share a process, a machine, a set of installed libraries, and a fate, such that if one of them crashes it takes the other down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the shared driver costs you
&lt;/h2&gt;

&lt;p&gt;Sharing a process is fine when it's one person on one laptop, and it causes a surprising number of problems as soon as more than one person (or more than one program) uses the same Spark:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need Java and all of Spark on every machine that runs your code&lt;/strong&gt;, including your laptop, a CI runner, or a web service that just wants to run one query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your application and Spark are upgraded together.&lt;/strong&gt; Upgrading Spark means changing the environment every job runs in, whether the job was ready or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everyone's code shares the driver.&lt;/strong&gt; One badly behaved notebook can take the driver down for everyone attached to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only JVM languages and Python can talk to Spark&lt;/strong&gt;, because they're the ones that can live inside or next to the JVM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials go everywhere the driver goes&lt;/strong&gt;, so catalog passwords and storage keys end up on every machine that runs a job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spark Connect fixes all of these with one change, which is that &lt;strong&gt;it splits the driver in two.&lt;/strong&gt; The part you write against, the DataFrame API, stays in your program; everything that thinks (the session, the analyzer, the catalog, Catalyst, the schedulers) moves into a long-running &lt;strong&gt;Spark Connect server&lt;/strong&gt;, with a network connection in between. Your program builds step 2, the unresolved plan, as a small &lt;a href="https://protobuf.dev/" rel="noopener noreferrer"&gt;protobuf&lt;/a&gt; message, sends it over &lt;a href="https://grpc.io/" rel="noopener noreferrer"&gt;gRPC&lt;/a&gt; and gets results back as &lt;a href="https://arrow.apache.org/" rel="noopener noreferrer"&gt;Arrow&lt;/a&gt;, and steps 3 to 8 all happen on the server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZHJ2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGEzX0NbXCJjbGllbnRcIl1cbiAgICBhM19BUFBbXCJ5b3VyIGNvZGU8YnIvPkRhdGFGcmFtZSBBUElcIl06Ojp5b3VcbiAgZW5kXG4gIHN1YmdyYXBoIGEzX1NbXCJzZXJ2ZXJcIl1cbiAgICBhM19EUlZbXCJkcml2ZXI8YnIvPnNlc3Npb24gXHUwMGI3IGFuYWx5emVyIFx1MDBiNzxici8-Q2F0YWx5c3QgXHUwMGI3IHNjaGVkdWxlcnNcIl06OjpkcnZcbiAgZW5kXG4gIGEzX0FQUCAtLT58XCJwbGFuIG92ZXIgZ1JQQ1wifCBhM19EUlZcbiAgYTNfRFJWIC0tPnxcIkFycm93XCJ8IGEzX0FQUFxuICBhM19EUlYgLS0-IGEzX0UxW1wiZXhlY3V0b3JcIl06OjpleFxuICBhM19EUlYgLS0-IGEzX0UyW1wiZXhlY3V0b3JcIl06OjpleFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiB5b3UgZmlsbDojOWE3YjAwLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojOWE3YjAwLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgZHJ2IGZpbGw6I2MwNjYxYSxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6I2MwNjYxYSxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIGV4IGZpbGw6IzNhNWY5NixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzNhNWY5Nixjb2xvcjojZmZmZmZmXG4gIHN1YmdyYXBoIGEzX0NbXCJjbGllbnRcIl1cbiAgICBhM19BUFBbXCJ5b3VyIGNvZGU8YnIvPkRhdGFGcmFtZSBBUElcIl06Ojp5b3VcbiAgZW5kXG4gIHN1YmdyYXBoIGEzX1NbXCJzZXJ2ZXJcIl1cbiAgICBhM19EUlZbXCJkcml2ZXI8YnIvPnNlc3Npb24gXHUwMGI3IGFuYWx5emVyIFx1MDBiNzxici8-Q2F0YWx5c3QgXHUwMGI3IHNjaGVkdWxlcnNcIl06OjpkcnZcbiAgZW5kXG4gIGEzX0FQUCAtLT58XCJwbGFuIG92ZXIgZ1JQQ1wifCBhM19EUlZcbiAgYTNfRFJWIC0tPnxcIkFycm93XCJ8IGEzX0FQUFxuICBhM19EUlYgLS0-IGEzX0UxW1wiZXhlY3V0b3JcIl06OjpleFxuICBhM19EUlYgLS0-IGEzX0UyW1wiZXhlY3V0b3JcIl06OjpleFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 3" width="828" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Piece by piece, this is what moves and what stays put:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Classic PySpark&lt;/th&gt;
&lt;th&gt;Spark Connect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your code and the DataFrame API&lt;/td&gt;
&lt;td&gt;your process&lt;/td&gt;
&lt;td&gt;your process (the client)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Building the unresolved plan&lt;/td&gt;
&lt;td&gt;the driver JVM, via Py4J&lt;/td&gt;
&lt;td&gt;the client, as a protobuf message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SparkSession&lt;/code&gt; and its state (temp views, settings, artifacts)&lt;/td&gt;
&lt;td&gt;the driver, in your process&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyzer and catalog connection&lt;/td&gt;
&lt;td&gt;the driver, in your process&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalyst, physical planning, code generation&lt;/td&gt;
&lt;td&gt;the driver, in your process&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage and task scheduling&lt;/td&gt;
&lt;td&gt;the driver, in your process&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster manager, workers, executors&lt;/td&gt;
&lt;td&gt;the cluster&lt;/td&gt;
&lt;td&gt;the cluster, &lt;strong&gt;unchanged&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Results&lt;/td&gt;
&lt;td&gt;Py4J and a local socket into your process&lt;/td&gt;
&lt;td&gt;Arrow batches over gRPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog and storage credentials&lt;/td&gt;
&lt;td&gt;wherever the driver runs&lt;/td&gt;
&lt;td&gt;the server only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cluster itself doesn't change at all, and executors run tasks exactly as they did before. What changes is that your program no longer contains a driver, so it doesn't need Java, it can be written in any language that speaks gRPC and Arrow, and it can be upgraded on its own schedule rather than the server's. Connect first appeared in Spark 3.4, and since 4.0 the server has shipped in the standard Spark download.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pretty much every confusing thing about Connect is this split showing up somewhere you didn't expect it.&lt;/strong&gt; &lt;code&gt;explain()&lt;/code&gt; shows you a plan your laptop didn't make, because Catalyst is on the server; a typo in a column name fails late, because the analyzer is on the server and only sees your plan once you ask for a result; &lt;code&gt;sparkContext&lt;/code&gt; is gone, because it belonged to the driver. Anything that used to live in your program (the JVM, your files, your Python version, your credentials) now lives on the other side of a network connection, and most of the rest of this guide is working through the consequences of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting from Python
&lt;/h2&gt;

&lt;p&gt;From Python it's two steps: install the thin client (&lt;a href="https://pypi.org/project/pyspark-client/" rel="noopener noreferrer"&gt;&lt;code&gt;pyspark-client&lt;/code&gt;&lt;/a&gt;),&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pyspark-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and create your session with &lt;code&gt;.remote(...)&lt;/code&gt; instead of &lt;code&gt;.master(...)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://localhost:15002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# the *server's* Spark version
&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pizza&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;24.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sushi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urban&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pizza&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region string, item string, total double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after the session line is ordinary PySpark, the same code you'd run in classic mode. Your program builds the plan and the server optimizes and runs it, so if you call &lt;code&gt;.explain()&lt;/code&gt; you're reading the server's plan (your laptop doesn't have an optimizer anymore).&lt;/p&gt;

&lt;h3&gt;
  
  
  Before and after: setting up a session
&lt;/h3&gt;

&lt;p&gt;A real classic session usually does a lot more than pick a master; it pulls in packages, sets SQL extensions, turns on Hive support and quiets the logs. A typical one looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: Spark Classic. Everything is configured by the program that runs the driver.
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;master&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark://my-cluster:7077&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.jars.packages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.shuffle.partitions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enableHiveSupport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sparkContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLogLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WARN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under Connect that one block splits in two, because some of those settings describe the &lt;em&gt;driver&lt;/em&gt;, and the driver isn't yours anymore. Anything the server reads once when it starts (packages and JARs, &lt;code&gt;spark.sql.extensions&lt;/code&gt;, catalog implementations, Hive support) moves to the command that starts the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After, part 1: the server owns its JVM, so it gets the packages and static settings.&lt;/span&gt;
./sbin/start-connect-server.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--master&lt;/span&gt; spark://my-cluster:7077 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--packages&lt;/span&gt; org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the client keeps only what can change per session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# After, part 2: the client names the server and sets runtime options.
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://localhost:15002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.shuffle.partitions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# runtime settings still work from the client
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log level moves to the server as well (its &lt;code&gt;conf/log4j2.properties&lt;/code&gt;), since the logs are the server's now. The sneaky part is that Connect doesn't stop you from setting a startup-only setting from the client; it warns and carries on without it. Asking for a package from the client gets you this, and no package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UserWarning: Failed to set spark.jars.packages to Some(org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0)
due to [CANNOT_MODIFY_CONFIG] Cannot modify the value of the Spark config ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enableHiveSupport()&lt;/code&gt; does the same thing (with &lt;code&gt;CANNOT_MODIFY_STATIC_CONFIG&lt;/code&gt;) and hands you a session without Hive, which you might not notice until a table you expected isn't there. The rule of thumb I've landed on is that if a setting describes how the server starts, it belongs wherever the server starts.&lt;/p&gt;

&lt;p&gt;You can also leave the URL out of your code entirely by setting &lt;code&gt;SPARK_REMOTE&lt;/code&gt;, and a plain &lt;code&gt;SparkSession.builder.getOrCreate()&lt;/code&gt; will connect to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sc://localhost:15002"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Before and after: the rest of your code
&lt;/h3&gt;

&lt;p&gt;Most DataFrame code doesn't change at all. What does change is anything that reached into the driver, and accumulators are a good example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: an accumulator, counted on the executors and read on the driver
&lt;/span&gt;&lt;span class="n"&gt;bad_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sparkContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accumulator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foreach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bad_rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad_rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# After: an Observation, collected alongside a query you were running anyway
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Observation&lt;/span&gt;

&lt;span class="n"&gt;obs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Observation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bad_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overwrite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;saveAsTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_checked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bad_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd argue the &lt;a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.Observation.html" rel="noopener noreferrer"&gt;Observation&lt;/a&gt; is the better pattern anyway, since the metric rides along with a write you were already doing instead of needing its own pass over the data. The rest of the common swaps, side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before (Classic)&lt;/th&gt;
&lt;th&gt;After (Connect)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sparkContext.setLogLevel("WARN")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;set the level in the server's &lt;code&gt;conf/log4j2.properties&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;df.rdd.zipWithIndex()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;df.zipWithIndex()&lt;/code&gt; (4.2+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.read.json(rdd_of_strings)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark.read.json(df)&lt;/code&gt; on a string column (4.2+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sc.accumulator(0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;df.observe(Observation(...), ...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sc.addPyFile("lib/geo.py")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spark.addArtifact("lib/geo.py", pyfile=True)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sc.broadcast(lookup)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a broadcast join, &lt;code&gt;F.broadcast(lookup_df)&lt;/code&gt;, or a literal column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.newSession()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SparkSession.builder.remote(url).create()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark-submit job.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;python job.py&lt;/code&gt; with &lt;code&gt;SPARK_REMOTE&lt;/code&gt; set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of that assumes there's a server sitting at &lt;code&gt;localhost:15002&lt;/code&gt;, which brings us to running one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running a Connect server
&lt;/h2&gt;

&lt;p&gt;There are three ways to get one, roughly in order of effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Inside your own Python process, for trying things out.&lt;/strong&gt; Full PySpark can start a Connect server in-process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local[*]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This needs full &lt;code&gt;pyspark&lt;/code&gt; (not &lt;code&gt;pyspark-client&lt;/code&gt;) and Java installed, because the server still has to run somewhere; it's the Connect code path without a separate server, which makes it great for tests and for learning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. From a Spark download, on your machine or a server.&lt;/strong&gt; Download Spark from &lt;a href="https://spark.apache.org/downloads.html" rel="noopener noreferrer"&gt;spark.apache.org&lt;/a&gt; (you'll need Java 17 or newer) and start the server that ships with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./sbin/start-connect-server.sh        &lt;span class="c"&gt;# listens on port 15002&lt;/span&gt;
./sbin/stop-connect-server.sh         &lt;span class="c"&gt;# when you're done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default it runs Spark locally on that one machine; to have it run jobs on a cluster instead, point it at the cluster's master:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./sbin/start-connect-server.sh &lt;span class="nt"&gt;--master&lt;/span&gt; spark://my-cluster:7077
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing that surprised me is that &lt;strong&gt;by default the server listens on every network interface&lt;/strong&gt;, so anything that can reach your machine can reach the server the moment it starts. On a laptop on shared Wi-Fi, restrict it with &lt;code&gt;--conf spark.connect.grpc.binding.address=127.0.0.1&lt;/code&gt;. (You'll see &lt;code&gt;spark.connect.grpc.binding.host&lt;/code&gt; in plenty of examples, including some of mine; it isn't a real setting, and Spark silently ignores it.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. In Docker.&lt;/strong&gt; The official &lt;a href="https://hub.docker.com/r/apache/spark" rel="noopener noreferrer"&gt;&lt;code&gt;apache/spark&lt;/code&gt;&lt;/a&gt; image includes Spark, Java and Python; add PyArrow and pandas on top, since Python UDFs use Arrow by default as of Spark 4.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; apache/spark:4.2.0-scala2.13-java21-python3-ubuntu&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; root&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; pyarrow pandas
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; spark&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the server with &lt;code&gt;--wait&lt;/code&gt;, which keeps it in the foreground (what a container needs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spark-connect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/sbin/start-connect-server.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--wait&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:15002:15002"&lt;/span&gt;   &lt;span class="c1"&gt;# Spark Connect&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:4040:4040"&lt;/span&gt;     &lt;span class="c1"&gt;# Spark UI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image's Python is 3.10, which matters for your client (more on that below).&lt;/p&gt;

&lt;p&gt;For production, Connect servers also run on Kubernetes (the &lt;a href="https://github.com/apache/spark-kubernetes-operator" rel="noopener noreferrer"&gt;Apache Spark Kubernetes Operator&lt;/a&gt; deploys one as an ordinary Spark application) and, since Spark 4.2, inside YARN. The repo's &lt;a href="https://github.com/lisancao/spark-demos/blob/main/demos/02_spark_connect/setup/TOPOLOGIES.md" rel="noopener noreferrer"&gt;topology guide&lt;/a&gt; walks through six ways to deploy one and when each makes sense.&lt;/p&gt;

&lt;p&gt;Before you share a server with other people, know that &lt;strong&gt;a single server holds many sessions&lt;/strong&gt;, each isolated from the others but all sharing the server's memory and executors; if people need stronger isolation than that, they need separate servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running a job
&lt;/h2&gt;

&lt;p&gt;There's no separate "submit" step for a Connect job, because &lt;strong&gt;your program is the job&lt;/strong&gt;, and running it sends the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sc://localhost:15002"&lt;/span&gt;
python my_pipeline.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;SparkSession.builder.getOrCreate()&lt;/code&gt; picks up &lt;code&gt;SPARK_REMOTE&lt;/code&gt; from the environment, so the same script runs against whichever server the variable names.) A few other routes are worth knowing about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An existing job, almost unchanged.&lt;/strong&gt; &lt;code&gt;spark.api.mode&lt;/code&gt; switches a classic job to Connect: Spark starts a Connect server for the job and runs your code as its client. Set it in the session builder and keep your existing &lt;code&gt;master&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;master&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark://my-cluster:7077&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.api.mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the gentlest way to try Connect on real work, since you can move one job, watch it, and move it back if something breaks. The documentation also shows passing it to &lt;code&gt;spark-submit&lt;/code&gt; as &lt;code&gt;--conf spark.api.mode=connect&lt;/code&gt;; on 4.2.0 that failed for me with &lt;code&gt;TypeError: 'JavaPackage' object is not callable&lt;/code&gt; while the builder setting worked, so for now, set it in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;a href="https://spark.apache.org/docs/latest/declarative-pipelines-programming-guide.html" rel="noopener noreferrer"&gt;declarative pipeline&lt;/a&gt;.&lt;/strong&gt; Tell the &lt;code&gt;spark-pipelines&lt;/code&gt; CLI which server to use with &lt;code&gt;--remote&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;spark-pipelines run &lt;span class="nt"&gt;--spec&lt;/span&gt; spark-pipeline.yml &lt;span class="nt"&gt;--remote&lt;/span&gt; sc://localhost:15002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't rely on &lt;code&gt;SPARK_REMOTE&lt;/code&gt; here; the CLI ignores it and quietly starts a local server of its own. The &lt;code&gt;spark-pipelines&lt;/code&gt; command comes with full PySpark, and with only the thin client it's the other way round: run &lt;code&gt;python -m pyspark.pipelines.cli run --spec spark-pipeline.yml&lt;/code&gt;, which has no &lt;code&gt;--remote&lt;/code&gt; option and connects to whatever &lt;code&gt;SPARK_REMOTE&lt;/code&gt; names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code your job depends on.&lt;/strong&gt; If your functions import your own modules, the server doesn't have them, so ship them with the session using &lt;a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.SparkSession.addArtifact.html" rel="noopener noreferrer"&gt;&lt;code&gt;addArtifact&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addArtifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lib/geo.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pyfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# importable by your UDFs
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addArtifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.tar.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# unpacked on the executors
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addArtifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lookup.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# copied as is
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JARs for catalogs and extensions are the exception; those have to be installed on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need locally
&lt;/h2&gt;

&lt;p&gt;Much less than before, which is kind of the point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need&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;Python, &lt;strong&gt;the same minor version as the server's&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Python functions you send run in the server's Python. The 4.2 client needs Python 3.10 or newer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark-client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The thin client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Its dependencies: pyarrow, pandas, numpy, grpcio&lt;/td&gt;
&lt;td&gt;They come with it; this is most of the install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; Java, no Spark download, no &lt;code&gt;SPARK_HOME&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;That all lives on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thing actually worth getting right is which package you install, because there are four with nearly identical names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What you actually get&lt;/th&gt;
&lt;th&gt;Java needed locally?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full PySpark: about 470 MB, most of it some 280 JAR files&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark[connect]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full PySpark plus the Connect extras&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark-connect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A tiny package that installs full &lt;code&gt;pyspark&lt;/code&gt;&lt;/strong&gt; (and makes Connect its default)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark-client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The thin client: about 15 MB, no JARs&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;pyspark-connect&lt;/code&gt; sounds like the Connect client and isn't; the thin client is &lt;code&gt;pyspark-client&lt;/code&gt;. Don't judge by the size of your environment either: with its dependencies a &lt;code&gt;pyspark-client&lt;/code&gt; environment is about 330 MB against about 780 MB for &lt;code&gt;pyspark[connect]&lt;/code&gt;, because pyarrow, pandas and numpy are most of it, so &lt;strong&gt;what the thin client really saves you is Java, not disk space.&lt;/strong&gt; To check which one you actually have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;importlib.util&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pyspark&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pyspark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# thin client: False
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;py4j&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# thin client: False
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If either prints &lt;code&gt;True&lt;/code&gt;, you're on full PySpark. And if you have a full Spark installed elsewhere on the machine, clear &lt;code&gt;SPARK_HOME&lt;/code&gt; and &lt;code&gt;PYTHONPATH&lt;/code&gt; before you start, or they can quietly shadow the thin client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clients in other languages
&lt;/h2&gt;

&lt;p&gt;Because the client only has to build a plan and read Arrow, it doesn't have to be Python, or even a JVM language, and the same server takes all of these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Latest release&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pyspark-client&lt;/code&gt; (in &lt;code&gt;apache/spark&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scala and Java&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark-connect-client-jvm&lt;/code&gt; (in &lt;code&gt;apache/spark&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/spark-connect-go" rel="noopener noreferrer"&gt;&lt;code&gt;apache/spark-connect-go&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;v0.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/spark-connect-swift" rel="noopener noreferrer"&gt;&lt;code&gt;apache/spark-connect-swift&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/apache/spark-connect-rust" rel="noopener noreferrer"&gt;&lt;code&gt;apache/spark-connect-rust&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything with JDBC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spark-connect-client-jdbc&lt;/code&gt;, URL &lt;code&gt;jdbc:sc://host:15002&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;since 4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They all start the same way, with a connection string. In Scala:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="nv"&gt;spark&lt;/span&gt; &lt;span class="k"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;SparkSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="py"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sc://localhost:15002"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;getOrCreate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;span class="nv"&gt;spark&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;range&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id % 2 = 0"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;show&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSessionBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sc://localhost:15002"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT 'apple' AS word, 123 AS count"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(At the time of writing, the Go client's only release, v0.1.0, can't be fetched with &lt;code&gt;go get&lt;/code&gt; because of a module-path mismatch; &lt;code&gt;go get github.com/apache/spark-connect-go@master&lt;/code&gt; works.)&lt;/p&gt;

&lt;p&gt;Rust is where it gets fun for me, because results arrive as Arrow and can go straight into a native Rust tool. This program from the repo has Spark aggregate five million rows down to seven and then hands them to &lt;a href="https://pola.rs/" rel="noopener noreferrer"&gt;Polars&lt;/a&gt; for local work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sc://localhost:15002"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.get_or_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.expression&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// aggregate expressions, unwrapped&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;
    &lt;span class="nf"&gt;.range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.with_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;.group_by&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="nf"&gt;.agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;f&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;))]);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="nf"&gt;.collect_record_batches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Arrow RecordBatches, ready for Polars&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That division of labour, with Spark doing the part that needs a cluster and your language of choice doing the rest, is the part of Connect I find most interesting. The clients aren't equally complete yet, though (the Python and JVM ones cover the most, the newer ones less), so check that the one you pick supports what you need before you build on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging
&lt;/h2&gt;

&lt;p&gt;Most Connect problems come from forgetting that there are now two sides, so when something goes wrong I work through these in roughly this order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Am I talking to the server I think I am?&lt;/strong&gt; Print &lt;code&gt;spark.version&lt;/code&gt; first, every time, since nothing in &lt;code&gt;sc://localhost:15002&lt;/code&gt; says which Spark is supposed to answer. I learned this one the hard way: an old Spark 4.1 server was still sitting on port 15002 on my machine when I pointed newer tools at it, and it answered, misunderstood a request it had never heard of, and gave me an error convincing enough that I wrote it up as a Spark bug. (The full story is in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/lakehouse-at-home-demos" rel="noopener noreferrer"&gt;Hosting an Entire Open Lakehouse on Port 8080&lt;/a&gt;.) Two related traps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://server-a:15002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sc://server-b:15002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;    &lt;span class="c1"&gt;# getOrCreate() returned the existing session; use .create() for a new one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and connection strings that look fine but aren't; the part after the port has to be empty, and parameter values can't contain &lt;code&gt;/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sc://localhost:15002/;user_agent=myapp/setup
PySparkValueError: [INVALID_CONNECT_URL] ... The path component '/;user_agent=myapp/setup' must be empty.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Read the error's name.&lt;/strong&gt; Connect errors carry an &lt;a href="https://spark.apache.org/docs/latest/sql-error-conditions.html" rel="noopener noreferrer"&gt;error class&lt;/a&gt; in square brackets, and a handful of them cover most of what you'll hit early on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JVM_ATTRIBUTE_NOT_SUPPORTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You used something that lived in the local JVM: &lt;code&gt;spark.sparkContext&lt;/code&gt; (and so everything reached through it, like &lt;code&gt;sparkContext.setLogLevel&lt;/code&gt;), &lt;code&gt;_jvm&lt;/code&gt;, &lt;code&gt;df.rdd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use the DataFrame replacement: &lt;code&gt;df.zipWithIndex()&lt;/code&gt;, &lt;code&gt;spark.read.json(df)&lt;/code&gt;, &lt;code&gt;df.observe(...)&lt;/code&gt; for counters, &lt;code&gt;addArtifact&lt;/code&gt; for files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PYTHON_VERSION_MISMATCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Python and the server's differ (e.g. 3.12 vs 3.10)&lt;/td&gt;
&lt;td&gt;Match your Python minor version to the server's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ModuleNotFoundError&lt;/code&gt; inside a UDF&lt;/td&gt;
&lt;td&gt;The server's Python is missing a package your function needs&lt;/td&gt;
&lt;td&gt;Ship it with &lt;code&gt;addArtifact&lt;/code&gt;, or install it on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNSUPPORTED_LOCAL_CONNECTION_STRING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You called &lt;code&gt;.create()&lt;/code&gt; with &lt;code&gt;local[*]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;.getOrCreate()&lt;/code&gt; for local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INVALID_CONNECT_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The connection string is malformed&lt;/td&gt;
&lt;td&gt;Empty path, no &lt;code&gt;/&lt;/code&gt; in parameter values&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One that doesn't raise at all is &lt;code&gt;enableHiveSupport()&lt;/code&gt;, which prints a warning and gives you a session without Hive, so read your warnings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Errors arrive late.&lt;/strong&gt; Classic Spark checks your DataFrame the moment you define it (that step 3 from earlier); Connect doesn't send anything to the server until it needs an answer, so a typo in a column name fails at the &lt;code&gt;show()&lt;/code&gt; or &lt;code&gt;write&lt;/code&gt; rather than on the line with the typo, and a &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;except&lt;/code&gt; around the definition catches nothing. The same thing has two quieter consequences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrReplaceTempView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrReplaceTempView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# Classic: rows of a    Connect: rows of b
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A DataFrame built on a temp view reads whatever the view points to &lt;em&gt;when it runs&lt;/em&gt;, and a Python function that reads a variable sees that variable's value &lt;em&gt;when it runs&lt;/em&gt;, so give temp views unique names and pass values into functions explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Look at the server's side.&lt;/strong&gt; The server has the &lt;a href="https://spark.apache.org/docs/latest/web-ui.html" rel="noopener noreferrer"&gt;Spark UI&lt;/a&gt; (port 4040 by default), where you can see the jobs your client triggered along with a &lt;strong&gt;Connect&lt;/strong&gt; tab listing every client session and request, and it has its logs (in &lt;code&gt;logs/&lt;/code&gt; under the Spark directory, or &lt;code&gt;docker logs&lt;/code&gt; for a container). &lt;code&gt;df.explain()&lt;/code&gt; shows you the plan the server actually built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check your client isn't newer than your server.&lt;/strong&gt; The supported direction is an older client talking to a newer server, and the other way round can fail in odd ways: a 4.2 client can't even build a DataFrame from a three-row Python list on a 4.1 server, because the 4.1 server reports a size limit as &lt;code&gt;3221225472b&lt;/code&gt; and the 4.2 client chokes on the &lt;code&gt;b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Let a script check for you.&lt;/strong&gt; The repo includes a &lt;a href="https://github.com/lisancao/spark-demos/blob/main/demos/02_spark_connect/tools/doctor.py" rel="noopener noreferrer"&gt;doctor&lt;/a&gt; that checks the client you installed, whether the server is reachable, its version, and whether Python functions actually run on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python tools/doctor.py &lt;span class="nt"&gt;--remote&lt;/span&gt; sc://localhost:15002 &lt;span class="nt"&gt;--expect-version&lt;/span&gt; 4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The whole guide, on one page
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spark&lt;/strong&gt; splits big data work across many machines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spark Connect&lt;/strong&gt; moves the driver out of your program; your code becomes a client that sends plans to a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it&lt;/strong&gt; with &lt;code&gt;pip install pyspark-client&lt;/code&gt; and &lt;code&gt;SparkSession.builder.remote("sc://host:15002")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a server&lt;/strong&gt; in-process with &lt;code&gt;local[*]&lt;/code&gt;, from a Spark download with &lt;code&gt;start-connect-server.sh&lt;/code&gt;, or from the &lt;code&gt;apache/spark&lt;/code&gt; Docker image (plus PyArrow).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send a job&lt;/strong&gt; by running your program; ship your own modules with &lt;code&gt;addArtifact&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locally&lt;/strong&gt; you need Python matching the server's version and &lt;code&gt;pyspark-client&lt;/code&gt;, and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other languages&lt;/strong&gt; (Scala, Go, Rust, Swift, JDBC) use the same server and the same connection string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it breaks,&lt;/strong&gt; check which server answered, read the error's name, remember errors arrive late, and look at the server's side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nearly all of it comes back to the one change at the start, which is that the driver moved out of your program; once you know which side of the network a thing lives on, most of Connect stops being surprising.&lt;/p&gt;

</description>
      <category>spark</category>
      <category>sparkconnect</category>
      <category>pyspark</category>
    </item>
    <item>
      <title>Spark's Modules, Seen Through Spark Connect</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:20:49 +0000</pubDate>
      <link>https://dev.to/lisancao/sparks-modules-seen-through-spark-connect-gg1</link>
      <guid>https://dev.to/lisancao/sparks-modules-seen-through-spark-connect-gg1</guid>
      <description>&lt;p&gt;Spark is moving its centre of gravity out of the JVM. Spark Connect, introduced in 3.4, lets a client drive Spark over gRPC without running a JVM of its own, and in the 4.x line that thin client has become the thing new features are built for: &lt;code&gt;pip install pyspark-client&lt;/code&gt; gets you the DataFrame API with none of the JARs full PySpark carries. That gives a simple way to tell which parts of Spark have a future. &lt;strong&gt;The state of each Spark module is best read through a single test: can you reach it from a thin Python client over Spark Connect?&lt;/strong&gt; The modules getting sustained investment were either built for that world (SQL, Declarative Pipelines) or are being rebuilt to reach it (Structured Streaming, MLlib, the Python UDF path); the ones fading (GraphX, SparkR, DStreams, the RDD-based &lt;code&gt;spark.mllib&lt;/code&gt;) are exactly the ones that can't cross it.&lt;/p&gt;

&lt;p&gt;I learned to read Spark this way by accident. In February I tested Real-Time Mode on open-source Spark 4.1, and the first obstacle wasn't latency; it was Python. The real-time trigger existed in Scala, PySpark had no way to ask for it, and to run the pipeline at all I wrote a helper that reached through the Py4J gateway and built the Scala trigger object by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_real_time_trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checkpoint_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;jvm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jvm&lt;/span&gt;
    &lt;span class="n"&gt;jTrigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jvm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RealTimeTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;checkpoint_interval&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jwrite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jwrite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jTrigger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dsw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked, and it's exactly the kind of code that has no future. It only runs in classic mode, because a Spark Connect client has no JVM to reach into; the PySpark 4.2 Connect session refuses &lt;code&gt;_jvm&lt;/code&gt; by name, along with &lt;code&gt;_jsc&lt;/code&gt;, &lt;code&gt;sparkContext&lt;/code&gt;, and the rest of the Py4J surface. Five months later the helper was obsolete anyway. Spark 4.2 &lt;a href="https://issues.apache.org/jira/browse/SPARK-54660" rel="noopener noreferrer"&gt;added &lt;code&gt;trigger(realTime=...)&lt;/code&gt; to PySpark&lt;/a&gt;, so the same pipeline runs from a thin client, and the 4.3 branch lets that Python code run aggregations and deduplication in real time too. A feature that existed but couldn't be reached from Python became real the moment it crossed the client boundary, and that is the story of nearly every module below.&lt;/p&gt;

&lt;p&gt;Crossing isn't free, and part of this post is what it cost me when I tried. It's written against &lt;a href="https://spark.apache.org/releases/spark-release-4-2-0.html" rel="noopener noreferrer"&gt;Spark 4.2.0&lt;/a&gt; (released July 14, 2026). Spark 4.3 is close but not out: its first release candidate was tagged on August 31, and the vote on it failed on September 6, so where 4.3 matters I describe what's on the release branch, not what has shipped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICAgIHN1YmdyYXBoIGNsaWVudHNbXCJIb3cgeW91IHJlYWNoIFNwYXJrXCJdXG4gICAgICAgIFRDW1wicHlzcGFyay1jbGllbnQgKHRoaW4sIENvbm5lY3Qgb25seSlcIl1cbiAgICAgICAgUFlbXCJQeVNwYXJrIChjbGFzc2ljIG9yIENvbm5lY3QpXCJdXG4gICAgICAgIE9UW1wiU2NhbGEgLyBKYXZhIC8gR28gLyBSdXN0IC8gSkRCQyBjbGllbnRzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggYnVpbHRbXCJCdWlsdCBmb3IgQ29ubmVjdFwiXVxuICAgICAgICBTUUxbXCJTcGFyayBTUUxcIl1cbiAgICAgICAgU0RQW1wiRGVjbGFyYXRpdmUgUGlwZWxpbmVzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggcmVidWlsdFtcIlJlYnVpbHQgdG8gcmVhY2ggaXRcIl1cbiAgICAgICAgU1NbXCJTdHJ1Y3R1cmVkIFN0cmVhbWluZyArIFJlYWwtVGltZSBNb2RlXCJdXG4gICAgICAgIE1MW1wiTUxsaWIgKHNwYXJrLm1sKVwiXVxuICAgICAgICBVREZbXCJQeXRob24gVURGcyBvbiBBcnJvd1wiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGxlZnRbXCJMZWZ0IGJlaGluZFwiXVxuICAgICAgICBHWFtcIkdyYXBoWFwiXVxuICAgICAgICBTUltcIlNwYXJrUlwiXVxuICAgICAgICBEU1tcIkRTdHJlYW1zXCJdXG4gICAgICAgIFJERE1MW1wic3BhcmsubWxsaWIgKFJERClcIl1cbiAgICBlbmRcbiAgICBjbGllbnRzIC0tPiBidWlsdFxuICAgIGNsaWVudHMgLS0-IHJlYnVpbHRcbiAgICBjbGllbnRzIC0uIFwiY2Fubm90IHJlYWNoXCIgLi0-IGxlZnRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICAgIHN1YmdyYXBoIGNsaWVudHNbXCJIb3cgeW91IHJlYWNoIFNwYXJrXCJdXG4gICAgICAgIFRDW1wicHlzcGFyay1jbGllbnQgKHRoaW4sIENvbm5lY3Qgb25seSlcIl1cbiAgICAgICAgUFlbXCJQeVNwYXJrIChjbGFzc2ljIG9yIENvbm5lY3QpXCJdXG4gICAgICAgIE9UW1wiU2NhbGEgLyBKYXZhIC8gR28gLyBSdXN0IC8gSkRCQyBjbGllbnRzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggYnVpbHRbXCJCdWlsdCBmb3IgQ29ubmVjdFwiXVxuICAgICAgICBTUUxbXCJTcGFyayBTUUxcIl1cbiAgICAgICAgU0RQW1wiRGVjbGFyYXRpdmUgUGlwZWxpbmVzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggcmVidWlsdFtcIlJlYnVpbHQgdG8gcmVhY2ggaXRcIl1cbiAgICAgICAgU1NbXCJTdHJ1Y3R1cmVkIFN0cmVhbWluZyArIFJlYWwtVGltZSBNb2RlXCJdXG4gICAgICAgIE1MW1wiTUxsaWIgKHNwYXJrLm1sKVwiXVxuICAgICAgICBVREZbXCJQeXRob24gVURGcyBvbiBBcnJvd1wiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGxlZnRbXCJMZWZ0IGJlaGluZFwiXVxuICAgICAgICBHWFtcIkdyYXBoWFwiXVxuICAgICAgICBTUltcIlNwYXJrUlwiXVxuICAgICAgICBEU1tcIkRTdHJlYW1zXCJdXG4gICAgICAgIFJERE1MW1wic3BhcmsubWxsaWIgKFJERClcIl1cbiAgICBlbmRcbiAgICBjbGllbnRzIC0tPiBidWlsdFxuICAgIGNsaWVudHMgLS0-IHJlYnVpbHRcbiAgICBjbGllbnRzIC0uIFwiY2Fubm90IHJlYWNoXCIgLi0-IGxlZnRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 1" width="997" height="930"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What "reachable over Connect" actually means
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Spark Connect&lt;/a&gt; splits the application from the engine. The client builds an unresolved logical plan, serializes it as protobuf, sends it over gRPC, and gets results back as Arrow batches; everything that plans and runs the query lives on the server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEMgYXMgQ2xpZW50IChQeXRob24sIFNjYWxhLCBHbywgUnVzdCwgSkRCQylcbiAgICBwYXJ0aWNpcGFudCBTIGFzIFNwYXJrIENvbm5lY3Qgc2VydmVyXG4gICAgcGFydGljaXBhbnQgRSBhcyBFeGVjdXRvcnNcbiAgICBDLT4-UzogRXhlY3V0ZVBsYW4gKHVucmVzb2x2ZWQgbG9naWNhbCBwbGFuLCBwcm90b2J1ZiBvdmVyIGdSUEMpXG4gICAgUy0-PlM6IEFuYWx5emUsIG9wdGltaXplLCBwbGFuIChDYXRhbHlzdClcbiAgICBTLT4-RTogUnVuIHRhc2tzXG4gICAgRS0tPj5TOiBSZXN1bHRzXG4gICAgUy0tPj5DOiBBcnJvdyByZWNvcmQgYmF0Y2hlcyAoc3RyZWFtZWQpXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEMgYXMgQ2xpZW50IChQeXRob24sIFNjYWxhLCBHbywgUnVzdCwgSkRCQylcbiAgICBwYXJ0aWNpcGFudCBTIGFzIFNwYXJrIENvbm5lY3Qgc2VydmVyXG4gICAgcGFydGljaXBhbnQgRSBhcyBFeGVjdXRvcnNcbiAgICBDLT4-UzogRXhlY3V0ZVBsYW4gKHVucmVzb2x2ZWQgbG9naWNhbCBwbGFuLCBwcm90b2J1ZiBvdmVyIGdSUEMpXG4gICAgUy0-PlM6IEFuYWx5emUsIG9wdGltaXplLCBwbGFuIChDYXRhbHlzdClcbiAgICBTLT4-RTogUnVuIHRhc2tzXG4gICAgRS0tPj5TOiBSZXN1bHRzXG4gICAgUy0tPj5DOiBBcnJvdyByZWNvcmQgYmF0Y2hlcyAoc3RyZWFtZWQpXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="968" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That contract is the whole test. &lt;strong&gt;Anything expressible as a logical plan travels; anything that assumes the client and the driver share a JVM doesn't.&lt;/strong&gt; The line falls in a predictable place:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Over Spark Connect&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DataFrame operations, Spark SQL, catalog operations, temp views&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured Streaming&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python, Pandas, and Arrow UDFs; UDTFs&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pyspark.ml&lt;/code&gt; pipelines (fit / transform)&lt;/td&gt;
&lt;td&gt;Supported, via a server-side model cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SparkContext&lt;/code&gt;, RDDs, broadcast variables, accumulators&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RDDs are the dividing line because an RDD isn't a plan; it's a handle to partitioned data and closures living in the driver's JVM. Every module built on RDDs inherits that limitation, which is most of the reason the left-hand column of the diagram above looks the way it does.&lt;/p&gt;

&lt;p&gt;The payoff for crossing the line is a client with no JVM. &lt;code&gt;pyspark-client&lt;/code&gt; &lt;a href="https://spark.apache.org/docs/latest/app-dev-spark-connect.html" rel="noopener noreferrer"&gt;ships only the gRPC client, the protobuf definitions, and the DataFrame API&lt;/a&gt;; it installs none of the roughly 277 jars that full &lt;code&gt;pyspark&lt;/code&gt; carries. When I measured the 4.2.0 packages, Spark's own Python payload dropped from roughly 480 MB to about 20 MB and the jar directory to zero.&lt;/p&gt;

&lt;p&gt;The common "1.5 MB client" line is misleading, though: 1.60 MB is the wheel, and a working thin-client virtualenv still came to roughly 360 to 380 MB across my installs, because &lt;code&gt;pyarrow&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt;, and &lt;code&gt;numpy&lt;/code&gt; are hard requirements and dwarf Spark itself. The honest headline is &lt;em&gt;no JVM, no JDK, no jars&lt;/em&gt;, not &lt;em&gt;small&lt;/em&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark-client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The thin client: pure Python, no JDK, remote only (&lt;code&gt;sc://...&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark[connect]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full PySpark plus Connect extras; can also start a local server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark-connect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A small shim that depends on full &lt;code&gt;pyspark&lt;/code&gt; (so not thin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyspark&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Classic Py4J by default; talks to a Connect server via &lt;code&gt;.remote()&lt;/code&gt; with the extras installed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The classic/Connect choice is controlled by &lt;code&gt;spark.api.mode&lt;/code&gt;, whose default is still &lt;code&gt;classic&lt;/code&gt;; the proposal to make Connect the default (&lt;a href="https://issues.apache.org/jira/browse/SPARK-50411" rel="noopener noreferrer"&gt;SPARK-50411&lt;/a&gt;) is open. So the direction is clear, but for now you opt in, and what that means for someone choosing today is simple: &lt;strong&gt;anything you write that touches &lt;code&gt;sparkContext&lt;/code&gt; or &lt;code&gt;_jvm&lt;/code&gt; is a bet against where the project is going.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Built for it: SQL and Declarative Pipelines
&lt;/h2&gt;

&lt;p&gt;Spark SQL passes the test almost by definition, because a Connect request &lt;em&gt;is&lt;/em&gt; a logical plan and Spark SQL is the machinery that turns logical plans into execution. Every DataFrame call, every &lt;code&gt;spark.sql()&lt;/code&gt; string, and every Structured Streaming query goes through the same pipeline:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIFFbXCJVc2VyIHF1ZXJ5IChTUUwgb3IgRGF0YUZyYW1lKVwiXSAtLT4gVVtcIlVucmVzb2x2ZWQgbG9naWNhbCBwbGFuXCJdXG4gICAgVSAtLT58XCJBbmFseXNpczogcmVzb2x2ZSBuYW1lcyBhbmQgdHlwZXNcInwgUltcIlJlc29sdmVkIGxvZ2ljYWwgcGxhblwiXVxuICAgIFIgLS0-fFwiT3B0aW1pemF0aW9uOiBwdXNoZG93biwgY29uc3RhbnQgZm9sZGluZywgam9pbiByZW9yZGVyaW5nXCJ8IE9bXCJPcHRpbWl6ZWQgbG9naWNhbCBwbGFuXCJdXG4gICAgTyAtLT58XCJQaHlzaWNhbCBwbGFubmluZ1wifCBQW1wiUGh5c2ljYWwgcGxhblwiXVxuICAgIFAgLS0-fFwiV2hvbGUtc3RhZ2UgY29kZSBnZW5lcmF0aW9uXCJ8IEdbXCJHZW5lcmF0ZWQgSlZNIGJ5dGVjb2RlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIFFbXCJVc2VyIHF1ZXJ5IChTUUwgb3IgRGF0YUZyYW1lKVwiXSAtLT4gVVtcIlVucmVzb2x2ZWQgbG9naWNhbCBwbGFuXCJdXG4gICAgVSAtLT58XCJBbmFseXNpczogcmVzb2x2ZSBuYW1lcyBhbmQgdHlwZXNcInwgUltcIlJlc29sdmVkIGxvZ2ljYWwgcGxhblwiXVxuICAgIFIgLS0-fFwiT3B0aW1pemF0aW9uOiBwdXNoZG93biwgY29uc3RhbnQgZm9sZGluZywgam9pbiByZW9yZGVyaW5nXCJ8IE9bXCJPcHRpbWl6ZWQgbG9naWNhbCBwbGFuXCJdXG4gICAgTyAtLT58XCJQaHlzaWNhbCBwbGFubmluZ1wifCBQW1wiUGh5c2ljYWwgcGxhblwiXVxuICAgIFAgLS0-fFwiV2hvbGUtc3RhZ2UgY29kZSBnZW5lcmF0aW9uXCJ8IEdbXCJHZW5lcmF0ZWQgSlZNIGJ5dGVjb2RlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 3" width="276" height="806"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's why SQL keeps absorbing features that used to live in application code. Across the 4.x line it gained procedural scripting (generally available in 4.1, with cursors added in 4.2), recursive common table expressions (a request that sat open as &lt;a href="https://issues.apache.org/jira/browse/SPARK-24497" rel="noopener noreferrer"&gt;SPARK-24497&lt;/a&gt; from 2018), the VARIANT type for semi-structured data (shredded into typed Parquet columns from 4.1), collations, and pipe syntax. None of these needs anything from the client beyond the text of the query, which is the point: &lt;strong&gt;the more logic moves into SQL, the less it matters what language or process the client is.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Recursive CTE: walk an org chart without a loop in application code&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most interesting 4.2 addition, and the one I spent the most time probing, is &lt;strong&gt;metric views&lt;/strong&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-54119" rel="noopener noreferrer"&gt;SPARK-54119&lt;/a&gt;): a semantic layer declared with &lt;code&gt;CREATE VIEW ... WITH METRICS&lt;/code&gt;, where measures such as a conversion rate are defined once and re-aggregated correctly at whatever grain you query.&lt;/p&gt;

&lt;p&gt;The problem they solve is real. In an example I built on synthetic session data, the true monthly conversion rate was 0.0923, while the naive average of the five regional rates was 0.2384, 2.6 times too high, because an average of ratios gives a region with 200 sessions the same weight as one with 50,000. A metric view makes that mistake hard to write.&lt;/p&gt;

&lt;p&gt;What it doesn't yet do is fail gracefully.&lt;/p&gt;

&lt;p&gt;I ran a grammar probe of 87 statements against the official &lt;code&gt;apache/spark:4.2.0&lt;/code&gt; image (once in classic mode and twice over Connect, all three runs identical), and six invalid statements didn't produce a clean error; they crashed the planner with &lt;code&gt;INTERNAL_ERROR&lt;/code&gt;, the message that begins "You hit a bug in Spark":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement (abridged)&lt;/th&gt;
&lt;th&gt;Result on 4.2.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM &amp;lt;metric view&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INTERNAL_ERROR&lt;/code&gt; at planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selecting a measure without &lt;code&gt;MEASURE()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INTERNAL_ERROR&lt;/code&gt; at planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Joining a metric view on a dimension&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INTERNAL_ERROR&lt;/code&gt; at planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MEASURE()&lt;/code&gt; over an ordinary table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INTERNAL_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE VIEW ... WITH METRICS&lt;/code&gt; without &lt;code&gt;LANGUAGE YAML&lt;/code&gt;, and a metric view defined over another metric view&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INTERNAL_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The numbers are protected; the person writing the query isn't, yet.&lt;/p&gt;

&lt;p&gt;Declarative Pipelines (SDP) &lt;a href="https://spark.apache.org/docs/latest/declarative-pipelines-programming-guide.html" rel="noopener noreferrer"&gt;extend the same idea from queries to whole pipelines&lt;/a&gt;: you declare what each table should contain, and Spark resolves dependencies, orders execution, and manages checkpoints and writes. Pipelines are defined in Python or SQL and run over Spark Connect by design, so SDP was born on the right side of the line.&lt;/p&gt;

&lt;p&gt;It arrived in 4.1; 4.2 added AUTO CDC for applying change feeds as SCD Type 1, and the 4.3 branch adds &lt;a href="https://issues.apache.org/jira/browse/SPARK-58321" rel="noopener noreferrer"&gt;SCD Type 2 end to end&lt;/a&gt; and &lt;a href="https://issues.apache.org/jira/browse/SPARK-57402" rel="noopener noreferrer"&gt;a SQL form&lt;/a&gt;, &lt;code&gt;CREATE FLOW ... AS AUTO CDC&lt;/code&gt;. I tested the 4.2 behaviour in detail in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/auto-cdc-declarative-pipelines" rel="noopener noreferrer"&gt;AUTO CDC: Turning an event log into current state&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Python API&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Materialized view&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@dp.materialized_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Batch table, recomputed each run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@dp.table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Incremental ingestion with managed checkpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flow&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@dp.append_flow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named writer into a streaming table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AUTO CDC flow (4.2)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp.create_auto_cdc_flow(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies a change feed as SCD Type 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For someone choosing today:&lt;/strong&gt; push as much logic as you can into SQL and declarative definitions. It's the part of Spark that works identically from every client, it's where new features land first, and it's the part least likely to strand you in a migration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rebuilt to reach it: streaming, ML, and Python UDFs
&lt;/h2&gt;

&lt;p&gt;The second group are modules that predate Spark Connect and are being reworked to pass the test. Each has made real progress, and each still has a visible seam.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Streaming and Real-Time Mode
&lt;/h3&gt;

&lt;p&gt;Spark has had three streaming engines, and the history is a clean illustration of the thesis. DStreams (described by &lt;a href="https://doi.org/10.1145/2517349.2522737" rel="noopener noreferrer"&gt;Matei Zaharia and colleagues&lt;/a&gt; in 2012 and 2013) modelled a stream as a sequence of small RDDs; Structured Streaming (Spark 2.0, 2016; written up by &lt;a href="https://doi.org/10.1145/3183713.3190664" rel="noopener noreferrer"&gt;Michael Armbrust and colleagues at SIGMOD 2018&lt;/a&gt;) rebuilt streaming on DataFrames, as an unbounded table queried with the same API as batch; and &lt;a href="https://issues.apache.org/jira/browse/SPARK-53736" rel="noopener noreferrer"&gt;Real-Time Mode, added in 4.1&lt;/a&gt;, replaces the micro-batch loop with a continuous pipeline:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIG1iW1wiTWljcm8tYmF0Y2hcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIEIxW1wiQmF0Y2ggMTogcGxhbiwgcmVhZCwgcHJvY2Vzcywgd3JpdGUsIGNvbW1pdFwiXSAtLT58XCJzY2hlZHVsaW5nIGdhcFwifCBCMltcIkJhdGNoIDI6IHBsYW4sIHJlYWQsIHByb2Nlc3MsIHdyaXRlLCBjb21taXRcIl0gLS0-fFwic2NoZWR1bGluZyBnYXBcInwgQjNbXCJCYXRjaCAzIC4uLlwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIHJ0W1wiUmVhbC1UaW1lIE1vZGVcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIFNbXCJTb3VyY2VcIl0gLS0-IFJEW1wiUmVhZGVyXCJdIC0tPiBQUltcIlByb2Nlc3NvclwiXSAtLT4gV1tcIldyaXRlclwiXSAtLT4gQ1tcIkNvbW1pdHRlciAoYXN5bmMgY2hlY2twb2ludHMpXCJdXG4gICAgZW5kXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIG1iW1wiTWljcm8tYmF0Y2hcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIEIxW1wiQmF0Y2ggMTogcGxhbiwgcmVhZCwgcHJvY2Vzcywgd3JpdGUsIGNvbW1pdFwiXSAtLT58XCJzY2hlZHVsaW5nIGdhcFwifCBCMltcIkJhdGNoIDI6IHBsYW4sIHJlYWQsIHByb2Nlc3MsIHdyaXRlLCBjb21taXRcIl0gLS0-fFwic2NoZWR1bGluZyBnYXBcInwgQjNbXCJCYXRjaCAzIC4uLlwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIHJ0W1wiUmVhbC1UaW1lIE1vZGVcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIFNbXCJTb3VyY2VcIl0gLS0-IFJEW1wiUmVhZGVyXCJdIC0tPiBQUltcIlByb2Nlc3NvclwiXSAtLT4gV1tcIldyaXRlclwiXSAtLT4gQ1tcIkNvbW1pdHRlciAoYXN5bmMgY2hlY2twb2ludHMpXCJdXG4gICAgZW5kXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 4" width="1109" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Real-Time Mode is also where I learned how wide the gap between a feature's design and its open-source reality can be. The widely quoted single-digit-millisecond figures come from Databricks' own deployments.&lt;/p&gt;

&lt;p&gt;On open-source 4.1, a stateless Kafka-to-Kafka passthrough I measured (500 events per second on a single 16-core machine, six runs of roughly two thousand events each) had a p50 of about 17 ms and a p99 of 21 to 26 ms. That's fast; it's also a different number, measured on a much smaller setup, and I'd treat both figures as specific to where they came from. The limits mattered more than the latency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateless only.&lt;/strong&gt; The 4.1 operator allowlist permitted projections, filters, and broadcast joins; aggregations, shuffles, and state store operators were off it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Python UDFs.&lt;/strong&gt; &lt;code&gt;BatchEvalPythonExec&lt;/code&gt; wasn't on the allowlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No PySpark trigger,&lt;/strong&gt; hence the Py4J helper at the top of this post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every stage runs concurrently,&lt;/strong&gt; so a 16-partition Kafka source needs 16 task slots just to read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spark 4.2 fixed the Python gap (the &lt;code&gt;realTime&lt;/code&gt; trigger in PySpark) but, per the 4.2 source, left the operator allowlist unchanged, so on 4.2 it's still a stateless engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spark 4.2+: Real-Time Mode from PySpark, no JVM access needed.
# The argument is the checkpoint interval, not a latency target.
&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e.*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# stateless: allowed
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectExpr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to_json(struct(*)) AS value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka.bootstrap.servers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka:9092&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payments_clean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpointLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/checkpoints/payments_clean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;realTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 4.3 branch is where it becomes a general engine. It adds stateful Real-Time Mode (umbrella &lt;a href="https://issues.apache.org/jira/browse/SPARK-54699" rel="noopener noreferrer"&gt;SPARK-54699&lt;/a&gt;): streaming aggregations including windowed ones, &lt;code&gt;dropDuplicates&lt;/code&gt;, the JVM &lt;code&gt;transformWithState&lt;/code&gt;, and pipelined shuffles, and it removes the &lt;code&gt;@Experimental&lt;/code&gt; marker from the trigger APIs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/apache/spark/blob/branch-4.3/docs/streaming/real-time-mode.md" rel="noopener noreferrer"&gt;The branch documentation&lt;/a&gt; is just as clear about what remains out of scope: stream-stream joins, &lt;code&gt;flatMapGroupsWithState&lt;/code&gt;, session windows, and the &lt;em&gt;PySpark&lt;/em&gt; &lt;code&gt;transformWithState&lt;/code&gt; APIs are unsupported; Python UDFs are still not allowed (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57237" rel="noopener noreferrer"&gt;SPARK-57237&lt;/a&gt; is open); Kafka is the only source; and stateful queries need enough cores to run both stages at once.&lt;/p&gt;

&lt;p&gt;Notice where the seam is. When I built the 4.3 branch and ran it against my own stack, the stateful operators that are ordinary DataFrame operations worked from PySpark in Real-Time Mode: a streaming aggregation and a &lt;code&gt;dropDuplicates&lt;/code&gt; both ran at about 40 ms median latency (against about 600 ms for the same query as a one-second micro-batch), with no duplicates getting through. &lt;strong&gt;What's still missing is Python's own stateful API&lt;/strong&gt;: PySpark &lt;code&gt;transformWithState&lt;/code&gt; isn't supported in Real-Time Mode, and on the branch today it fails with a misleading checkpoint-metadata error (&lt;code&gt;STDS_FAILED_TO_READ_OPERATOR_METADATA&lt;/code&gt;) rather than the documented unsupported-operator one. Custom state in real time is still a Scala feature.&lt;/p&gt;

&lt;p&gt;The rest of Structured Streaming is further along. &lt;code&gt;transformWithState&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-46815" rel="noopener noreferrer"&gt;SPARK-46815&lt;/a&gt;) replaced the older &lt;code&gt;mapGroupsWithState&lt;/code&gt; APIs in 4.0 with typed, evolvable state, and it has a Python API, which ended the era when complex stateful streaming meant writing Scala. RocksDB is the default state store in 4.x, and 4.1 enabled adaptive query execution for stateless streaming queries (&lt;a href="https://issues.apache.org/jira/browse/SPARK-53941" rel="noopener noreferrer"&gt;SPARK-53941&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For someone choosing today:&lt;/strong&gt; on 4.2, use Real-Time Mode from Python for stateless transforms where the latency matters, and micro-batch Structured Streaming for anything stateful. On 4.3, aggregations and deduplication move into Real-Time Mode from Python as well; if you need custom state (&lt;code&gt;transformWithState&lt;/code&gt;) at real-time latency, that's still Scala.&lt;/p&gt;

&lt;h3&gt;
  
  
  MLlib on Connect
&lt;/h3&gt;

&lt;p&gt;MLlib's problem was more fundamental than streaming's. In classic PySpark, &lt;code&gt;model.fit(df)&lt;/code&gt; returns a model object that lives in the driver's JVM, and the Python client just holds a Py4J reference to it. A Connect client has no JVM to hold anything, so ML over Connect required moving model state to the server:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIGNsYXNzaWNbXCJDbGFzc2ljIChQeTRKKVwiXVxuICAgICAgICBkaXJlY3Rpb24gTFJcbiAgICAgICAgUENbXCJQeXRob24gY2xpZW50OiBQeTRKIHJlZmVyZW5jZVwiXSA8LS0-fFwiUHk0SlwifCBESltcIkRyaXZlciBKVk06IG1vZGVsIG9iamVjdFwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGNvbm5lY3RbXCJNTCBvbiBDb25uZWN0IChnUlBDKVwiXVxuICAgICAgICBkaXJlY3Rpb24gTFJcbiAgICAgICAgQ0NbXCJUaGluIGNsaWVudDogbW9kZWwgSUQgb25seVwiXSA8LS0-fFwiZ1JQQ1wifCBTVltcIkNvbm5lY3Qgc2VydmVyOiBNTEhhbmRsZXIgKyBNTENhY2hlIChmaXR0ZWQgbW9kZWxzIGxpdmUgaGVyZSlcIl1cbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIGNsYXNzaWNbXCJDbGFzc2ljIChQeTRKKVwiXVxuICAgICAgICBkaXJlY3Rpb24gTFJcbiAgICAgICAgUENbXCJQeXRob24gY2xpZW50OiBQeTRKIHJlZmVyZW5jZVwiXSA8LS0-fFwiUHk0SlwifCBESltcIkRyaXZlciBKVk06IG1vZGVsIG9iamVjdFwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGNvbm5lY3RbXCJNTCBvbiBDb25uZWN0IChnUlBDKVwiXVxuICAgICAgICBkaXJlY3Rpb24gTFJcbiAgICAgICAgQ0NbXCJUaGluIGNsaWVudDogbW9kZWwgSUQgb25seVwiXSA8LS0-fFwiZ1JQQ1wifCBTVltcIkNvbm5lY3Qgc2VydmVyOiBNTEhhbmRsZXIgKyBNTENhY2hlIChmaXR0ZWQgbW9kZWxzIGxpdmUgaGVyZSlcIl1cbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 5" width="708" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The server keeps fitted models in a cache and hands the client an ID; &lt;code&gt;fit&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;, evaluation, attribute lookup, and save/load become RPCs. &lt;strong&gt;This is the one rebuild that's essentially finished.&lt;/strong&gt; &lt;code&gt;pyspark.ml&lt;/code&gt; on Connect &lt;a href="https://spark.apache.org/releases/spark-release-4-0-0.html" rel="noopener noreferrer"&gt;shipped in 4.0&lt;/a&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-50812" rel="noopener noreferrer"&gt;SPARK-50812&lt;/a&gt;); 4.1 brought it to general availability with model cache offloading, model size and cache limits, and summaries that survive eviction (&lt;a href="https://issues.apache.org/jira/browse/SPARK-51236" rel="noopener noreferrer"&gt;SPARK-51236&lt;/a&gt;); 4.2 re-enabled FPGrowth (&lt;a href="https://issues.apache.org/jira/browse/SPARK-54574" rel="noopener noreferrer"&gt;SPARK-54574&lt;/a&gt;); and the 4.3 branch adds model-size estimation for around twenty model types (umbrella &lt;a href="https://issues.apache.org/jira/browse/SPARK-58181" rel="noopener noreferrer"&gt;SPARK-58181&lt;/a&gt;) plus a fix for size overcounting (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57521" rel="noopener noreferrer"&gt;SPARK-57521&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;What it hasn't done is grow: the estimators, transformers, and evaluators registered for Connect are the same list from 4.0 through the 4.3 branch, a few known gaps remain open (&lt;a href="https://issues.apache.org/jira/browse/SPARK-51473" rel="noopener noreferrer"&gt;SPARK-51473&lt;/a&gt;, &lt;a href="https://issues.apache.org/jira/browse/SPARK-51478" rel="noopener noreferrer"&gt;SPARK-51478&lt;/a&gt;), and the ML guide still has no section on Connect at all.&lt;/p&gt;

&lt;p&gt;Meanwhile the DataFrame-based &lt;code&gt;spark.ml&lt;/code&gt; has been the primary API since Spark 2.0, when the RDD-based &lt;code&gt;spark.mllib&lt;/code&gt; entered maintenance mode; &lt;code&gt;spark.mllib&lt;/code&gt; doesn't pass the Connect test and isn't going to. For deep learning, the path is TorchDistributor (&lt;a href="https://issues.apache.org/jira/browse/SPARK-41589" rel="noopener noreferrer"&gt;SPARK-41589&lt;/a&gt;), which uses Spark to launch distributed PyTorch training rather than reimplementing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For someone choosing today:&lt;/strong&gt; &lt;code&gt;spark.ml&lt;/code&gt; pipelines over Connect are safe for the algorithms MLlib already has; don't expect the catalogue to expand, and use TorchDistributor or an external framework for anything modern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python UDFs on Arrow
&lt;/h3&gt;

&lt;p&gt;The Python UDF path is the rebuild I've measured most closely. Its history is a long march away from pickling one row at a time: Pandas UDFs (2018) moved data in Arrow batches but converted them to pandas; &lt;code&gt;useArrow=True&lt;/code&gt; (from 3.5) put ordinary scalar UDFs on Arrow transport; and &lt;code&gt;@arrow_udf&lt;/code&gt; (public in 4.1, &lt;a href="https://issues.apache.org/jira/browse/SPARK-53014" rel="noopener noreferrer"&gt;SPARK-53014&lt;/a&gt;) hands your function a &lt;code&gt;pyarrow.Array&lt;/code&gt; with no pandas conversion at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIHBhbmRhc1tcIlBhbmRhcyBVREZcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIEoxW1wiSlZNIChBcnJvdylcIl0gLS0-IEMxW1wiQXJyb3cgdG8gcGFuZGFzXCJdIC0tPiBVMVtcInlvdXIgZnVuY3Rpb25cIl0gLS0-IEMyW1wicGFuZGFzIHRvIEFycm93XCJdIC0tPiBKMltcIkpWTVwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGFycm93W1wiQXJyb3cgVURGXCJdXG4gICAgICAgIGRpcmVjdGlvbiBMUlxuICAgICAgICBKM1tcIkpWTSAoQXJyb3cpXCJdIC0tPiBVMltcInlvdXIgZnVuY3Rpb246IHB5YXJyb3cuQXJyYXkgaW4gYW5kIG91dFwiXSAtLT4gSjRbXCJKVk1cIl1cbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIHBhbmRhc1tcIlBhbmRhcyBVREZcIl1cbiAgICAgICAgZGlyZWN0aW9uIExSXG4gICAgICAgIEoxW1wiSlZNIChBcnJvdylcIl0gLS0-IEMxW1wiQXJyb3cgdG8gcGFuZGFzXCJdIC0tPiBVMVtcInlvdXIgZnVuY3Rpb25cIl0gLS0-IEMyW1wicGFuZGFzIHRvIEFycm93XCJdIC0tPiBKMltcIkpWTVwiXVxuICAgIGVuZFxuICAgIHN1YmdyYXBoIGFycm93W1wiQXJyb3cgVURGXCJdXG4gICAgICAgIGRpcmVjdGlvbiBMUlxuICAgICAgICBKM1tcIkpWTSAoQXJyb3cpXCJdIC0tPiBVMltcInlvdXIgZnVuY3Rpb246IHB5YXJyb3cuQXJyYXkgaW4gYW5kIG91dFwiXSAtLT4gSjRbXCJKVk1cIl1cbiAgICBlbmRcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 6" width="1136" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my benchmark on Spark 4.1, &lt;strong&gt;what decided the cost of a UDF was mostly what the function received, not how the bytes travelled.&lt;/strong&gt; &lt;code&gt;useArrow=True&lt;/code&gt; was worth 1.4x to 2.4x over pickle depending on the workload; the vectorized UDFs were faster again; and &lt;code&gt;@arrow_udf&lt;/code&gt; beat &lt;code&gt;@pandas_udf&lt;/code&gt; by about 1.6x on strings but only 5 to 9% on numeric work, because a numeric pandas Series is cheap to build from Arrow while strings are not. The details, and a correction to my own benchmark, are in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/pyspark-udf-showdown" rel="noopener noreferrer"&gt;Stop Blaming Serialization for Slow Python UDFs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Getting Arrow on by default took two tries. &lt;a href="https://issues.apache.org/jira/browse/SPARK-48516" rel="noopener noreferrer"&gt;SPARK-48516&lt;/a&gt; flipped the default for Spark 4.0, and Hyukjin Kwon &lt;a href="https://www.mail-archive.com/commits@spark.apache.org/msg72229.html" rel="noopener noreferrer"&gt;reverted it in March 2025&lt;/a&gt;, before 4.0 shipped. Spark 4.2 finally turned it on (&lt;a href="https://issues.apache.org/jira/browse/SPARK-54555" rel="noopener noreferrer"&gt;SPARK-54555&lt;/a&gt;), for regular Python UDFs, UDTFs, and the pandas conversions, and raised the minimum PyArrow to 18.0.0.&lt;/p&gt;

&lt;p&gt;The 4.3 branch drops Python 3.10 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-56889" rel="noopener noreferrer"&gt;SPARK-56889&lt;/a&gt;) and adds an opt-in pipelined UDF transfer (&lt;code&gt;spark.python.udf.pipelined.enabled&lt;/code&gt;, off by default, &lt;a href="https://issues.apache.org/jira/browse/SPARK-56642" rel="noopener noreferrer"&gt;SPARK-56642&lt;/a&gt;); pandas 3 still isn't fully supported even there (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55139" rel="noopener noreferrer"&gt;SPARK-55139&lt;/a&gt; is open).&lt;/p&gt;

&lt;p&gt;The same Arrow-first direction shows up in the Python Data Source API (&lt;a href="https://issues.apache.org/jira/browse/SPARK-44076" rel="noopener noreferrer"&gt;SPARK-44076&lt;/a&gt;), which lets you write a connector entirely in Python and, since 4.1, receive pushed-down filters. That removes one of the last reasons a Python team needed Scala.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For someone choosing today:&lt;/strong&gt; on 4.2, plain &lt;code&gt;@udf&lt;/code&gt; already gets Arrow transport for free; write new UDFs as &lt;code&gt;@arrow_udf&lt;/code&gt; when the logic maps onto &lt;code&gt;pyarrow.compute&lt;/code&gt; (especially strings), and &lt;code&gt;@pandas_udf&lt;/code&gt; when you need pandas or SciPy.&lt;/p&gt;




&lt;h2&gt;
  
  
  What crossing the boundary cost me
&lt;/h2&gt;

&lt;p&gt;Everything above argues that Connect is where Spark is going. It's worth being equally concrete about the price of getting there, because I paid it in two ways this month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrating a real pipeline.&lt;/strong&gt; I moved the pipelines in my lakehouse-stack project to Spark Connect and started with an audit script over the repository. It reported 27 findings across 24 files, and 21 of them were the same line: &lt;code&gt;spark.sparkContext.setLogLevel("WARN")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That single call was the entire blocking surface&lt;/strong&gt;, and it has no client-side equivalent, because the log level belongs to the server's JVM; the fix is to configure logging where the Connect server starts and delete the call.&lt;/p&gt;

&lt;p&gt;The migration itself was almost mechanical, and after it all ten tables matched their pre-migration versions on schema, row count, and an order-independent content hash (that parity result is recorded in my migration notes rather than as a saved log).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real blocker was the ecosystem.&lt;/strong&gt; Apache Iceberg doesn't publish a Spark 4.2 runtime; the newest on Maven Central targets 4.1. And the 4.1 jar can't simply be reused, because Spark 4.2 changed the catalog interfaces it implements: &lt;code&gt;View&lt;/code&gt; became a class, so loading the old runtime fails with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.IncompatibleClassChangeError: class org.apache.iceberg.spark.source.SparkView
can not implement org.apache.spark.sql.connector.catalog.View, because it is not an interface
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and because that failure happens while the catalog loads, it takes down tables, reads, and writes along with views. 4.2 also requires catalogs that serve both tables and views to implement a new &lt;code&gt;RelationCatalog&lt;/code&gt; interface. I built a local Iceberg runtime for 4.2 to keep going, which is fine for a demo and not something I'd run in production.&lt;/p&gt;

&lt;p&gt;The general lesson is that a Spark minor version can break connectors, and &lt;strong&gt;your upgrade schedule is set by your slowest connector, not by Spark.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixing versions.&lt;/strong&gt; Connect's promise is that client and server can be upgraded independently. I tested all four combinations of 4.1.2 and 4.2.0 clients and servers, running each fourteen-operation matrix twice, with identical results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client → server&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;4.2.0 → 4.2.0&lt;/td&gt;
&lt;td&gt;Everything works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4.1.2 → 4.2.0&lt;/td&gt;
&lt;td&gt;Works; newer APIs simply don't exist in the old client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4.1.2 → 4.1.2&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4.2.0 → 4.1.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;createDataFrame&lt;/code&gt; from a local list fails; &lt;code&gt;GetStatus&lt;/code&gt; returns &lt;code&gt;UNIMPLEMENTED&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;createDataFrame&lt;/code&gt; failure is worth spelling out, because it's so small. A 4.2 client checks the size of local data against a limit it reads from the server (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55047" rel="noopener noreferrer"&gt;SPARK-55047&lt;/a&gt;); a 4.1.2 server reports that limit as &lt;code&gt;3221225472b&lt;/code&gt;, with a unit suffix, and the new client's integer conversion fails on the &lt;code&gt;b&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: invalid literal for int() with base 10: '3221225472b'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a new client can't build a three-row DataFrame on an old server. &lt;strong&gt;Independent upgrades hold in one direction only: upgrade the server first, and pin clients to it.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Left behind: GraphX, SparkR, and the RDD APIs
&lt;/h2&gt;

&lt;p&gt;The last group fails the test for the same structural reason: each is built on RDDs or on a pre-Connect bridge, and none has a path across.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GraphX&lt;/strong&gt; was one of Spark's original libraries (Spark 0.9, 2014), built entirely on RDDs and never given a Python API. Between September 30 and October 8, 2024, the dev list &lt;a href="https://www.mail-archive.com/dev@spark.apache.org/msg32381.html" rel="noopener noreferrer"&gt;voted on Holden Karau's proposal&lt;/a&gt; to officially deprecate it in Spark 4; she noted that it "hasn't been updated meaningfully in several years" and was already "de-facto deprecated," and was explicit that it was "not a vote to remove GraphX." The proposal passed, although the 4.2.0 GraphX programming guide still carries no deprecation notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clearest verdict came from outside the project.&lt;/strong&gt; GraphFrames, &lt;a href="https://doi.org/10.1145/2960414.2960416" rel="noopener noreferrer"&gt;the DataFrame-based graph library&lt;/a&gt; that Berkeley and Databricks released as an external package in 2016, had gone quiet for years; its maintainers revived it, moved it to the &lt;code&gt;io.graphframes&lt;/code&gt; namespace, &lt;a href="https://graphframes.io/02-quick-start/01-installation.html" rel="noopener noreferrer"&gt;added Spark 4 support&lt;/a&gt;, and, starting with 0.10.0, stopped depending on Spark's GraphX at all, shipping their own "internal modified and updated fork of the Apache Spark GraphX" instead.&lt;/p&gt;

&lt;p&gt;Because GraphFrames stores vertices and edges as DataFrames, graph queries go through Catalyst, work from Python, and pass the Connect test's premise:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEdGW1wiR3JhcGhGcmFtZVwiXSAtLT4gVkRbXCJWZXJ0aWNlcyBEYXRhRnJhbWVcIl1cbiAgICBHRiAtLT4gRURbXCJFZGdlcyBEYXRhRnJhbWVcIl1cbiAgICBWRCAtLT4gQ0FUW1wiU3BhcmsgU1FMIC8gQ2F0YWx5c3RcIl1cbiAgICBFRCAtLT4gQ0FUXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEdGW1wiR3JhcGhGcmFtZVwiXSAtLT4gVkRbXCJWZXJ0aWNlcyBEYXRhRnJhbWVcIl1cbiAgICBHRiAtLT4gRURbXCJFZGdlcyBEYXRhRnJhbWVcIl1cbiAgICBWRCAtLT4gQ0FUW1wiU3BhcmsgU1FMIC8gQ2F0YWx5c3RcIl1cbiAgICBFRCAtLT4gQ0FUXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 7" width="457" height="278"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install graphframes-py==0.12.2
# pyspark --packages io.graphframes:graphframes-spark4_2.13:0.12.2
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;graphframes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GraphFrame&lt;/span&gt;

&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GraphFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vertices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# two DataFrames: id, and src/dst
&lt;/span&gt;&lt;span class="n"&gt;ranks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pageRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resetProbability&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxIter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Motif finding: patterns become DataFrame joins that Catalyst can plan
&lt;/span&gt;&lt;span class="n"&gt;triangles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(a)-[]-&amp;gt;(b); (b)-[]-&amp;gt;(c); (c)-[]-&amp;gt;(a)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SparkR&lt;/strong&gt; was contributed in Spark 1.4 (2015) by Shivaram Venkataraman and the AMPLab team. Hyukjin Kwon &lt;a href="https://www.mail-archive.com/commits@spark.apache.org/msg69114.html" rel="noopener noreferrer"&gt;committed its deprecation&lt;/a&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-49347" rel="noopener noreferrer"&gt;SPARK-49347&lt;/a&gt;) in August 2024, and since 4.0 starting a SparkR session prints a warning that it "is deprecated from Apache Spark 4.0.0 and will be removed in a future version." It uses its own JVM bridge and was never ported to Connect, and most R users had already moved to &lt;code&gt;sparklyr&lt;/code&gt;, which is &lt;a href="https://spark.rstudio.com/" rel="noopener noreferrer"&gt;maintained by Posit and supports Spark Connect&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DStreams&lt;/strong&gt; were deprecated in 3.4 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-42075" rel="noopener noreferrer"&gt;SPARK-42075&lt;/a&gt;), and &lt;strong&gt;&lt;code&gt;spark.mllib&lt;/code&gt;&lt;/strong&gt; has been in maintenance mode since 2.0; both are RDD APIs with DataFrame successors that do pass the test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For someone choosing today:&lt;/strong&gt; don't start anything new on these. Use GraphFrames for graphs (it's the only option from Python), &lt;code&gt;sparklyr&lt;/code&gt; for R, Structured Streaming for streams, and &lt;code&gt;spark.ml&lt;/code&gt; for ML, and treat existing code on the old APIs as migration work with a deadline you don't control.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing, module by module
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Reachable over Connect&lt;/th&gt;
&lt;th&gt;What I'd do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spark SQL&lt;/td&gt;
&lt;td&gt;Built for it&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Default choice; push logic here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declarative Pipelines&lt;/td&gt;
&lt;td&gt;Built for it&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Use for multi-table pipelines; AUTO CDC for change feeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured Streaming&lt;/td&gt;
&lt;td&gt;Rebuilt&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Micro-batch for stateful work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-Time Mode&lt;/td&gt;
&lt;td&gt;Rebuilding&lt;/td&gt;
&lt;td&gt;Yes from 4.2 (stateless)&lt;/td&gt;
&lt;td&gt;Stateless only until 4.3; check Python gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MLlib (&lt;code&gt;spark.ml&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Rebuilt&lt;/td&gt;
&lt;td&gt;Yes (since 4.0)&lt;/td&gt;
&lt;td&gt;Fine for existing algorithms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python UDFs&lt;/td&gt;
&lt;td&gt;Rebuilt&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@arrow_udf&lt;/code&gt; first; Arrow transport is default in 4.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GraphX&lt;/td&gt;
&lt;td&gt;Left behind&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Migrate to GraphFrames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SparkR&lt;/td&gt;
&lt;td&gt;Left behind&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Migrate to &lt;code&gt;sparklyr&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DStreams, &lt;code&gt;spark.mllib&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Left behind&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Migrate to DataFrame APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEFbXCJOZWVkIGN1c3RvbSBsb2dpYyBpbiBTcGFya1wiXSAtLT4gQntcIkV4cHJlc3NpYmxlIGluIFNRTCBvcjxici8-YSBkZWNsYXJhdGl2ZSBwaXBlbGluZT9cIn1cbiAgICBCIC0tPnx5ZXN8IENbXCJTcGFyayBTUUwgLyBEZWNsYXJhdGl2ZSBQaXBlbGluZXNcIl1cbiAgICBCIC0tPnxub3wgRHtcIlN0cmVhbWluZz9cIn1cbiAgICBEIC0tPnxcInN0YXRlZnVsXCJ8IEVbXCJTdHJ1Y3R1cmVkIFN0cmVhbWluZyAobWljcm8tYmF0Y2gpXCJdXG4gICAgRCAtLT58XCJzdGF0ZWxlc3MsIGxhdGVuY3ktc2Vuc2l0aXZlXCJ8IEZbXCJSZWFsLVRpbWUgTW9kZSAoNC4yKylcIl1cbiAgICBEIC0tPnxub3wgR3tcIlB5dGhvbiBmdW5jdGlvbiBwZXIgcm93IG9yIGJhdGNoP1wifVxuICAgIEcgLS0-fFwiYmF0Y2gsIG1hcHMgdG8gcHlhcnJvdy5jb21wdXRlXCJ8IEhbXCJAYXJyb3dfdWRmXCJdXG4gICAgRyAtLT58XCJuZWVkcyBwYW5kYXMgLyBTY2lQeVwifCBJW1wiQHBhbmRhc191ZGZcIl1cbiAgICBHIC0tPnxcIm9uZSB2YWx1ZSBhdCBhIHRpbWVcInwgSltcIkB1ZGYgKEFycm93IHRyYW5zcG9ydCBieSBkZWZhdWx0IG9uIDQuMilcIl1cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEFbXCJOZWVkIGN1c3RvbSBsb2dpYyBpbiBTcGFya1wiXSAtLT4gQntcIkV4cHJlc3NpYmxlIGluIFNRTCBvcjxici8-YSBkZWNsYXJhdGl2ZSBwaXBlbGluZT9cIn1cbiAgICBCIC0tPnx5ZXN8IENbXCJTcGFyayBTUUwgLyBEZWNsYXJhdGl2ZSBQaXBlbGluZXNcIl1cbiAgICBCIC0tPnxub3wgRHtcIlN0cmVhbWluZz9cIn1cbiAgICBEIC0tPnxcInN0YXRlZnVsXCJ8IEVbXCJTdHJ1Y3R1cmVkIFN0cmVhbWluZyAobWljcm8tYmF0Y2gpXCJdXG4gICAgRCAtLT58XCJzdGF0ZWxlc3MsIGxhdGVuY3ktc2Vuc2l0aXZlXCJ8IEZbXCJSZWFsLVRpbWUgTW9kZSAoNC4yKylcIl1cbiAgICBEIC0tPnxub3wgR3tcIlB5dGhvbiBmdW5jdGlvbiBwZXIgcm93IG9yIGJhdGNoP1wifVxuICAgIEcgLS0-fFwiYmF0Y2gsIG1hcHMgdG8gcHlhcnJvdy5jb21wdXRlXCJ8IEhbXCJAYXJyb3dfdWRmXCJdXG4gICAgRyAtLT58XCJuZWVkcyBwYW5kYXMgLyBTY2lQeVwifCBJW1wiQHBhbmRhc191ZGZcIl1cbiAgICBHIC0tPnxcIm9uZSB2YWx1ZSBhdCBhIHRpbWVcInwgSltcIkB1ZGYgKEFycm93IHRyYW5zcG9ydCBieSBkZWZhdWx0IG9uIDQuMilcIl1cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 8" width="999" height="1095"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part of this that surprised me most is how little of the sorting is about quality. GraphX isn't fading because its algorithms were bad, and SparkR isn't fading because R users were wrong; &lt;strong&gt;they're fading because they sit on the wrong side of a client boundary the project has decided to build everything else around.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My Py4J helper was on that side too; it still works in classic mode, and on 4.2 nothing needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://issues.apache.org/jira/browse/SPARK-54633" rel="noopener noreferrer"&gt;SPARK-54633&lt;/a&gt;: Quarterly release cadence SPIP.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>pyspark</category>
      <category>sparkconnect</category>
      <category>streaming</category>
    </item>
    <item>
      <title>AUTO_CDC: Turning an event log into current state with Spark Declarative Pipelines</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:20:43 +0000</pubDate>
      <link>https://dev.to/lisancao/autocdc-turning-an-event-log-into-current-state-with-spark-declarative-pipelines-21ep</link>
      <guid>https://dev.to/lisancao/autocdc-turning-an-event-log-into-current-state-with-spark-declarative-pipelines-21ep</guid>
      <description>&lt;h2&gt;
  
  
  The question an event log can't answer cheaply
&lt;/h2&gt;

&lt;p&gt;The dataset I use for most of my pipeline demos is a simulated ghost kitchen (a delivery-only restaurant operation), and like most operational data it arrives as an append-only log. Every order produces a lifecycle of events, each stamped with a time and a per-order sequence number, and a day of it is about a million events. The generator deliberately injects a few percent of late and duplicated events, because real streams do that.&lt;/p&gt;

&lt;p&gt;Logs like this are excellent at answering what happened and awkward at answering what operations people actually ask: which orders are live right now, and where is each one?&lt;/p&gt;

&lt;p&gt;The usual answer is to recompute it. Group the whole history by order, take the latest event for each, drop the cancelled ones, and repeat on every refresh, paying for the entire history every time and getting it subtly wrong whenever "latest" means latest to arrive rather than latest to happen.&lt;/p&gt;

&lt;p&gt;The better answer depends on noticing something that sounds obvious once it's said: &lt;strong&gt;an append-only lifecycle log is already a change data capture feed.&lt;/strong&gt; Each event is a change to the current state of one entity (the order), keyed by order id, sequenced by when it happened, and a cancellation is simply a delete.&lt;/p&gt;

&lt;p&gt;Here is an illustrative order (simplified; the real sequence is described below), first as the lifecycle log records it, then as the change feed it implies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;seq&lt;/th&gt;
&lt;th&gt;event_type&lt;/th&gt;
&lt;th&gt;as a change to &lt;code&gt;orders_live&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;order_created&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;order_created&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kitchen_started&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;kitchen_started&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;order_ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;order_ready&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;driver_picked_up&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;driver_picked_up&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;driver_ping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;driver_ping&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delivered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UPSERT order 1042, status = &lt;code&gt;delivered&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An order that ends in &lt;code&gt;order_cancelled&lt;/code&gt; produces a DELETE as its last change instead, and disappears from the table. Once the log is seen this way, "current status of every order" stops being an aggregation problem and becomes the problem of applying a change feed to a table, which is exactly what AUTO CDC was built to do.&lt;/p&gt;

&lt;p&gt;My argument is that AUTO CDC is worth adopting less because it saves code (though it does, a lot) than because it moves the hard parts of change application into the engine, and leaves you with a small, explicit contract: &lt;strong&gt;a sequence column that is unique and never null, a target that genuinely supports row-level operations, and deletes expressed as events.&lt;/strong&gt; Most of what goes wrong with hand-rolled CDC is the engine's side of that contract done badly; most of what can still go wrong with AUTO CDC is your side of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AUTO CDC is, and where it stands
&lt;/h2&gt;

&lt;p&gt;AUTO CDC began as &lt;code&gt;APPLY CHANGES&lt;/code&gt; in Databricks' Delta Live Tables and was contributed to Apache Spark under its own SPIP (&lt;a href="https://issues.apache.org/jira/browse/SPARK-56249" rel="noopener noreferrer"&gt;SPARK-56249&lt;/a&gt;), as part of &lt;a href="https://issues.apache.org/jira/browse/SPARK-51727" rel="noopener noreferrer"&gt;Spark Declarative Pipelines&lt;/a&gt;. In Python it's &lt;code&gt;create_auto_cdc_flow&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;AUTO CDC status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spark 4.2.0 (July 2026)&lt;/td&gt;
&lt;td&gt;Shipped. SCD Type 1 via the Python / Spark Connect API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spark 4.3.0 (release candidate)&lt;/td&gt;
&lt;td&gt;Adds SCD Type 2 (history with validity ranges)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;main branch (5.0.0-SNAPSHOT)&lt;/td&gt;
&lt;td&gt;Ongoing work under SPARK-56249&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything I tested was &lt;em&gt;SCD Type 1&lt;/em&gt; (the target holds only the current state per key, and updates overwrite in place), on a build of the main branch from late May 2026, before &lt;a href="https://spark.apache.org/news/" rel="noopener noreferrer"&gt;4.2 was released&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Applying a change feed correctly means doing several things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;matching each change to its row by key&lt;/li&gt;
&lt;li&gt;keeping the version with the highest sequence per key, not the last to arrive&lt;/li&gt;
&lt;li&gt;honouring deletes, but only when the delete is the latest thing that happened to that key&lt;/li&gt;
&lt;li&gt;staying idempotent when a streaming source redelivers after a failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hand-rolled version is usually a &lt;code&gt;foreachBatch&lt;/code&gt; with windowed deduplication feeding a &lt;code&gt;MERGE&lt;/code&gt;, and &lt;strong&gt;it tends to get the cross-batch cases wrong without throwing errors&lt;/strong&gt;. A late update that lands in the micro-batch after a newer one quietly overwrites it, and nobody notices until the numbers disagree with something else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIExPR1tcIm9yZGVyX2V2ZW50czxici8-KGFwcGVuZC1vbmx5IGxpZmVjeWNsZSBsb2cpXCJdIC0tPiBQUk9KW1wicHJvamVjdGlvbjxici8-b3AgPSBVUFNFUlQgLyBERUxFVEU8YnIvPnNlcSA9IHVuaXF1ZSBwZXIgb3JkZXJcIl1cbiAgICBQUk9KIC0tPiBTUkNbXCJzdHJlYW1pbmcgc291cmNlPGJyLz5AZHAudGFibGVcIl1cbiAgICBTUkMgLS0-IEZMT1dbXCJjcmVhdGVfYXV0b19jZGNfZmxvdzxici8-a2V5cyA9IG9yZGVyX2lkPGJyLz5zZXF1ZW5jZV9ieSA9IHNlcVwiXVxuICAgIEZMT1cgLS0-IFRHVFtcImdvbGQub3JkZXJzX2xpdmU8YnIvPihJY2ViZXJnLCBvbmUgcm93IHBlciBsaXZlIG9yZGVyKVwiXVxuICAgIEZMT1cgPC0tPiBBVVhbXCJfX3NwYXJrX2F1dG9jZGNfYXV4X3N0YXRlXy4uLjxici8-bGF0ZXN0IHNlcSBwZXIga2V5LCBhY3Jvc3MgcnVuc1wiXVxuICAgIExPRyAtLi0-fFwibmFpdmUgYWx0ZXJuYXRpdmU6PGJyLz5yZS1hZ2dyZWdhdGUgYWxsIGhpc3RvcnkgZXZlcnkgcmVmcmVzaFwifCBOQUlWRVtcImxhdGVzdC1ldmVudC1wZXItb3JkZXI8YnIvPmZ1bGwgcmVjb21wdXRlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIExPR1tcIm9yZGVyX2V2ZW50czxici8-KGFwcGVuZC1vbmx5IGxpZmVjeWNsZSBsb2cpXCJdIC0tPiBQUk9KW1wicHJvamVjdGlvbjxici8-b3AgPSBVUFNFUlQgLyBERUxFVEU8YnIvPnNlcSA9IHVuaXF1ZSBwZXIgb3JkZXJcIl1cbiAgICBQUk9KIC0tPiBTUkNbXCJzdHJlYW1pbmcgc291cmNlPGJyLz5AZHAudGFibGVcIl1cbiAgICBTUkMgLS0-IEZMT1dbXCJjcmVhdGVfYXV0b19jZGNfZmxvdzxici8-a2V5cyA9IG9yZGVyX2lkPGJyLz5zZXF1ZW5jZV9ieSA9IHNlcVwiXVxuICAgIEZMT1cgLS0-IFRHVFtcImdvbGQub3JkZXJzX2xpdmU8YnIvPihJY2ViZXJnLCBvbmUgcm93IHBlciBsaXZlIG9yZGVyKVwiXVxuICAgIEZMT1cgPC0tPiBBVVhbXCJfX3NwYXJrX2F1dG9jZGNfYXV4X3N0YXRlXy4uLjxici8-bGF0ZXN0IHNlcSBwZXIga2V5LCBhY3Jvc3MgcnVuc1wiXVxuICAgIExPRyAtLi0-fFwibmFpdmUgYWx0ZXJuYXRpdmU6PGJyLz5yZS1hZ2dyZWdhdGUgYWxsIGhpc3RvcnkgZXZlcnkgcmVmcmVzaFwifCBOQUlWRVtcImxhdGVzdC1ldmVudC1wZXItb3JkZXI8YnIvPmZ1bGwgcmVjb21wdXRlXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="1579" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;The projection from lifecycle log to change feed is ordinary Spark. This is the core of what my test harness does: every event becomes an UPSERT of the order's current status, a cancellation becomes a DELETE, and the sequence is made unique per order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_cancelled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
     &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;otherwise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPSERT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# unique per-key ordering: event time, broken by per-order sequence
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the pipeline itself is three declarations: a streaming source, a target streaming table, and the flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pipelines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;

&lt;span class="nd"&gt;@dp.table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_cdc_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_cdc_source&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ORDER_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file:///data/order_cdc/*.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_streaming_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ice.gold.orders_live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_auto_cdc_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ice.gold.orders_live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_cdc_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                  &lt;span class="c1"&gt;# how a change finds its row
&lt;/span&gt;    &lt;span class="n"&gt;sequence_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;# highest sequence per key wins, whatever the arrival order
&lt;/span&gt;    &lt;span class="n"&gt;apply_as_deletes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# which events delete the row
&lt;/span&gt;    &lt;span class="n"&gt;except_column_list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# bookkeeping columns to leave out of the target
&lt;/span&gt;    &lt;span class="n"&gt;stored_as_scd_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine handles what the hand-rolled version gets wrong: deduplication across micro-batches by &lt;code&gt;sequence_by&lt;/code&gt;, the generated upsert and delete, exactly-once checkpointing, and the auxiliary state table that remembers the latest sequence per key across runs.&lt;/p&gt;

&lt;p&gt;That table is what lets a late event arriving in a later batch resolve correctly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Recompute from the log&lt;/th&gt;
&lt;th&gt;AUTO CDC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per refresh&lt;/td&gt;
&lt;td&gt;re-aggregates the entire history&lt;/td&gt;
&lt;td&gt;processes only new events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Out-of-order events&lt;/td&gt;
&lt;td&gt;"latest" by arrival is wrong&lt;/td&gt;
&lt;td&gt;resolved by &lt;code&gt;sequence_by&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancellations&lt;/td&gt;
&lt;td&gt;a row every query must remember to filter&lt;/td&gt;
&lt;td&gt;the row is deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;bespoke &lt;code&gt;foreachBatch&lt;/code&gt; + dedup + &lt;code&gt;MERGE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;three declarations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Running it on messy data
&lt;/h2&gt;

&lt;p&gt;I ran the order-status projection on a sample of the ghost-kitchen stream: a little under five thousand orders and roughly 125,000 of their lifecycle events, including the injected late and duplicate ones. AUTO CDC collapsed them into one row per live order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;gold.orders_live&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delivered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,601&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;driver_ping&lt;/code&gt; (mid-delivery)&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order_ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order_created&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kitchen_started&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;driver_picked_up&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total live orders&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,691&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cancelled orders removed by &lt;code&gt;apply_as_deletes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;308&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To check it, I recomputed the same table from scratch the naive way and compared the two in both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;over&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rn = 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op != &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ice.gold.orders_live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="c1"&gt;# match = True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They matched exactly.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what that proves: it compares the two columns the table exists to answer, as sets, rather than every column byte for byte.&lt;/p&gt;

&lt;p&gt;Two details of the run also deserve honesty, because they're the kind of thing that makes a demo look cleaner than it was. The sample was drawn by sorting order ids and taking the first five thousand, and because the raw data contains events with a null order id (which Spark sorts first), the sample was really 4,999 orders.&lt;/p&gt;

&lt;p&gt;And the projection joined each event to its order's creation event, so orders with duplicated creation events fanned out into duplicate rows in the feed. AUTO CDC handled both without complaint, since duplicate copies of an event carry the same sequence value and resolve to the same row, but a pipeline that quietly processes inputs it wasn't meant to receive is a reminder to &lt;strong&gt;look at your inputs before trusting your outputs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The second scenario was the textbook one: keeping a dimension current as kitchens relocate, rename, and close. These are the exact changes streamed into &lt;code&gt;silver.dim_locations_current&lt;/code&gt;, on top of an initial load of four locations at sequence 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Los Angeles - Arts District&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPSERT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;San Francisco - SoMa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPSERT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Chicago&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# kitchen closed
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Los Angeles (STALE)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPSERT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# arrives late, older
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;after AUTO CDC&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;1&lt;/td&gt;
&lt;td&gt;San Francisco - SoMa&lt;/td&gt;
&lt;td&gt;rename at seq 4 applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Los Angeles - Arts District&lt;/td&gt;
&lt;td&gt;rename at seq 5 kept; the stale seq 3 edit ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;(no row)&lt;/td&gt;
&lt;td&gt;closure at seq 6 deleted it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Austin&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A nightly full reload would eventually reach the same names, but it copies every unchanged row to get there and has no way at all to express that a location no longer exists.&lt;/p&gt;

&lt;p&gt;That's the more general lesson: an append-only or aggregate pipeline can only add facts, so "this was cancelled" becomes another row every downstream query has to remember to filter. &lt;strong&gt;In a CDC pipeline a terminal business event can be the delete itself&lt;/strong&gt;, and the same mechanism propagates hard deletes for compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases, and how Type 1 resolves them
&lt;/h2&gt;

&lt;p&gt;I put SCD Type 1 through a battery of adversarial cases, and it behaved correctly in every one. The behaviours are worth knowing precisely, because this is where intuition tends to be wrong. These are the cases from my stress harness, with the events in arrival order; &lt;code&gt;|&lt;/code&gt; separates micro-batches.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Events for one key&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;Upsert to a key that never existed&lt;/td&gt;
&lt;td&gt;UPSERT@1&lt;/td&gt;
&lt;td&gt;row inserted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete of a key that never existed&lt;/td&gt;
&lt;td&gt;DELETE@1&lt;/td&gt;
&lt;td&gt;no-op&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several updates in one batch&lt;/td&gt;
&lt;td&gt;UPSERT@1, UPSERT@2, UPSERT@3&lt;/td&gt;
&lt;td&gt;row has the @3 values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale delete&lt;/td&gt;
&lt;td&gt;UPSERT@1 | UPSERT@3 | DELETE@2&lt;/td&gt;
&lt;td&gt;row stays, with the @3 values; the update is newer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale insert after a delete&lt;/td&gt;
&lt;td&gt;UPSERT@3 | DELETE@5 | UPSERT@2&lt;/td&gt;
&lt;td&gt;row stays deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete arrives before an older insert&lt;/td&gt;
&lt;td&gt;DELETE@5 | UPSERT@2&lt;/td&gt;
&lt;td&gt;row stays deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resurrection&lt;/td&gt;
&lt;td&gt;UPSERT@1 | DELETE@2 | UPSERT@3&lt;/td&gt;
&lt;td&gt;row present, with the @3 values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume, out of order&lt;/td&gt;
&lt;td&gt;500 keys, each UPSERT@1, @3, @2 shuffled across 5 batches, plus DELETE@5 for 100 of them&lt;/td&gt;
&lt;td&gt;exactly 400 rows, all with the @3 values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tie (probe, not pass/fail)&lt;/td&gt;
&lt;td&gt;UPSERT@5 "Boston", UPSERT@5 "Denver" in one batch&lt;/td&gt;
&lt;td&gt;one of them, chosen arbitrarily&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Your side of the contract
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sequence.&lt;/strong&gt; A null in the &lt;code&gt;sequence_by&lt;/code&gt; column fails the micro-batch with &lt;code&gt;AUTOCDC_MICROBATCH_VALIDATION.NULL_SEQUENCE&lt;/code&gt; rather than guessing, which is the right behaviour, and means the column has to be non-null by construction.&lt;/p&gt;

&lt;p&gt;Ties are worse, because they don't fail: two changes to the same key with the same sequence in one micro-batch are resolved arbitrarily, as &lt;a href="https://github.com/apache/spark/blob/master/docs/declarative-pipelines-programming-guide.md" rel="noopener noreferrer"&gt;the programming guide&lt;/a&gt; itself warns. A coarse timestamp is the classic way to produce ties, so &lt;strong&gt;sequence on something strictly increasing per key&lt;/strong&gt;, like a log offset or commit LSN. My composite of timestamp times 100 plus the per-order sequence works only because no order in this data has more than 99 events; it's a local trick, not a general recipe. The ties that did occur in my data were all injected exact duplicates, the one case where an arbitrary choice is harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The target.&lt;/strong&gt; AUTO CDC needs a connector that implements Spark's DataSource V2 row-level operation interface (&lt;code&gt;SupportsRowLevelOperations&lt;/code&gt;), and &lt;em&gt;"supports MERGE"&lt;/em&gt; is not the same thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Result in my testing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain Parquet&lt;/td&gt;
&lt;td&gt;rejected immediately: &lt;code&gt;AUTOCDC_TARGET_DOES_NOT_SUPPORT_MERGE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta&lt;/td&gt;
&lt;td&gt;did not work: Delta's table implementation doesn't expose &lt;code&gt;SupportsRowLevelOperations&lt;/code&gt;, despite supporting MERGE elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, Hadoop catalog&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, JDBC (Postgres) catalog&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, Hive Metastore catalog&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, Gravitino REST catalog&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, Glue catalog (emulated)&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg, Unity Catalog OSS REST&lt;/td&gt;
&lt;td&gt;failed: the catalog is read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any ephemeral / in-memory catalog&lt;/td&gt;
&lt;td&gt;unsuitable: the aux state table must persist across restarts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two smaller surprises: the target carries a hidden &lt;code&gt;__spark_autocdc_metadata&lt;/code&gt; column that shows up in &lt;code&gt;SELECT *&lt;/code&gt;, and Iceberg's own changelog is batch-only in open-source Spark for now, so you can't stream one Iceberg table's changes straight into AUTO CDC as a source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A correction.&lt;/strong&gt; I originally reported that running an AUTO CDC pipeline through the &lt;code&gt;spark-pipelines&lt;/code&gt; CLI failed at registration with &lt;code&gt;InvalidPlanInput: RELTYPE_NOT_SET&lt;/code&gt;, and called it a bug. It wasn't.&lt;/p&gt;

&lt;p&gt;The CLI connects to the default Spark Connect endpoint, and an old Spark 4.1 Connect server was still listening there, so my pipeline was being sent to a server that had never heard of AUTO CDC. Against a server of the right version the CLI works correctly. If you see that error, &lt;strong&gt;check what's actually listening on &lt;code&gt;sc://localhost:15002&lt;/code&gt; before anything else.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes
&lt;/h2&gt;

&lt;p&gt;With a hand-rolled pipeline, the question you're left with is whether you've correctly implemented cross-batch deduplication, sequenced deletes, and idempotent merges, and the honest answer is usually that you won't know until something disagrees. With AUTO CDC that part belongs to the engine, which is tested against exactly the cases people get wrong.&lt;/p&gt;

&lt;p&gt;The question becomes whether you've given it a sequence that is unique and never null, a target that really supports row-level operations, and a delete for everything that should be allowed to disappear. That's a much better question to be left with, and the first step is often not a new data source at all; it's noticing that &lt;strong&gt;the event log you already have has been a change feed all along&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Test harnesses and results: &lt;code&gt;lakehouse-stack/scripts/pipelines/autocdc-scd1&lt;/code&gt; (Spark main branch, 5.0.0-SNAPSHOT at 06ffa273, with an Iceberg runtime ported to Spark 5.0).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>declarativepipelines</category>
      <category>cdc</category>
      <category>iceberg</category>
    </item>
    <item>
      <title>From Pickle to Arrow: A Decade of Moving Data Between Spark's JVM and Python</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:19:50 +0000</pubDate>
      <link>https://dev.to/lisancao/explaining-the-python-jvm-boundary-in-spark-and-what-arrow-changed-59ke</link>
      <guid>https://dev.to/lisancao/explaining-the-python-jvm-boundary-in-spark-and-what-arrow-changed-59ke</guid>
      <description>&lt;p&gt;&lt;strong&gt;Arrow's real contribution to PySpark isn't speed at any one API but a shared memory format at every boundary between the JVM and Python; each release removed one conversion at one boundary, what each one left behind explains the next, and the conversion that remains now lives inside Spark's own engine rather than at the Python edge.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;That crossing is the oldest problem in PySpark, and the most persistent attempt to fix it started in February 2016, when Wes McKinney (the creator of pandas) filed a Spark ticket with a modest title: &lt;a href="https://issues.apache.org/jira/browse/SPARK-13534" rel="noopener noreferrer"&gt;SPARK-13534&lt;/a&gt;, "Implement Apache Arrow serializer for Spark DataFrame for use in DataFrame.toPandas." It asked for one method to stop using pickle.&lt;/p&gt;

&lt;p&gt;It took two years to ship. And over the decade that followed, the same idea went through every other door between Spark's JVM and Python: UDFs, partition-level APIs, the network protocol of Spark Connect, custom data sources, until in Spark 4.1 a Python function could receive Spark's data as an Arrow array and never touch pandas at all, and in Spark 4.2 the Arrow paths &lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;finally became the defaults&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;PySpark is not a Python reimplementation of Spark. It's a Python client for a JVM engine. When you write &lt;code&gt;df.filter(col("x") &amp;gt; 5).groupBy("y").count()&lt;/code&gt;, Python builds a query plan and hands it to the JVM, which optimizes, distributes, and executes it; &lt;strong&gt;Python never touches the data&lt;/strong&gt;, which is why DataFrame code runs at the same speed in PySpark and Scala.&lt;/p&gt;

&lt;p&gt;The trouble starts the moment your own code needs the data: a UDF, &lt;code&gt;toPandas()&lt;/code&gt;, &lt;code&gt;createDataFrame(pandas_df)&lt;/code&gt;, a custom data source. Then data has to physically cross from the JVM into a Python process, and for most of Spark's history the only way across was pickle, one row at a time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEogYXMgSlZNIGV4ZWN1dG9yIChTcGFyayB0YXNrKVxuICAgIHBhcnRpY2lwYW50IFAgYXMgUHl0aG9uIHdvcmtlciAoeW91ciBVREYpXG4gICAgTm90ZSBvdmVyIEosUDogVENQIGxvb3BiYWNrIHNvY2tldCwgYmVmb3JlIEFycm93XG4gICAgbG9vcCBvbmNlIGZvciBldmVyeSByb3cgaW4gdGhlIHBhcnRpdGlvblxuICAgICAgICBKLT4-UDogcGlja2xlKHJvdylcbiAgICAgICAgUC0-PlA6IHVucGlja2xlLCBjYWxsIHlvdXIgZnVuY3Rpb24sIHBpY2tsZSB0aGUgcmVzdWx0XG4gICAgICAgIFAtPj5KOiBwaWNrbGUocmVzdWx0KVxuICAgICAgICBKLT4-SjogdW5waWNrbGUocmVzdWx0KVxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEogYXMgSlZNIGV4ZWN1dG9yIChTcGFyayB0YXNrKVxuICAgIHBhcnRpY2lwYW50IFAgYXMgUHl0aG9uIHdvcmtlciAoeW91ciBVREYpXG4gICAgTm90ZSBvdmVyIEosUDogVENQIGxvb3BiYWNrIHNvY2tldCwgYmVmb3JlIEFycm93XG4gICAgbG9vcCBvbmNlIGZvciBldmVyeSByb3cgaW4gdGhlIHBhcnRpdGlvblxuICAgICAgICBKLT4-UDogcGlja2xlKHJvdylcbiAgICAgICAgUC0-PlA6IHVucGlja2xlLCBjYWxsIHlvdXIgZnVuY3Rpb24sIHBpY2tsZSB0aGUgcmVzdWx0XG4gICAgICAgIFAtPj5KOiBwaWNrbGUocmVzdWx0KVxuICAgICAgICBKLT4-SjogdW5waWNrbGUocmVzdWx0KVxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="596" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every row pays four serialization steps and a function call. For a UDF like &lt;code&gt;return x * 1.1&lt;/code&gt;, the arithmetic is nanoseconds and the machinery around it is microseconds, &lt;strong&gt;three orders of magnitude of overhead wrapped around almost no work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was PySpark from 2015 to 2018, and the same crossing existed at every door between the two runtimes, on both the driver and the executors:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggSlZNW1wiSlZNXCJdXG4gICAgRFJWW1wiZHJpdmVyXCJdOjo6anZtXG4gICAgRVhbXCJleGVjdXRvciB0YXNrc1wiXTo6Omp2bVxuICAgIEVYMltcImV4ZWN1dG9yIHRhc2tzPGJyLz4oc2Nhbm5pbmcgYSBQeXRob24gc291cmNlKVwiXTo6Omp2bVxuICAgIENTW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXJcIl06Ojpqdm1cbiAgZW5kXG4gIHN1YmdyYXBoIFBZW1wiUHl0aG9uXCJdXG4gICAgUERbXCJQeXRob24gZHJpdmVyPGJyLz50b1BhbmRhcygpLCBjcmVhdGVEYXRhRnJhbWUoKVwiXTo6OnB5XG4gICAgUFdbXCJQeXRob24gd29ya2Vyczxici8-VURGcywgbWFwSW4qLCBhcHBseUluKlwiXTo6OnB5XG4gICAgRFNbXCJQeXRob24gZGF0YSBzb3VyY2U8YnIvPnJlYWRlclwiXTo6OnB5XG4gICAgQ0NbXCJTcGFyayBDb25uZWN0IGNsaWVudDxici8-KHJlbW90ZSwgbm8gSlZNKVwiXTo6OnB5XG4gIGVuZFxuICBEUlYgLS0tfFwiY29sbGVjdGVkIHJlc3VsdHMsPGJyLz5sb2NhbCBEYXRhRnJhbWVzXCJ8IFBEXG4gIEVYIC0tLXxcImJhdGNoZXMgb2Ygcm93cyw8YnIvPnBlciB0YXNrXCJ8IFBXXG4gIEVYMiAtLS18XCJ0dXBsZXMgb3IgUmVjb3JkQmF0Y2hlc1wifCBEU1xuICBDUyAtLS18XCJwbGFucyBpbiwgcmVzdWx0cyBvdXQ8YnIvPihnUlBDKVwifCBDQ1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggSlZNW1wiSlZNXCJdXG4gICAgRFJWW1wiZHJpdmVyXCJdOjo6anZtXG4gICAgRVhbXCJleGVjdXRvciB0YXNrc1wiXTo6Omp2bVxuICAgIEVYMltcImV4ZWN1dG9yIHRhc2tzPGJyLz4oc2Nhbm5pbmcgYSBQeXRob24gc291cmNlKVwiXTo6Omp2bVxuICAgIENTW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXJcIl06Ojpqdm1cbiAgZW5kXG4gIHN1YmdyYXBoIFBZW1wiUHl0aG9uXCJdXG4gICAgUERbXCJQeXRob24gZHJpdmVyPGJyLz50b1BhbmRhcygpLCBjcmVhdGVEYXRhRnJhbWUoKVwiXTo6OnB5XG4gICAgUFdbXCJQeXRob24gd29ya2Vyczxici8-VURGcywgbWFwSW4qLCBhcHBseUluKlwiXTo6OnB5XG4gICAgRFNbXCJQeXRob24gZGF0YSBzb3VyY2U8YnIvPnJlYWRlclwiXTo6OnB5XG4gICAgQ0NbXCJTcGFyayBDb25uZWN0IGNsaWVudDxici8-KHJlbW90ZSwgbm8gSlZNKVwiXTo6OnB5XG4gIGVuZFxuICBEUlYgLS0tfFwiY29sbGVjdGVkIHJlc3VsdHMsPGJyLz5sb2NhbCBEYXRhRnJhbWVzXCJ8IFBEXG4gIEVYIC0tLXxcImJhdGNoZXMgb2Ygcm93cyw8YnIvPnBlciB0YXNrXCJ8IFBXXG4gIEVYMiAtLS18XCJ0dXBsZXMgb3IgUmVjb3JkQmF0Y2hlc1wifCBEU1xuICBDUyAtLS18XCJwbGFucyBpbiwgcmVzdWx0cyBvdXQ8YnIvPihnUlBDKVwifCBDQ1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="856" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fix was a memory format, not a faster pickle
&lt;/h2&gt;

&lt;p&gt;Arrow was conceived in late 2015 by people who had hit the same wall from different directions. McKinney had spent years fighting pandas' memory model; his 2017 essay &lt;a href="https://wesmckinney.com/blog/apache-arrow-pandas-internals/" rel="noopener noreferrer"&gt;&lt;em&gt;"Apache Arrow and the 10 Things I Hate About pandas"&lt;/em&gt;&lt;/a&gt; lists the grievances, including a rule of thumb that pandas users needed "5 to 10 times as much RAM as the size of your dataset."&lt;/p&gt;

&lt;p&gt;Jacques Nadeau, building a query engine at Dremio, saw every data system inventing its own internal format and paying to translate between them at every boundary. Marcel Kornacker, who created Impala at Cloudera, saw the same copying across the Hadoop ecosystem. &lt;a href="https://www.dremio.com/blog/origin-and-history-of-apache-arrow/" rel="noopener noreferrer"&gt;They met at Strata NYC&lt;/a&gt; in September 2015, and Arrow was announced as an Apache top-level project in February 2016, skipping the incubator entirely, which is unusual and reflected how many companies wanted the same thing.&lt;/p&gt;

&lt;p&gt;The idea was not a faster serializer. &lt;strong&gt;It was to agree on the bytes themselves:&lt;/strong&gt; &lt;a href="https://arrow.apache.org/docs/format/Columnar.html" rel="noopener noreferrer"&gt;a language-independent, columnar, in-memory layout&lt;/a&gt; specified down to byte alignment, null bitmaps, and buffer order, so that two Arrow-aware systems can hand each other data without converting it.&lt;/p&gt;

&lt;p&gt;Within a process, or with shared memory, that's genuinely &lt;em&gt;zero-copy&lt;/em&gt;; across a socket it's still a copy, but with no per-row decoding on either side, because the bytes on the wire are already the bytes in memory.&lt;/p&gt;

&lt;p&gt;Columnar mattered as much as standard. In a row layout, values from one column are interleaved with everything else in the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;row layout:     [id=1, name="Alice", age=30] [id=2, name="Bob", age=25] [id=3, name="Carol", age=35]
columnar:       id:   [1, 2, 3]
                name: ["Alice", "Bob", "Carol"]
                age:  [30, 25, 35]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With columns stored contiguously, a 64-byte cache line holds eight useful &lt;code&gt;int64&lt;/code&gt; values instead of one row's mixed fields, and SIMD instructions can process a run of values at once.&lt;/p&gt;

&lt;p&gt;The database literature had already shown what this buys: the &lt;a href="https://www.cidrdb.org/cidr2005/papers/P19.pdf" rel="noopener noreferrer"&gt;MonetDB/X100 work&lt;/a&gt; (CIDR 2005) argued that pushing batches of around a thousand column values through each operator, instead of one row at a time, is an order of magnitude or two faster. Arrow's batch-oriented design was built around exactly that model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Boundary one: the driver (2018)
&lt;/h2&gt;

&lt;p&gt;The first door was the smallest. &lt;code&gt;DataFrame.toPandas()&lt;/code&gt; collected results to the driver by pickling every row and rebuilding each one as Python objects before pandas could wrap them, and it was notoriously slow. &lt;a href="https://issues.apache.org/jira/browse/SPARK-13534" rel="noopener noreferrer"&gt;McKinney's ticket&lt;/a&gt;, filed on February 28, 2016, three weeks after the first commit to the Arrow repository, asked to replace that path. It stayed open for over a year. Bryan Cutler, at IBM's Spark Technology Center, resolved it on June 23, 2017, and it reached users in Spark 2.3.0 in February 2018.&lt;/p&gt;

&lt;p&gt;Two years, for one method.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIGp2bSBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIHB5IGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gICAgc3ViZ3JhcGggSlZNW1wiSlZNIGRyaXZlclwiXVxuICAgICAgICBBW1wiSW50ZXJuYWxSb3cgYmF0Y2hlc1wiXTo6Omp2bSAtLT4gQltcIkFycm93IFJlY29yZEJhdGNoZXNcIl06Ojpqdm1cbiAgICBlbmRcbiAgICBzdWJncmFwaCBQWVtcIlB5dGhvbiBkcml2ZXJcIl1cbiAgICAgICAgQ1tcInB5YXJyb3cgcmVhZHMgUmVjb3JkQmF0Y2hlczxici8-KGEgY29weSBhY3Jvc3MgcHJvY2Vzc2VzLDxici8-bm8gcGVyLXJvdyBkZWNvZGluZylcIl06OjpweSAtLT4gRFtcIlRhYmxlLnRvX3BhbmRhcygpPGJyLz4oemVyby1jb3B5IGZvciBtYW55PGJyLz5udW1lcmljIHR5cGVzKVwiXTo6OnB5XG4gICAgZW5kXG4gICAgQiAtLT58XCJBcnJvdyBJUEMgc3RyZWFtXCJ8IENcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIGp2bSBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIHB5IGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gICAgc3ViZ3JhcGggSlZNW1wiSlZNIGRyaXZlclwiXVxuICAgICAgICBBW1wiSW50ZXJuYWxSb3cgYmF0Y2hlc1wiXTo6Omp2bSAtLT4gQltcIkFycm93IFJlY29yZEJhdGNoZXNcIl06Ojpqdm1cbiAgICBlbmRcbiAgICBzdWJncmFwaCBQWVtcIlB5dGhvbiBkcml2ZXJcIl1cbiAgICAgICAgQ1tcInB5YXJyb3cgcmVhZHMgUmVjb3JkQmF0Y2hlczxici8-KGEgY29weSBhY3Jvc3MgcHJvY2Vzc2VzLDxici8-bm8gcGVyLXJvdyBkZWNvZGluZylcIl06OjpweSAtLT4gRFtcIlRhYmxlLnRvX3BhbmRhcygpPGJyLz4oemVyby1jb3B5IGZvciBtYW55PGJyLz5udW1lcmljIHR5cGVzKVwiXTo6OnB5XG4gICAgZW5kXG4gICAgQiAtLT58XCJBcnJvdyBJUEMgc3RyZWFtXCJ8IENcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 3" width="1276" height="212"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spark 2.3 name, later renamed; on by default since Spark 4.2
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.execution.arrow.pyspark.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reverse direction, &lt;code&gt;createDataFrame(pandas_df)&lt;/code&gt;, got the same treatment (&lt;a href="https://issues.apache.org/jira/browse/SPARK-20791" rel="noopener noreferrer"&gt;SPARK-20791&lt;/a&gt;). What made this matter wasn't the one method; it was &lt;a href="https://arrow.apache.org/blog/2017/07/26/spark-arrow/" rel="noopener noreferrer"&gt;the plumbing Cutler built&lt;/a&gt; to do it (Arrow converters on the JVM side, pyarrow integration on the Python side), &lt;strong&gt;which every later boundary reused&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it removed:&lt;/strong&gt; pickle for collecting data to the driver. &lt;strong&gt;What it left:&lt;/strong&gt; everything that happens on executors. UDFs still crossed row by row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary two: UDFs, in batches (2018)
&lt;/h2&gt;

&lt;p&gt;The second door was the one that mattered for performance, and it came from outside Databricks. The proposal, &lt;a href="https://issues.apache.org/jira/browse/SPARK-21190" rel="noopener noreferrer"&gt;SPARK-21190&lt;/a&gt; ("SPIP: Vectorized UDFs in Python"), was filed by Reynold Xin, but most of the implementation was done by Li Jin, an engineer at the quantitative investment firm Two Sigma and an Arrow committer, on top of Cutler's serializer. It shipped in the same Spark 2.3.0 release. Instead of calling your function once per row, Spark would send a batch of rows (10,000 by default, &lt;code&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/code&gt;) as an Arrow RecordBatch and call your function once per batch, with a pandas Series:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: once per row, pickle both ways
&lt;/span&gt;&lt;span class="nd"&gt;@udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returnType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_tax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# After: once per batch, a pandas Series in and out
&lt;/span&gt;&lt;span class="nd"&gt;@pandas_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_tax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.databricks.com/blog/2017/10/30/introducing-vectorized-udfs-for-pyspark.html" rel="noopener noreferrer"&gt;Li Jin's post introducing the feature&lt;/a&gt; in October 2017 reported speedups from around 3x on simple arithmetic to around 100x on a cumulative-probability function, and the spread is the point: &lt;strong&gt;the simpler the function, the more of its cost was the crossing&lt;/strong&gt;, and the more complex the function, the more it also benefits from running as vectorized NumPy instead of Python bytecode.&lt;/p&gt;

&lt;p&gt;My own measurements, eight years and many releases later, land in the same shape. At 50 million rows, a pandas UDF did the arithmetic workload in 0.79 seconds against 5.20 for a pickled UDF, and a normal CDF in 0.82 against 6.18; on a string transform the gap was narrower, 2.51 against 6.22, for a reason that turns out to matter later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it removed:&lt;/strong&gt; per-row serialization and per-row function calls. &lt;strong&gt;What it left:&lt;/strong&gt; a rewrite (your function had to speak pandas), and a conversion. Every batch arrived as Arrow and was turned into pandas before your code saw it, and the result was turned back:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29udiBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBBW1wiSlZNOiBiYXRjaCBvZjxici8-MTAsMDAwIHJvd3NcIl06Ojpqdm0gLS0-fFwiQXJyb3cgSVBDXCJ8IEJbXCJweWFycm93PGJyLz5SZWNvcmRCYXRjaFwiXTo6OnB5XG4gIEIgLS0-fFwiQXJyb3cgdG8gcGFuZGFzLDxici8-cGVyIGJhdGNoXCJ8IENbXCJwYW5kYXMuU2VyaWVzXCJdOjo6Y29udlxuICBDIC0tPiBEW1wieW91ciBmdW5jdGlvbiw8YnIvPm9uY2UgcGVyIGJhdGNoXCJdOjo6cHlcbiAgRCAtLT4gRVtcInBhbmRhcy5TZXJpZXNcIl06Ojpjb252XG4gIEUgLS0-fFwicGFuZGFzIHRvIEFycm93LDxici8-cGVyIGJhdGNoXCJ8IEZbXCJweWFycm93PGJyLz5SZWNvcmRCYXRjaFwiXTo6OnB5XG4gIEYgLS0-fFwiQXJyb3cgSVBDXCJ8IEdbXCJKVk06IHJvd3NcIl06Ojpqdm1cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29udiBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBBW1wiSlZNOiBiYXRjaCBvZjxici8-MTAsMDAwIHJvd3NcIl06Ojpqdm0gLS0-fFwiQXJyb3cgSVBDXCJ8IEJbXCJweWFycm93PGJyLz5SZWNvcmRCYXRjaFwiXTo6OnB5XG4gIEIgLS0-fFwiQXJyb3cgdG8gcGFuZGFzLDxici8-cGVyIGJhdGNoXCJ8IENbXCJwYW5kYXMuU2VyaWVzXCJdOjo6Y29udlxuICBDIC0tPiBEW1wieW91ciBmdW5jdGlvbiw8YnIvPm9uY2UgcGVyIGJhdGNoXCJdOjo6cHlcbiAgRCAtLT4gRVtcInBhbmRhcy5TZXJpZXNcIl06Ojpjb252XG4gIEUgLS0-fFwicGFuZGFzIHRvIEFycm93LDxici8-cGVyIGJhdGNoXCJ8IEZbXCJweWFycm93PGJyLz5SZWNvcmRCYXRjaFwiXTo6OnB5XG4gIEYgLS0-fFwiQXJyb3cgSVBDXCJ8IEdbXCJKVk06IHJvd3NcIl06Ojpqdm1cbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 4" width="1790" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually crosses the socket
&lt;/h2&gt;

&lt;p&gt;Every boundary from here on sends the same thing over the socket: &lt;a href="https://arrow.apache.org/docs/python/ipc.html" rel="noopener noreferrer"&gt;Arrow's IPC stream format&lt;/a&gt;, a schema message followed by record-batch messages, each framed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│ Continuation indicator: 0xFFFFFFFF (4 bytes)                │
│ Metadata length (4 bytes, little-endian int32)              │
│ Flatbuffer metadata (Message type, schema or batch header)  │
│ Padding to 8-byte boundary                                  │
│ Message body (data buffers, padded to 8-byte multiple)      │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the body, each column is a set of plain buffers: a validity bitmap with one bit per value, the values themselves as a contiguous typed array, and, for variable-length types like strings, an offsets array into one contiguous UTF-8 buffer. For four rows of &lt;code&gt;(name: string, age: int32)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Validity bitmap (age):  [1, 1, 0, 1]      → 0b1011 (bit-packed)
Data buffer (age):      [30, 25, 0, 35]   → 16 bytes (4 × int32)
Offsets (name):         [0, 5, 8, 13, 18] → 5 × int32
Data buffer (name):     "AliceBobCarolDavid" (contiguous UTF-8)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No per-value type tags, no escaping, no framing between values.&lt;/p&gt;

&lt;p&gt;Strings are where Arrow and pandas differ most: &lt;strong&gt;in Arrow, a million strings are one buffer and an array of offsets&lt;/strong&gt;, while in traditional pandas they're a million separate Python objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  Boundary three: letting code see Arrow (2022 to 2025)
&lt;/h2&gt;

&lt;p&gt;For four years, every batch API in PySpark handed you pandas, whether you wanted pandas or not.&lt;/p&gt;

&lt;p&gt;The first API that let user code see Arrow directly was &lt;code&gt;mapInArrow&lt;/code&gt;, added by Hyukjin Kwon, a long-time PySpark maintainer at Databricks (&lt;a href="https://issues.apache.org/jira/browse/SPARK-37227" rel="noopener noreferrer"&gt;SPARK-37227&lt;/a&gt;, with the Python side in &lt;a href="https://issues.apache.org/jira/browse/SPARK-37228" rel="noopener noreferrer"&gt;SPARK-37228&lt;/a&gt;), in Spark 3.3.0 in June 2022. It passes whole partitions as an iterator of &lt;code&gt;pyarrow.RecordBatch&lt;/code&gt; objects, and your function yields RecordBatches back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow.compute&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_tax_arrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;taxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_arrays&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;taxed&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                         &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_with_tax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapInArrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;add_tax_arrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id long, price_with_tax double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grouped operations were the obvious remaining gap, and Enrico Minack closed it with &lt;code&gt;applyInArrow&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-40559" rel="noopener noreferrer"&gt;SPARK-40559&lt;/a&gt;), which shipped in Spark 4.0.0 in May 2025. Where &lt;code&gt;applyInPandas&lt;/code&gt; turns each group into a pandas DataFrame, &lt;code&gt;applyInArrow&lt;/code&gt; hands you the group as a &lt;code&gt;pyarrow.Table&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_py&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stddev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_py&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;
                  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_arrays&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normalized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;applyInArrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id long, total double, normalized double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same idea reached the other direction in 4.0 too: the new Python Data Source API (&lt;a href="https://issues.apache.org/jira/browse/SPARK-44076" rel="noopener noreferrer"&gt;SPARK-44076&lt;/a&gt;) lets a custom source written in Python yield &lt;code&gt;pyarrow.RecordBatch&lt;/code&gt; objects instead of tuples (&lt;a href="https://issues.apache.org/jira/browse/SPARK-48493" rel="noopener noreferrer"&gt;SPARK-48493&lt;/a&gt;), so data read by Python code can enter Spark as Arrow instead of being built up one Python tuple at a time.&lt;/p&gt;

&lt;p&gt;I haven't benchmarked the grouped or data-source paths myself, so there are no numbers for them here; the mechanism is the same as everywhere else, and the benefit should be largest where the pandas conversion is most expensive, which is strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it removed:&lt;/strong&gt; the forced detour through pandas, for anyone willing to write Arrow code. &lt;strong&gt;What it left:&lt;/strong&gt; that condition. Most PySpark code is plain &lt;code&gt;@udf&lt;/code&gt; functions that nobody is going to rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary four: the network (2023)
&lt;/h2&gt;

&lt;p&gt;Spark Connect started as a proposal from Martin Grund at Databricks in June 2022 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-39375" rel="noopener noreferrer"&gt;SPARK-39375&lt;/a&gt;): separate the client from the server, so an application talks to Spark over gRPC instead of living inside the driver's JVM. Once there's a network between you and the engine, &lt;strong&gt;the format results travel in stops being an optimization and becomes the protocol&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The answer was Arrow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIGp2bSBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIHB5IGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gICAgc3ViZ3JhcGggQ2xpZW50W1wiU3BhcmsgQ29ubmVjdCBjbGllbnQgKG5vIEpWTSlcIl1cbiAgICAgICAgQzFbXCJQeXRob24gYXBwIGJ1aWxkczxici8-YSBsb2dpY2FsIHBsYW5cIl06OjpweVxuICAgICAgICBDMltcIlJlY2VpdmVzIHRoZSBBcnJvdyBzdHJlYW0sPGJyLz5yZWNvbnN0cnVjdHMgdGhlIERhdGFGcmFtZVwiXTo6OnB5XG4gICAgZW5kXG4gICAgc3ViZ3JhcGggU2VydmVyW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXIgKGZ1bGwgU3BhcmsgcnVudGltZSlcIl1cbiAgICAgICAgUzFbXCJDYXRhbHlzdCBvcHRpbWl6ZXIsPGJyLz5waHlzaWNhbCBleGVjdXRpb25cIl06Ojpqdm1cbiAgICAgICAgUzJbXCJSZXN1bHRzIGNvbnZlcnRlZCB0bzxici8-QXJyb3cgUmVjb3JkQmF0Y2hlc1wiXTo6Omp2bVxuICAgIGVuZFxuICAgIEMxIC0tPnxcInVucmVzb2x2ZWQgcGxhbjxici8-KFByb3RvYnVmIG92ZXIgZ1JQQylcInwgUzEgLS0-IFMyIC0tPnxcIkFycm93IElQQyBvdmVyIGdSUENcInwgQzJcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIGp2bSBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIHB5IGZpbGw6IzZiNGE5YixmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzZiNGE5Yixjb2xvcjojZmZmZmZmXG4gICAgc3ViZ3JhcGggQ2xpZW50W1wiU3BhcmsgQ29ubmVjdCBjbGllbnQgKG5vIEpWTSlcIl1cbiAgICAgICAgQzFbXCJQeXRob24gYXBwIGJ1aWxkczxici8-YSBsb2dpY2FsIHBsYW5cIl06OjpweVxuICAgICAgICBDMltcIlJlY2VpdmVzIHRoZSBBcnJvdyBzdHJlYW0sPGJyLz5yZWNvbnN0cnVjdHMgdGhlIERhdGFGcmFtZVwiXTo6OnB5XG4gICAgZW5kXG4gICAgc3ViZ3JhcGggU2VydmVyW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXIgKGZ1bGwgU3BhcmsgcnVudGltZSlcIl1cbiAgICAgICAgUzFbXCJDYXRhbHlzdCBvcHRpbWl6ZXIsPGJyLz5waHlzaWNhbCBleGVjdXRpb25cIl06Ojpqdm1cbiAgICAgICAgUzJbXCJSZXN1bHRzIGNvbnZlcnRlZCB0bzxici8-QXJyb3cgUmVjb3JkQmF0Y2hlc1wiXTo6Omp2bVxuICAgIGVuZFxuICAgIEMxIC0tPnxcInVucmVzb2x2ZWQgcGxhbjxici8-KFByb3RvYnVmIG92ZXIgZ1JQQylcInwgUzEgLS0-IFMyIC0tPnxcIkFycm93IElQQyBvdmVyIGdSUENcInwgQzJcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 5" width="1427" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The client sends unresolved logical plans as Protocol Buffers; the server optimizes and runs them and streams the results back as Arrow record batches over the same channel. That's what makes a JVM-free Python client possible at all: the &lt;code&gt;pyspark-client&lt;/code&gt; package is a 1.6 MB wheel of pure Python. The same columnar format that moves data between executors and Python workers now moves data between a remote cluster and a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary five: the UDFs nobody rewrote (2023 to 2026)
&lt;/h2&gt;

&lt;p&gt;Every boundary so far helped people who changed their code. Arrow-optimized Python UDFs (&lt;a href="https://issues.apache.org/jira/browse/SPARK-40307" rel="noopener noreferrer"&gt;SPARK-40307&lt;/a&gt;, Spark 3.5.0, September 2023) took the opposite approach: keep your plain &lt;code&gt;@udf&lt;/code&gt; exactly as written, and swap the transport underneath it. Batches cross as Arrow; Python unpacks each batch and still calls your function once per value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIG9sZCBmaWxsOiM0YjU1NjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM0YjU1NjMsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIG5ldyBmaWxsOiNkOTc3MDYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNkOTc3MDYsY29sb3I6I2ZmZmZmZlxuICAgIHN1YmdyYXBoIEJlZm9yZVtcIlBpY2tsZSBAdWRmXCJdXG4gICAgICAgIEExW1wiSlZNOiBlYWNoIHJvd1wiXTo6Om9sZCAtLT4gQTJbXCJwaWNrbGUodmFsdWUpXCJdOjo6b2xkIC0tPiBBM1tcInNvY2tldFwiXTo6Om9sZCAtLT4gQTRbXCJQeXRob246IHVucGlja2xlXCJdOjo6b2xkIC0tPiBBNVtcImNhbGwgZnVuY3Rpb25cIl06OjpvbGRcbiAgICBlbmRcbiAgICBzdWJncmFwaCBBZnRlcltcIkFycm93LW9wdGltaXplZCBAdWRmXCJdXG4gICAgICAgIEIxW1wiSlZNOiBiYXRjaCBvZiByb3dzXCJdOjo6bmV3IC0tPiBCMltcIkFycm93IFJlY29yZEJhdGNoXCJdOjo6bmV3IC0tPiBCM1tcInNvY2tldFwiXTo6Om5ldyAtLT4gQjRbXCJQeXRob246IHVucGFjayBiYXRjaFwiXTo6Om5ldyAtLT4gQjVbXCJjYWxsIGZ1bmN0aW9uIHBlciB2YWx1ZVwiXTo6Om9sZFxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIGNsYXNzRGVmIG9sZCBmaWxsOiM0YjU1NjMsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiM0YjU1NjMsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIG5ldyBmaWxsOiNkOTc3MDYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNkOTc3MDYsY29sb3I6I2ZmZmZmZlxuICAgIHN1YmdyYXBoIEJlZm9yZVtcIlBpY2tsZSBAdWRmXCJdXG4gICAgICAgIEExW1wiSlZNOiBlYWNoIHJvd1wiXTo6Om9sZCAtLT4gQTJbXCJwaWNrbGUodmFsdWUpXCJdOjo6b2xkIC0tPiBBM1tcInNvY2tldFwiXTo6Om9sZCAtLT4gQTRbXCJQeXRob246IHVucGlja2xlXCJdOjo6b2xkIC0tPiBBNVtcImNhbGwgZnVuY3Rpb25cIl06OjpvbGRcbiAgICBlbmRcbiAgICBzdWJncmFwaCBBZnRlcltcIkFycm93LW9wdGltaXplZCBAdWRmXCJdXG4gICAgICAgIEIxW1wiSlZNOiBiYXRjaCBvZiByb3dzXCJdOjo6bmV3IC0tPiBCMltcIkFycm93IFJlY29yZEJhdGNoXCJdOjo6bmV3IC0tPiBCM1tcInNvY2tldFwiXTo6Om5ldyAtLT4gQjRbXCJQeXRob246IHVucGFjayBiYXRjaFwiXTo6Om5ldyAtLT4gQjVbXCJjYWxsIGZ1bmN0aW9uIHBlciB2YWx1ZVwiXTo6Om9sZFxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 6" width="301" height="1356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.databricks.com/blog/arrow-optimized-python-udfs-apache-sparktm-35" rel="noopener noreferrer"&gt;Xinrong Meng's 2023 Databricks post&lt;/a&gt; measured about 1.6x over pickle for a single UDF and 1.9x for three chained ones on a 32 GB dataset, and my benchmark agrees in spirit: &lt;code&gt;useArrow=True&lt;/code&gt; took the arithmetic workload from 5.20 to 2.23 seconds and the CDF from 6.18 to 2.94. Real, and cheap to get, but nothing like the pandas UDF numbers, because the per-row function call is still there.&lt;/p&gt;

&lt;p&gt;That call is my benchmark's ceiling.&lt;/p&gt;

&lt;p&gt;Turning this on by default took two tries. It was merged for Spark 4.0 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-48516" rel="noopener noreferrer"&gt;SPARK-48516&lt;/a&gt;) and reverted in March 2025 before the release; it finally shipped as the default in Spark 4.2, along with Arrow for &lt;code&gt;toPandas()&lt;/code&gt; and for Python UDTFs, and &lt;a href="https://github.com/apache/spark/blob/branch-4.3/python/docs/source/migration_guide/pyspark_upgrade.rst" rel="noopener noreferrer"&gt;a minimum PyArrow of 18.0&lt;/a&gt;. On 4.2, then, every existing &lt;code&gt;@udf&lt;/code&gt; gets Arrow transport without anyone touching it.&lt;/p&gt;

&lt;p&gt;It's also where I made the mistake that best illustrates how confusing this boundary is. My benchmark's "Arrow" variant of the plain UDF flipped &lt;code&gt;spark.sql.execution.arrow.pyspark.enabled&lt;/code&gt;, which governs &lt;code&gt;toPandas()&lt;/code&gt; and &lt;code&gt;createDataFrame()&lt;/code&gt; and has nothing to do with UDFs. The setting that actually moves a UDF onto Arrow is &lt;code&gt;spark.sql.execution.pythonUDF.arrow.enabled&lt;/code&gt;, or &lt;code&gt;useArrow=True&lt;/code&gt; on the decorator. So my "Arrow" row was the pickled UDF measured twice, and its near-identical result was an accidental A/A test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three settings are named for Arrow, and they control three different boundaries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it removed:&lt;/strong&gt; pickle, for everyone, with no code changes. &lt;strong&gt;What it left:&lt;/strong&gt; the function call per row, which is the part no transport format can fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary six: Arrow as the execution format (2025)
&lt;/h2&gt;

&lt;p&gt;The last step turned Arrow from the way data travels into the way your code computes. Ruifeng Zheng's &lt;a href="https://issues.apache.org/jira/browse/SPARK-53014" rel="noopener noreferrer"&gt;SPARK-53014&lt;/a&gt;, "Make Arrow UDF public," shipped in Spark 4.1.0 on December 16, 2025, almost ten years after McKinney's ticket. An Arrow UDF receives &lt;code&gt;pyarrow.Array&lt;/code&gt; values and returns one, and it's meant to be written with &lt;code&gt;pyarrow.compute&lt;/code&gt;, whose functions are &lt;a href="https://arrow.apache.org/docs/python/compute.html" rel="noopener noreferrer"&gt;C++ kernels operating directly on Arrow buffers&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow.compute&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql.functions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;arrow_udf&lt;/span&gt;

&lt;span class="nd"&gt;@arrow_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_tax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@arrow_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utf8_trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utf8_lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The four UDF types differ only in where the data changes format:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UDF type&lt;/th&gt;
&lt;th&gt;Data path&lt;/th&gt;
&lt;th&gt;Format conversions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pickle &lt;code&gt;@udf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;JVM → pickle → Python value → pickle → JVM&lt;/td&gt;
&lt;td&gt;pickle both ways, per row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrow-optimized &lt;code&gt;@udf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;JVM → Arrow → Python value → Arrow → JVM&lt;/td&gt;
&lt;td&gt;Arrow ↔ Python objects, per row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JVM → Arrow → pandas → Arrow → JVM&lt;/td&gt;
&lt;td&gt;Arrow ↔ pandas, per batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@arrow_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JVM → Arrow → your function → Arrow → JVM&lt;/td&gt;
&lt;td&gt;none on the Python side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In my benchmark, &lt;code&gt;@arrow_udf&lt;/code&gt; was the fastest Python option everywhere, but &lt;strong&gt;the margin over pandas UDFs depended almost entirely on the data type&lt;/strong&gt;. On arithmetic and the CDF it was only 5 to 9% faster; on the string transform it was 1.6x faster at both 50M and 100M rows (1.55 seconds against 2.51 at 50M).&lt;/p&gt;

&lt;p&gt;The split comes down to strings. A numeric pandas Series is a NumPy array underneath, so converting from Arrow is cheap; a string column in traditional pandas is an array of Python objects, so converting means building millions of them. &lt;code&gt;pyarrow.compute&lt;/code&gt; skips all of that and works on the one contiguous buffer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it removed:&lt;/strong&gt; the last conversion on the Python side. &lt;strong&gt;What it left:&lt;/strong&gt; Spark's side.&lt;/p&gt;

&lt;p&gt;Taken together, the choice today comes down to what your function can receive:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIGNsYXNzRGVmIGJlc3QgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgICBjbGFzc0RlZiBvayBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIGxhc3QgZmlsbDojNGI1NTYzLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNGI1NTYzLGNvbG9yOiNmZmZmZmZcbiAgICBRMXtcIkV4cHJlc3NpYmxlIHdpdGggYnVpbHQtaW48YnIvPlNwYXJrIGZ1bmN0aW9ucyBvciBTUUw_XCJ9IC0tPnx5ZXN8IEExW1wiVXNlIHRob3NlLiBOb3RoaW5nIGluIFB5dGhvbiBjYW1lIGNsb3NlLlwiXTo6OmJlc3RcbiAgICBRMSAtLT58bm98IFEze1wiRXhwcmVzc2libGUgd2l0aDxici8-cHlhcnJvdy5jb21wdXRlP1wifVxuICAgIFEzIC0tPnx5ZXN8IEEzW1wiQGFycm93X3VkZjxici8-KGxhcmdlc3Qgd2luIG9uIHN0cmluZ3MpXCJdOjo6b2tcbiAgICBRMyAtLT58bm98IFE0e1wiTmVlZHMgcGFuZGFzIC8gTnVtUHkgLyBTY2lQeT9cIn1cbiAgICBRNCAtLT58eWVzfCBBNFtcIkBwYW5kYXNfdWRmXCJdOjo6b2tcbiAgICBRNCAtLT58bm98IEE1W1wicGxhaW4gQHVkZjogQXJyb3cgdHJhbnNwb3J0PGJyLz5ieSBkZWZhdWx0IG9uIFNwYXJrIDQuMlwiXTo6Omxhc3RcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIGNsYXNzRGVmIGJlc3QgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgICBjbGFzc0RlZiBvayBmaWxsOiMzYTVmOTYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiMzYTVmOTYsY29sb3I6I2ZmZmZmZlxuICAgIGNsYXNzRGVmIGxhc3QgZmlsbDojNGI1NTYzLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNGI1NTYzLGNvbG9yOiNmZmZmZmZcbiAgICBRMXtcIkV4cHJlc3NpYmxlIHdpdGggYnVpbHQtaW48YnIvPlNwYXJrIGZ1bmN0aW9ucyBvciBTUUw_XCJ9IC0tPnx5ZXN8IEExW1wiVXNlIHRob3NlLiBOb3RoaW5nIGluIFB5dGhvbiBjYW1lIGNsb3NlLlwiXTo6OmJlc3RcbiAgICBRMSAtLT58bm98IFEze1wiRXhwcmVzc2libGUgd2l0aDxici8-cHlhcnJvdy5jb21wdXRlP1wifVxuICAgIFEzIC0tPnx5ZXN8IEEzW1wiQGFycm93X3VkZjxici8-KGxhcmdlc3Qgd2luIG9uIHN0cmluZ3MpXCJdOjo6b2tcbiAgICBRMyAtLT58bm98IFE0e1wiTmVlZHMgcGFuZGFzIC8gTnVtUHkgLyBTY2lQeT9cIn1cbiAgICBRNCAtLT58eWVzfCBBNFtcIkBwYW5kYXNfdWRmXCJdOjo6b2tcbiAgICBRNCAtLT58bm98IEE1W1wicGxhaW4gQHVkZjogQXJyb3cgdHJhbnNwb3J0PGJyLz5ieSBkZWZhdWx0IG9uIFNwYXJrIDQuMlwiXTo6Omxhc3RcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 7" width="836" height="1055"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipe itself
&lt;/h2&gt;

&lt;p&gt;Everything above changes what goes through the socket. Hyukjin Kwon's &lt;a href="https://issues.apache.org/jira/browse/SPARK-51688" rel="noopener noreferrer"&gt;SPARK-51688&lt;/a&gt;, shipped in Spark 4.1.0, changes the socket: instead of TCP over loopback, which runs every byte through the full TCP/IP stack even though it never leaves the machine, the JVM and Python workers can talk over a &lt;em&gt;Unix domain socket&lt;/em&gt;, which goes straight through a kernel buffer.&lt;/p&gt;

&lt;p&gt;It's opt-in (&lt;code&gt;spark.python.unix.domain.socket.enabled&lt;/code&gt;, whose default comes from the &lt;code&gt;PYSPARK_UDS_MODE&lt;/code&gt; environment variable) in 4.1 and 4.2, and that's unchanged on the 4.3 branch, which instead adds another transport option: &lt;a href="https://issues.apache.org/jira/browse/SPARK-56642" rel="noopener noreferrer"&gt;pipelined, asynchronous transfer of UDF batches&lt;/a&gt; between the JVM and Python (&lt;code&gt;spark.python.udf.pipelined.enabled&lt;/code&gt;, off by default).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIG5ldyBmaWxsOiNkOTc3MDYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNkOTc3MDYsY29sb3I6I2ZmZmZmZlxuICBzdWJncmFwaCBUQ1BbXCJUQ1Agb3ZlciBsb29wYmFjayAoZGVmYXVsdClcIl1cbiAgICBKMVtcIkpWTSBleGVjdXRvclwiXTo6Omp2bSAtLT4gVDFbXCJUQ1AvSVAgc3RhY2s8YnIvPihzZWdtZW50cywgQUNLcywgZmxvdyBjb250cm9sKVwiXTo6Om9sZCAtLT4gTFtcImxvb3BiYWNrIGludGVyZmFjZVwiXTo6Om9sZCAtLT4gVDJbXCJUQ1AvSVAgc3RhY2tcIl06OjpvbGQgLS0-IFAxW1wiUHl0aG9uIHdvcmtlclwiXTo6OnB5XG4gIGVuZFxuICBzdWJncmFwaCBVRFNbXCJVbml4IGRvbWFpbiBzb2NrZXQgKG9wdC1pbiBzaW5jZSA0LjEpXCJdXG4gICAgSjJbXCJKVk0gZXhlY3V0b3JcIl06Ojpqdm0gLS0-IEtbXCJrZXJuZWwgc29ja2V0IGJ1ZmZlclwiXTo6Om5ldyAtLT4gUDJbXCJQeXRob24gd29ya2VyXCJdOjo6cHlcbiAgZW5kXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgb2xkIGZpbGw6IzRiNTU2MyxmaWxsLW9wYWNpdHk6MC43MixzdHJva2U6IzRiNTU2Myxjb2xvcjojZmZmZmZmXG4gIGNsYXNzRGVmIG5ldyBmaWxsOiNkOTc3MDYsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNkOTc3MDYsY29sb3I6I2ZmZmZmZlxuICBzdWJncmFwaCBUQ1BbXCJUQ1Agb3ZlciBsb29wYmFjayAoZGVmYXVsdClcIl1cbiAgICBKMVtcIkpWTSBleGVjdXRvclwiXTo6Omp2bSAtLT4gVDFbXCJUQ1AvSVAgc3RhY2s8YnIvPihzZWdtZW50cywgQUNLcywgZmxvdyBjb250cm9sKVwiXTo6Om9sZCAtLT4gTFtcImxvb3BiYWNrIGludGVyZmFjZVwiXTo6Om9sZCAtLT4gVDJbXCJUQ1AvSVAgc3RhY2tcIl06OjpvbGQgLS0-IFAxW1wiUHl0aG9uIHdvcmtlclwiXTo6OnB5XG4gIGVuZFxuICBzdWJncmFwaCBVRFNbXCJVbml4IGRvbWFpbiBzb2NrZXQgKG9wdC1pbiBzaW5jZSA0LjEpXCJdXG4gICAgSjJbXCJKVk0gZXhlY3V0b3JcIl06Ojpqdm0gLS0-IEtbXCJrZXJuZWwgc29ja2V0IGJ1ZmZlclwiXTo6Om5ldyAtLT4gUDJbXCJQeXRob24gd29ya2VyXCJdOjo6cHlcbiAgZW5kXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 8" width="346" height="1146"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Spark 1.x:  pickle format  + TCP loopback        slow format over a slow pipe
Spark 2.3:  Arrow format   + TCP loopback        fast format over a slow pipe
Spark 4.1:  Arrow format   + Unix domain socket  fast format over a faster pipe (opt-in)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  One boundary at a time
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInRpbWVsaW5lXG4gICAgdGl0bGUgQXJyb3cgaW4gUHlTcGFyaywgb25lIGJvdW5kYXJ5IGF0IGEgdGltZVxuICAgIDIwMTYgOiBBcGFjaGUgQXJyb3cgbGF1bmNoZWQgOiBTUEFSSy0xMzUzNCBmaWxlZFxuICAgIDIwMTggOiBTcGFyayAyLjMgOiBBcnJvdy1iYWNrZWQgdG9QYW5kYXMgYW5kIGNyZWF0ZURhdGFGcmFtZSA6IFBhbmRhcyBVREZzXG4gICAgMjAyMiA6IFNwYXJrIDMuMyA6IG1hcEluQXJyb3dcbiAgICAyMDIzIDogU3BhcmsgMy40IDogU3BhcmsgQ29ubmVjdCBzdHJlYW1zIHJlc3VsdHMgYXMgQXJyb3cgOiBTcGFyayAzLjUgOiBBcnJvdy1vcHRpbWl6ZWQgUHl0aG9uIFVERnMgKG9wdC1pbilcbiAgICBNYXkgMjAyNSA6IFNwYXJrIDQuMCA6IGFwcGx5SW5BcnJvdyA6IFB5dGhvbiBEYXRhIFNvdXJjZSB3aXRoIEFycm93IGJhdGNoZXNcbiAgICBEZWMgMjAyNSA6IFNwYXJrIDQuMSA6IEFycm93IFVERnMgbWFkZSBwdWJsaWMgOiBVbml4IGRvbWFpbiBzb2NrZXRzIChvcHQtaW4pXG4gICAgSnVsIDIwMjYgOiBTcGFyayA0LjIgOiBBcnJvdyBvbiBieSBkZWZhdWx0IGZvciB0b1BhbmRhcywgUHl0aG9uIFVERnMsIGFuZCBVRFRGc1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogInRpbWVsaW5lXG4gICAgdGl0bGUgQXJyb3cgaW4gUHlTcGFyaywgb25lIGJvdW5kYXJ5IGF0IGEgdGltZVxuICAgIDIwMTYgOiBBcGFjaGUgQXJyb3cgbGF1bmNoZWQgOiBTUEFSSy0xMzUzNCBmaWxlZFxuICAgIDIwMTggOiBTcGFyayAyLjMgOiBBcnJvdy1iYWNrZWQgdG9QYW5kYXMgYW5kIGNyZWF0ZURhdGFGcmFtZSA6IFBhbmRhcyBVREZzXG4gICAgMjAyMiA6IFNwYXJrIDMuMyA6IG1hcEluQXJyb3dcbiAgICAyMDIzIDogU3BhcmsgMy40IDogU3BhcmsgQ29ubmVjdCBzdHJlYW1zIHJlc3VsdHMgYXMgQXJyb3cgOiBTcGFyayAzLjUgOiBBcnJvdy1vcHRpbWl6ZWQgUHl0aG9uIFVERnMgKG9wdC1pbilcbiAgICBNYXkgMjAyNSA6IFNwYXJrIDQuMCA6IGFwcGx5SW5BcnJvdyA6IFB5dGhvbiBEYXRhIFNvdXJjZSB3aXRoIEFycm93IGJhdGNoZXNcbiAgICBEZWMgMjAyNSA6IFNwYXJrIDQuMSA6IEFycm93IFVERnMgbWFkZSBwdWJsaWMgOiBVbml4IGRvbWFpbiBzb2NrZXRzIChvcHQtaW4pXG4gICAgSnVsIDIwMjYgOiBTcGFyayA0LjIgOiBBcnJvdyBvbiBieSBkZWZhdWx0IGZvciB0b1BhbmRhcywgUHl0aG9uIFVERnMsIGFuZCBVRFRGc1xuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 9" width="1790" height="710"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As of Spark 4.2, Arrow is the format at every place data crosses between the JVM and Python:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;Arrow's role&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;toPandas()&lt;/code&gt; / &lt;code&gt;createDataFrame()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;replaces pickle for driver data collection&lt;/td&gt;
&lt;td&gt;yes, since 4.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; (plain Python UDFs)&lt;/td&gt;
&lt;td&gt;replaces pickle transport&lt;/td&gt;
&lt;td&gt;yes, since 4.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@pandas_udf&lt;/code&gt;, &lt;code&gt;mapInPandas&lt;/code&gt;, &lt;code&gt;applyInPandas&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;batch transport, converted to pandas&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@arrow_udf&lt;/code&gt;, &lt;code&gt;mapInArrow&lt;/code&gt;, &lt;code&gt;applyInArrow&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;transport and execution format&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spark Connect results&lt;/td&gt;
&lt;td&gt;the result protocol&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python Data Source API&lt;/td&gt;
&lt;td&gt;source data format&lt;/td&gt;
&lt;td&gt;when the source yields RecordBatches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The last boundary
&lt;/h2&gt;

&lt;p&gt;What's left is on Spark's side of the wall. Inside the JVM, Spark still executes on its own row format (UnsafeRow, from the Tungsten project), and Arrow exists at the edges, so &lt;strong&gt;every crossing still begins and ends with a conversion&lt;/strong&gt; between Spark's rows and Arrow's columns. Engines like Velox and DataFusion show that a query engine can execute on Arrow-format data directly; Spark doesn't, and closing that gap is a much bigger change than any of the boundaries above.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29udiBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggU3BhcmtbXCJTcGFyazogcm93cyBpbnNpZGUsIEFycm93IGF0IHRoZSBlZGdlXCJdXG4gICAgZGlyZWN0aW9uIExSXG4gICAgVVtcImV4ZWN1dGlvbiBvbiBVbnNhZmVSb3c8YnIvPihUdW5nc3RlbiByb3cgZm9ybWF0KVwiXTo6Omp2bSAtLT58XCJyb3dzIHRvIGNvbHVtbnNcInwgQzFbXCJBcnJvdyBiYXRjaFwiXTo6OmNvbnYgLS0-IFBbXCJQeXRob24sIENvbm5lY3QgY2xpZW50LDxici8-YW55dGhpbmcgb3V0c2lkZVwiXTo6OnB5IC0tPiBDMltcIkFycm93IGJhdGNoXCJdOjo6Y29udiAtLT58XCJjb2x1bW5zIHRvIHJvd3NcInwgVTJbXCJleGVjdXRpb24gb24gVW5zYWZlUm93XCJdOjo6anZtXG4gIGVuZFxuICBzdWJncmFwaCBOYXRpdmVbXCJEYXRhRnVzaW9uLCBWZWxveDogY29sdW1ucyBhbGwgdGhlIHdheSB0aHJvdWdoXCJdXG4gICAgZGlyZWN0aW9uIExSXG4gICAgTltcImV4ZWN1dGlvbiBvbiBBcnJvdyBvcjxici8-QXJyb3ctY29tcGF0aWJsZSBjb2x1bW5zXCJdOjo6bmV3IC0tPnxcIm5vIGNvbnZlcnNpb25cInwgUDJbXCJhbnl0aGluZyB0aGF0PGJyLz5zcGVha3MgQXJyb3dcIl06OjpweVxuICBlbmRcbiAgU3Bhcmsgfn5-IE5hdGl2ZVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICBjbGFzc0RlZiBqdm0gZmlsbDojM2E1Zjk2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojM2E1Zjk2LGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgcHkgZmlsbDojNmI0YTliLGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojNmI0YTliLGNvbG9yOiNmZmZmZmZcbiAgY2xhc3NEZWYgY29udiBmaWxsOiNjMDY2MWEsZmlsbC1vcGFjaXR5OjAuNzIsc3Ryb2tlOiNjMDY2MWEsY29sb3I6I2ZmZmZmZlxuICBjbGFzc0RlZiBuZXcgZmlsbDojZDk3NzA2LGZpbGwtb3BhY2l0eTowLjcyLHN0cm9rZTojZDk3NzA2LGNvbG9yOiNmZmZmZmZcbiAgc3ViZ3JhcGggU3BhcmtbXCJTcGFyazogcm93cyBpbnNpZGUsIEFycm93IGF0IHRoZSBlZGdlXCJdXG4gICAgZGlyZWN0aW9uIExSXG4gICAgVVtcImV4ZWN1dGlvbiBvbiBVbnNhZmVSb3c8YnIvPihUdW5nc3RlbiByb3cgZm9ybWF0KVwiXTo6Omp2bSAtLT58XCJyb3dzIHRvIGNvbHVtbnNcInwgQzFbXCJBcnJvdyBiYXRjaFwiXTo6OmNvbnYgLS0-IFBbXCJQeXRob24sIENvbm5lY3QgY2xpZW50LDxici8-YW55dGhpbmcgb3V0c2lkZVwiXTo6OnB5IC0tPiBDMltcIkFycm93IGJhdGNoXCJdOjo6Y29udiAtLT58XCJjb2x1bW5zIHRvIHJvd3NcInwgVTJbXCJleGVjdXRpb24gb24gVW5zYWZlUm93XCJdOjo6anZtXG4gIGVuZFxuICBzdWJncmFwaCBOYXRpdmVbXCJEYXRhRnVzaW9uLCBWZWxveDogY29sdW1ucyBhbGwgdGhlIHdheSB0aHJvdWdoXCJdXG4gICAgZGlyZWN0aW9uIExSXG4gICAgTltcImV4ZWN1dGlvbiBvbiBBcnJvdyBvcjxici8-QXJyb3ctY29tcGF0aWJsZSBjb2x1bW5zXCJdOjo6bmV3IC0tPnxcIm5vIGNvbnZlcnNpb25cInwgUDJbXCJhbnl0aGluZyB0aGF0PGJyLz5zcGVha3MgQXJyb3dcIl06OjpweVxuICBlbmRcbiAgU3Bhcmsgfn5-IE5hdGl2ZVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 10" width="1613" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 4.3 branch, which hasn't been released yet (its first release candidate &lt;a href="https://lists.apache.org/thread/6bhgq2w84zrv3sl4wr2zl8p23fvbsfrg" rel="noopener noreferrer"&gt;failed its vote on September 6&lt;/a&gt;, and a second, &lt;a href="https://lists.apache.org/thread/y7h0v7km0814xb5fg57ght5wtfptn9lt" rel="noopener noreferrer"&gt;put to a vote on October 13&lt;/a&gt;, drew -1 votes over critical fixes that still need backporting, so there's no date), has two small signs of movement at exactly this edge. One is an Arrow format for Spark's in-memory cache (&lt;code&gt;ArrowCachedBatchSerializer&lt;/code&gt;, &lt;a href="https://issues.apache.org/jira/browse/SPARK-57268" rel="noopener noreferrer"&gt;SPARK-57268&lt;/a&gt;), part of a proposal to make Spark better on a single machine: the first time Arrow shows up as a storage format inside Spark itself rather than at a door.&lt;/p&gt;

&lt;p&gt;The other goes around the boundary instead of through it: experimental transpilation of Python UDFs into Spark expressions (SPIP &lt;a href="https://issues.apache.org/jira/browse/SPARK-54783" rel="noopener noreferrer"&gt;SPARK-54783&lt;/a&gt;), so a simple enough Python function never has to cross into Python at all. It's off by default, classic-mode only, and requires ANSI mode, but it's the one approach that could get under my benchmark's ceiling, because &lt;strong&gt;it removes the per-row Python call rather than making the trip to it cheaper&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The pandas side is moving too, more slowly than you'd expect. &lt;a href="https://pandas.pydata.org/docs/whatsnew/v3.0.0.html" rel="noopener noreferrer"&gt;pandas 3.0, released in January 2026&lt;/a&gt;, made a PyArrow-backed string type the default, which should shrink exactly the string-conversion cost that separates pandas UDFs from Arrow UDFs. But PySpark doesn't support pandas 3 yet, not even on the 4.3 branch: it still pins pandas below 3.0, and the umbrella ticket for supporting it (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55139" rel="noopener noreferrer"&gt;SPARK-55139&lt;/a&gt;) is open.&lt;/p&gt;

&lt;p&gt;When Wes McKinney filed SPARK-13534 in 2016, he was asking for one method to stop pickling. Ten years later, every door he could have named uses Arrow, the defaults finally say so, and the conversion that remains is the one Spark performs on itself, between its own rows and everything outside it.&lt;/p&gt;

&lt;p&gt;That's the last boundary, and &lt;strong&gt;it was never a Python problem.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>spark</category>
      <category>pyspark</category>
      <category>arrow</category>
      <category>history</category>
    </item>
    <item>
      <title>Most PySpark Tuning Advice Starts in the Wrong Place</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:19:44 +0000</pubDate>
      <link>https://dev.to/lisancao/most-pyspark-tuning-advice-starts-in-the-wrong-place-4j6l</link>
      <guid>https://dev.to/lisancao/most-pyspark-tuning-advice-starts-in-the-wrong-place-4j6l</guid>
      <description>&lt;p&gt;When I benchmarked PySpark's UDF types earlier this year, I ran the same three workloads (trivial arithmetic, a string transform, a normal CDF) at five scales, from 100 thousand rows to 100 million. At 100 million rows, the default row-at-a-time Python UDF took about 10 seconds for arithmetic that a built-in function finished in under a fifth of a second. At 100 thousand rows, the same UDF was the fastest Python option I tested.&lt;/p&gt;

&lt;p&gt;Both results are true, and between them they're most of what you need to know about PySpark performance: &lt;strong&gt;the cost is almost never in your function.&lt;/strong&gt; It's in how many times, and in what shape, your data crosses from the JVM into Python, and whether that crossing is large enough to matter at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My argument is that there is really one PySpark performance technique, applied at different places: cross the JVM/Python boundary less often, move less across it, or stop paying for a JVM where you don't need one. The changes worth making are ordered by how much of that crossing they remove, and the biggest one, not crossing at all, is also the most often skipped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The history of how Arrow came to carry that crossing is in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/arrow-and-spark" rel="noopener noreferrer"&gt;Arrow in PySpark, 2016 to 2026&lt;/a&gt;, and the full benchmark is in &lt;a href="https://systemsnull.net/posts/data-engineering-ai/pyspark-udf-showdown" rel="noopener noreferrer"&gt;Stop Blaming Serialization for Slow Python UDFs&lt;/a&gt;. This post is the practical version: what to change, in what order, and what each change actually removes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the time goes
&lt;/h2&gt;

&lt;p&gt;PySpark is a Python client for a JVM engine. DataFrame operations build a plan in Python (through Py4J, which only ever carries control messages, never bulk data) and the JVM executes it; &lt;code&gt;df.filter(col("x") &amp;gt; 5).groupBy("y").count()&lt;/code&gt; never puts a row into Python, which is why it runs at the same speed as Scala. The data only leaves the JVM when your own code needs it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFNbXCJTdG9yYWdlXCJdIC0tPnxcInJlYWRcInwgSjFbXCJKVk08YnIvPm5hdGl2ZSBzcGVlZFwiXVxuICAgIEoxIC0tPnxcIlx1MjQ2MCBzZXJpYWxpemUgYW5kIHNlbmQ8YnIvPnRvIGEgUHl0aG9uIHdvcmtlclwifCBQW1wiUHl0aG9uIHdvcmtlcjxici8-eW91ciBVREZcIl1cbiAgICBQIC0tPnxcIlx1MjQ2MSBzZXJpYWxpemUgcmVzdWx0cyBiYWNrXCJ8IEoyW1wiSlZNPGJyLz5uYXRpdmUgc3BlZWRcIl1cbiAgICBKMiAtLT58XCJ3cml0ZVwifCBXW1wiU3RvcmFnZVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIFNbXCJTdG9yYWdlXCJdIC0tPnxcInJlYWRcInwgSjFbXCJKVk08YnIvPm5hdGl2ZSBzcGVlZFwiXVxuICAgIEoxIC0tPnxcIlx1MjQ2MCBzZXJpYWxpemUgYW5kIHNlbmQ8YnIvPnRvIGEgUHl0aG9uIHdvcmtlclwifCBQW1wiUHl0aG9uIHdvcmtlcjxici8-eW91ciBVREZcIl1cbiAgICBQIC0tPnxcIlx1MjQ2MSBzZXJpYWxpemUgcmVzdWx0cyBiYWNrXCJ8IEoyW1wiSlZNPGJyLz5uYXRpdmUgc3BlZWRcIl1cbiAgICBKMiAtLT58XCJ3cml0ZVwifCBXW1wiU3RvcmFnZVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="1292" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything outside arrows ① and ② happens at native speed. Every change below makes those two arrows smaller, rarer, or unnecessary, and I've ordered them by how much they took off the arrows in what I measured, or, where I haven't measured, by what they remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't cross at all: built-ins and SQL UDFs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The largest win isn't a better UDF; it's no UDF.&lt;/strong&gt; Built-in functions run inside the JVM as Catalyst expressions, fused by whole-stage code generation into tight loops, and never serialize anything.&lt;/p&gt;

&lt;p&gt;What surprised me more is that SQL UDFs get the same treatment. Spark 4.0 added &lt;a href="https://spark.apache.org/docs/4.1.0/sql-ref-syntax-ddl-create-sql-function.html" rel="noopener noreferrer"&gt;SQL user-defined functions&lt;/a&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-46057" rel="noopener noreferrer"&gt;SPARK-46057&lt;/a&gt;), and their bodies are inlined into the logical plan, so the optimizer can see straight through them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'excellent'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'good'&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'needs_improvement'&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my benchmark (median wall-clock time, 50M rows, Spark 4.1.0 through Spark Connect on one machine), the SQL UDF was indistinguishable from the built-ins, and every Python option cost several times more:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;arithmetic&lt;/th&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;normal CDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;built-in functions&lt;/td&gt;
&lt;td&gt;0.107 s&lt;/td&gt;
&lt;td&gt;0.486 s&lt;/td&gt;
&lt;td&gt;0.176 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL UDF&lt;/td&gt;
&lt;td&gt;0.095 s&lt;/td&gt;
&lt;td&gt;0.477 s&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastest Python UDF (&lt;code&gt;@arrow_udf&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.725 s&lt;/td&gt;
&lt;td&gt;1.545 s&lt;/td&gt;
&lt;td&gt;0.762 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;default Python UDF (&lt;code&gt;@udf&lt;/code&gt;, pickle)&lt;/td&gt;
&lt;td&gt;5.199 s&lt;/td&gt;
&lt;td&gt;6.216 s&lt;/td&gt;
&lt;td&gt;6.183 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;So the first question for any UDF is whether it needs to be Python.&lt;/strong&gt; Plenty of "we need a UDF" logic is a &lt;code&gt;CASE&lt;/code&gt; expression, a string function, or a date calculation that already exists in &lt;code&gt;pyspark.sql.functions&lt;/code&gt;. Python earns its place when you need a library (scikit-learn, a parser, a C extension) or logic that genuinely can't be expressed in SQL. Everything after this point is about paying less for that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross once per batch, not once per row
&lt;/h2&gt;

&lt;p&gt;If it has to be Python, the next biggest lever is how often Spark calls your function. A regular &lt;code&gt;@udf&lt;/code&gt; is called once per row; &lt;code&gt;@pandas_udf&lt;/code&gt; and &lt;code&gt;@arrow_udf&lt;/code&gt; are called once per batch of rows. That difference showed up as a hard ceiling in my runs: every row-at-a-time UDF, whatever it computed, topped out at roughly 8 to 10 million rows per second, which is roughly the rate at which CPython can be entered and exited. The batched UDFs weren't bound by it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;50M rows&lt;/th&gt;
&lt;th&gt;arithmetic&lt;/th&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;normal CDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; (pickle, per row)&lt;/td&gt;
&lt;td&gt;5.199 s&lt;/td&gt;
&lt;td&gt;6.216 s&lt;/td&gt;
&lt;td&gt;6.183 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf(useArrow=True)&lt;/code&gt; (Arrow transport, still per row)&lt;/td&gt;
&lt;td&gt;2.228 s&lt;/td&gt;
&lt;td&gt;4.430 s&lt;/td&gt;
&lt;td&gt;2.942 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@pandas_udf&lt;/code&gt; (per batch, &lt;code&gt;pandas.Series&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.789 s&lt;/td&gt;
&lt;td&gt;2.512 s&lt;/td&gt;
&lt;td&gt;0.819 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@arrow_udf&lt;/code&gt; (per batch, &lt;code&gt;pyarrow.Array&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.725 s&lt;/td&gt;
&lt;td&gt;1.545 s&lt;/td&gt;
&lt;td&gt;0.762 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in that table decide what to change. Moving a per-row UDF onto Arrow transport helps, 1.4x to 2.4x here, and &lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;on Spark 4.2 you get it for free&lt;/a&gt;: 4.2 finally flipped &lt;code&gt;spark.sql.execution.pythonUDF.arrow.enabled&lt;/code&gt; (and its UDTF counterpart) to true by default, after the same change was merged for 4.0 and reverted before release (&lt;a href="https://issues.apache.org/jira/browse/SPARK-48516" rel="noopener noreferrer"&gt;SPARK-48516&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;rewriting to a batched UDF is worth several times more again&lt;/strong&gt;, because it removes the per-row call rather than making the trip to it cheaper.&lt;/p&gt;

&lt;p&gt;Between the two batched options, the choice mostly comes down to strings: &lt;code&gt;@arrow_udf&lt;/code&gt; was only 5 to 9% faster than &lt;code&gt;@pandas_udf&lt;/code&gt; on numeric work, but about 1.6x faster on the string transform, because pandas has traditionally stored strings as individual Python objects while Arrow keeps them in one contiguous buffer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow.compute&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql.functions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;arrow_udf&lt;/span&gt;

&lt;span class="nd"&gt;@arrow_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;binary_join_element_wise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utf8_upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_SUFFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One warning, because I learned it the expensive way: &lt;code&gt;spark.sql.execution.arrow.pyspark.enabled&lt;/code&gt; is not the UDF setting. It governs &lt;code&gt;toPandas()&lt;/code&gt; and &lt;code&gt;createDataFrame()&lt;/code&gt;, and it's the one my benchmark originally toggled for its "Arrow" case, which is why I once reported that Arrow transport changes nothing for UDFs. It was the same pickled UDF, measured twice. (On 4.2 that setting is also on by default, so the confusion matters less for new code than for anyone reading old tuning advice.)&lt;/p&gt;

&lt;p&gt;And the scale caveat from the opening is real. At small sizes, fixed costs dominate, and the ordering can flip:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arithmetic, median seconds&lt;/th&gt;
&lt;th&gt;built-in&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@udf(useArrow=True)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;@udf&lt;/code&gt; (pickle)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100K rows&lt;/td&gt;
&lt;td&gt;0.073&lt;/td&gt;
&lt;td&gt;0.181&lt;/td&gt;
&lt;td&gt;0.212&lt;/td&gt;
&lt;td&gt;0.161&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10M rows&lt;/td&gt;
&lt;td&gt;0.074&lt;/td&gt;
&lt;td&gt;0.299&lt;/td&gt;
&lt;td&gt;0.568&lt;/td&gt;
&lt;td&gt;1.092&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100M rows&lt;/td&gt;
&lt;td&gt;0.147&lt;/td&gt;
&lt;td&gt;1.400&lt;/td&gt;
&lt;td&gt;4.188&lt;/td&gt;
&lt;td&gt;10.139&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your job touches a few hundred thousand rows, rewriting its UDFs is unlikely to be what makes it faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't produce the data: Python data sources with pushdown
&lt;/h2&gt;

&lt;p&gt;After calling Python less, the next thing to remove is data that never needed to exist. Built-in sources like Parquet already push filters down and skip row groups that can't match; for most of Spark's history, custom sources that could do the same had to be written in Java or Scala. Spark 4.0 added the &lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/python_data_source.html" rel="noopener noreferrer"&gt;Python Data Source API&lt;/a&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-44076" rel="noopener noreferrer"&gt;SPARK-44076&lt;/a&gt;), and Spark 4.1 added filter pushdown for it (&lt;a href="https://issues.apache.org/jira/browse/SPARK-51271" rel="noopener noreferrer"&gt;SPARK-51271&lt;/a&gt;), so a source written in Python can now turn &lt;code&gt;WHERE status = 'active'&lt;/code&gt; into a query parameter, a database predicate, or a skipped file block, and never send the rest.&lt;/p&gt;

&lt;p&gt;When I went back over my own notes on this feature to write this post, the example I'd written was wrong, and checking it against Spark's source turned up something I hadn't known: pushdown for Python sources is &lt;strong&gt;off by default&lt;/strong&gt;. It's governed by &lt;code&gt;spark.sql.python.filterPushdown.enabled&lt;/code&gt;, &lt;a href="https://github.com/apache/spark/blob/v4.2.0/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala" rel="noopener noreferrer"&gt;an internal setting added in 4.1&lt;/a&gt; whose description says enabling it costs "running Python worker one additional time during planning"; without it, your &lt;code&gt;pushFilters()&lt;/code&gt; is never called.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/apache/spark/blob/v4.2.0/python/pyspark/sql/datasource.py" rel="noopener noreferrer"&gt;The method&lt;/a&gt; also has a less obvious contract than I'd assumed. It returns the filters Spark must &lt;em&gt;still&lt;/em&gt; apply after the scan, not the ones you accepted, and each filter's &lt;code&gt;attribute&lt;/code&gt; is a column-path tuple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql.datasource&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DataSourceReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EqualTo&lt;/span&gt;

&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.python.filterPushdown.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# off by default
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataSourceReader&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pushFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Filters arrive ANDed together; yield the ones Spark must still apply.
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EqualTo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,):&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# handled at the source
&lt;/span&gt;            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only conjunctions are pushed (a filter like &lt;code&gt;a = 1 or b = 2&lt;/code&gt; arrives as an empty list), and a reader can also yield &lt;code&gt;pyarrow.RecordBatch&lt;/code&gt; objects instead of tuples, which &lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/python_data_source.html" rel="noopener noreferrer"&gt;the Spark docs&lt;/a&gt; say can improve performance by "up to one order of magnitude."&lt;/p&gt;

&lt;p&gt;I haven't measured either path, so this is the one change on the list I'm ranking by what it removes rather than by what I saw: for a source backed by an API or a database, &lt;strong&gt;rows that are never fetched are the cheapest rows there are.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip pandas for grouped work
&lt;/h2&gt;

&lt;p&gt;Per-group logic (normalize within each location, fit a model per customer) has long meant &lt;code&gt;applyInPandas&lt;/code&gt;, which converts every group from Arrow into a pandas DataFrame and back. Spark 4.0 added &lt;code&gt;applyInArrow&lt;/code&gt; (&lt;a href="https://issues.apache.org/jira/browse/SPARK-40559" rel="noopener noreferrer"&gt;SPARK-40559&lt;/a&gt;), and &lt;a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.GroupedData.applyInArrow.html" rel="noopener noreferrer"&gt;each group now reaches your function&lt;/a&gt; as a &lt;code&gt;pyarrow.Table&lt;/code&gt;, in the format it crossed the boundary in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_py&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stddev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_py&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;
                  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_arrays&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normalized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;applyInArrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id long, total double, normalized double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same trade as step 2, one level up: &lt;strong&gt;it removes a conversion, not a crossing&lt;/strong&gt;, so the payoff should track what that conversion cost in my UDF numbers (little for numeric groups, more for string-heavy ones, more for larger groups). I haven't benchmarked the grouped APIs, so I won't put a number on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take the JVM out of the client
&lt;/h2&gt;

&lt;p&gt;Spark Connect is on this list for a different reason than the others. &lt;strong&gt;It doesn't make a query faster; it removes the JVM from every client process.&lt;/strong&gt; Classic PySpark embeds a full Spark driver in each notebook, scheduled job, and CI container. &lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;With Connect&lt;/a&gt;, the client sends unresolved plans over gRPC and gets Arrow batches back, and the thin &lt;code&gt;pyspark-client&lt;/code&gt; package has no JVM and needs no JDK. When I measured it for my own Connect demos on 4.2, the numbers were less dramatic than the pitch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;pyspark&lt;/code&gt; (full)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pyspark-client&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyPI artifact&lt;/td&gt;
&lt;td&gt;429.28 MB&lt;/td&gt;
&lt;td&gt;1.60 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;installed &lt;code&gt;pyspark/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;481 MB&lt;/td&gt;
&lt;td&gt;19 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spark JARs on the client&lt;/td&gt;
&lt;td&gt;456 MB, 276 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;virtual env with Connect dependencies&lt;/td&gt;
&lt;td&gt;820 MB&lt;/td&gt;
&lt;td&gt;359 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the honest one: &lt;code&gt;pyarrow&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt;, and &lt;code&gt;numpy&lt;/code&gt; are required dependencies, so the environment shrinks by less than half.&lt;/p&gt;

&lt;p&gt;What you actually get is a client with no JVM to start, no JDK to install, and no way for a crashed notebook to take the driver down with it. What you give up is anything that needs the JVM on your side: RDDs, &lt;code&gt;SparkContext&lt;/code&gt;, custom accumulators. ML on Connect has been supported since 4.0 and generally available since 4.1, so that's no longer on the list of reasons to stay classic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give Python the memory it actually uses
&lt;/h2&gt;

&lt;p&gt;The last change is the one that turns slow jobs into failed ones when it's wrong. Python workers live outside the JVM heap, so the settings most Spark tuning advice is about don't cover them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICAgIENbXCJjb250YWluZXIgbWVtb3J5IGxpbWl0XCJdIC0tPiBIW1wic3BhcmsuZXhlY3V0b3IubWVtb3J5PGJyLz5KVk0gaGVhcFwiXVxuICAgIEMgLS0-IE9bXCJzcGFyay5leGVjdXRvci5tZW1vcnlPdmVyaGVhZDxici8-ZXZlcnl0aGluZyBvdXRzaWRlIHRoZSBoZWFwXCJdXG4gICAgQyAtLT4gT1NbXCJPUyAvIGNvbnRhaW5lclwiXVxuICAgIE8gLS0-IFBbXCJQeXRob24gd29ya2Vyczxici8-KGNhcHBlZCBieSBzcGFyay5leGVjdXRvci5weXNwYXJrLm1lbW9yeSk8YnIvPnlvdXIgVURGLCBQeUFycm93LCBwYW5kYXMsIE51bVB5XCJdXG4gICAgTyAtLT4gT0hbXCJKVk0gb2ZmLWhlYXA8YnIvPkFycm93IGJ1ZmZlcnMsIFR1bmdzdGVuXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBUQlxuICAgIENbXCJjb250YWluZXIgbWVtb3J5IGxpbWl0XCJdIC0tPiBIW1wic3BhcmsuZXhlY3V0b3IubWVtb3J5PGJyLz5KVk0gaGVhcFwiXVxuICAgIEMgLS0-IE9bXCJzcGFyay5leGVjdXRvci5tZW1vcnlPdmVyaGVhZDxici8-ZXZlcnl0aGluZyBvdXRzaWRlIHRoZSBoZWFwXCJdXG4gICAgQyAtLT4gT1NbXCJPUyAvIGNvbnRhaW5lclwiXVxuICAgIE8gLS0-IFBbXCJQeXRob24gd29ya2Vyczxici8-KGNhcHBlZCBieSBzcGFyay5leGVjdXRvci5weXNwYXJrLm1lbW9yeSk8YnIvPnlvdXIgVURGLCBQeUFycm93LCBwYW5kYXMsIE51bVB5XCJdXG4gICAgTyAtLT4gT0hbXCJKVk0gb2ZmLWhlYXA8YnIvPkFycm93IGJ1ZmZlcnMsIFR1bmdzdGVuXCJdXG4iLCAibWVybWFpZCI6IHsidGhlbWUiOiAiZGVmYXVsdCJ9fQ%3D%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="801" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule is that the container has to fit the heap, the overhead, and the OS, and that Python's share has to come out of the overhead.&lt;/strong&gt; If you use &lt;code&gt;toPandas()&lt;/code&gt; or pandas UDFs, &lt;code&gt;spark.sql.execution.arrow.pyspark.selfDestruct.enabled&lt;/code&gt; &lt;a href="https://spark.apache.org/docs/latest/configuration.html" rel="noopener noreferrer"&gt;frees Arrow buffers as they're converted&lt;/a&gt;, so the same data doesn't sit in memory twice.&lt;/p&gt;

&lt;p&gt;Beyond that, the usual advice still holds: let Adaptive Query Execution size shuffle partitions at runtime (it's been on by default since 3.2, and 4.1 extended it to stateless streaming, &lt;a href="https://issues.apache.org/jira/browse/SPARK-53941" rel="noopener noreferrer"&gt;SPARK-53941&lt;/a&gt;, which &lt;a href="https://spark.apache.org/docs/latest/streaming/ss-migration-guide.html" rel="noopener noreferrer"&gt;can change a streaming query's behaviour after an upgrade&lt;/a&gt;), and broadcast small dimension tables instead of shuffling them. None of that is specific to Python, which is why it's last on a list about Python.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's coming in 4.3
&lt;/h2&gt;

&lt;p&gt;The 4.3 branch hasn't been released (its first release candidate &lt;a href="https://lists.apache.org/thread/6bhgq2w84zrv3sl4wr2zl8p23fvbsfrg" rel="noopener noreferrer"&gt;failed its vote on September 6&lt;/a&gt;, and there's no new date), but it shows where the boundary is heading, and it confirms the order above.&lt;/p&gt;

&lt;p&gt;The most interesting change goes around the boundary instead of through it: experimental transpilation of Python UDFs into Spark expressions (SPIP &lt;a href="https://issues.apache.org/jira/browse/SPARK-54783" rel="noopener noreferrer"&gt;SPARK-54783&lt;/a&gt;, &lt;code&gt;spark.sql.experimental.optimizer.transpilePyUDFs&lt;/code&gt;). &lt;strong&gt;A simple enough Python function would never cross into Python at all&lt;/strong&gt;, which is change number 1 done for you. It's off by default, requires ANSI mode, and only works in classic mode, not Connect, but it's the only approach on the branch that could get under the per-row ceiling rather than lowering the cost of hitting it.&lt;/p&gt;

&lt;p&gt;Alongside it are smaller changes to the crossing itself: pipelined, asynchronous transfer of UDF batches (&lt;code&gt;spark.python.udf.pipelined.enabled&lt;/code&gt;, &lt;a href="https://issues.apache.org/jira/browse/SPARK-56642" rel="noopener noreferrer"&gt;SPARK-56642&lt;/a&gt;, off by default) and a cap on bytes per UDF batch (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57593" rel="noopener noreferrer"&gt;SPARK-57593&lt;/a&gt;). A proposal to make Spark better on a single machine also adds an Arrow format for Spark's in-memory cache (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57268" rel="noopener noreferrer"&gt;SPARK-57268&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Two things haven't moved. PySpark still doesn't support pandas 3, even on the 4.3 branch; it pins pandas below 3.0, and the ticket to support it (&lt;a href="https://issues.apache.org/jira/browse/SPARK-55139" rel="noopener noreferrer"&gt;SPARK-55139&lt;/a&gt;) is open, so the PyArrow-backed string type pandas 3 made the default in January won't help pandas UDFs yet. And Real-Time Mode in 4.3 gains stateful operators, but still can't run Python UDFs (&lt;a href="https://issues.apache.org/jira/browse/SPARK-57237" rel="noopener noreferrer"&gt;SPARK-57237&lt;/a&gt; is open).&lt;/p&gt;

&lt;p&gt;Housekeeping to plan for: &lt;a href="https://github.com/apache/spark/blob/branch-4.3/python/docs/source/migration_guide/pyspark_upgrade.rst" rel="noopener noreferrer"&gt;4.2 raised the minimum PyArrow to 18.0.0 and dropped PyPy&lt;/a&gt;, and 4.3 drops Python 3.10 (&lt;a href="https://issues.apache.org/jira/browse/SPARK-56889" rel="noopener noreferrer"&gt;SPARK-56889&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;I opened with a UDF that took 10 seconds at 100 million rows and was the fastest Python option at 100 thousand. The reason is the same one behind every change on this list: your function was never the expensive part.&lt;/p&gt;

&lt;p&gt;The crossing was, and the best PySpark optimization is still the one that decides the data doesn't need to cross.&lt;/p&gt;

</description>
      <category>spark</category>
      <category>pyspark</category>
      <category>performance</category>
      <category>arrow</category>
    </item>
    <item>
      <title>Stop Blaming Serialization for Slow Python UDFs</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:19:38 +0000</pubDate>
      <link>https://dev.to/lisancao/stop-blaming-serialization-for-slow-python-udfs-3kl5</link>
      <guid>https://dev.to/lisancao/stop-blaming-serialization-for-slow-python-udfs-3kl5</guid>
      <description>&lt;h2&gt;
  
  
  The benchmark that tested the wrong Arrow
&lt;/h2&gt;

&lt;p&gt;Earlier this year I built a benchmark to answer a question that comes up constantly: if you need custom Python logic in Spark, which kind of UDF should you write, and what does it cost? It compared seven options across three workloads (trivial arithmetic, a string transform, and a normal CDF) at scales from 100K to 100M rows.&lt;/p&gt;

&lt;p&gt;The result my write-up leaned on hardest was a pair of rows that came out almost identical: a plain &lt;code&gt;@udf&lt;/code&gt; ran at 48.5x the cost of the equivalent built-in function at 50M rows, and the same &lt;code&gt;@udf&lt;/code&gt; "with Arrow turned on" ran at 48.9x (69.2x against 69.0x at 100M). I called it the key insight: switching transport from pickle to Arrow buys nothing when your function still executes one row at a time.&lt;/p&gt;

&lt;p&gt;The conclusion about per-row execution is right, but the evidence wasn't what I thought. This is the setting the benchmark toggled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What my benchmark flipped for its "@udf + Arrow" case:
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.execution.arrow.pyspark.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ...which only governs DataFrame.toPandas() and createDataFrame(pandas_df).
# It never touches Python UDFs.
&lt;/span&gt;
&lt;span class="c1"&gt;# What actually moves a regular Python UDF onto Arrow:
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.execution.pythonUDF.arrow.enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# session-wide
&lt;/span&gt;&lt;span class="nd"&gt;@udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;useArrow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                        &lt;span class="c1"&gt;# or per UDF
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;The PySpark documentation&lt;/a&gt; is explicit that &lt;code&gt;arrow.pyspark.enabled&lt;/code&gt; controls pandas conversions. So my two nearly identical rows were the same pickled UDF measured twice, and the gap between them (under 1%) is just how much two runs of the same thing vary.&lt;/p&gt;

&lt;p&gt;I had built an A/A test by accident, in a benchmark that existed precisely because Spark's "Arrow" naming is easy to confuse.&lt;/p&gt;

&lt;p&gt;That mistake is the best evidence I have for the actual thesis: &lt;strong&gt;the cost of a Python UDF is decided far more by what your function receives (a single value, a pandas Series, or an Arrow array) than by whether Arrow carries the bytes, and because "Arrow" names several different mechanisms, it's easy to flip the switch that sounds right and optimize nothing.&lt;/strong&gt; Here is what each of them actually does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you set&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Affects your UDF?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.execution.arrow.pyspark.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;toPandas()&lt;/code&gt; and &lt;code&gt;createDataFrame(pandas_df)&lt;/code&gt; conversions&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf(useArrow=True)&lt;/code&gt; or &lt;code&gt;spark.sql.execution.pythonUDF.arrow.enabled&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Moves a regular UDF to Arrow batches (&lt;code&gt;ArrowEvalPython&lt;/code&gt;); your function still gets one scalar per call. On by default since Spark 4.2&lt;/td&gt;
&lt;td&gt;Yes, 1.4x to 2.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrow batches; your function gets a &lt;code&gt;pandas.Series&lt;/code&gt; per call&lt;/td&gt;
&lt;td&gt;Yes, vectorized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@arrow_udf&lt;/code&gt; (Spark 4.1+, SPARK-53014)&lt;/td&gt;
&lt;td&gt;Arrow batches; your function gets a &lt;code&gt;pyarrow.Array&lt;/code&gt; per call&lt;/td&gt;
&lt;td&gt;Yes, vectorized, no pandas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Three choices hiding behind every decorator
&lt;/h2&gt;

&lt;p&gt;Every Python UDF call makes three independent choices: how data crosses from the JVM to the Python worker (transport), which physical operator Spark plans (&lt;code&gt;BatchEvalPython&lt;/code&gt; or &lt;code&gt;ArrowEvalPython&lt;/code&gt;), and what your function receives once the data arrives (the execution model). &lt;strong&gt;The last one determines how many times Python has to be entered.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIEpWTVtcIkpWTSBleGVjdXRvcjxici8-KFNwYXJrIHJvd3MpXCJdXG4gICAgSlZNIC0tPnxcIkB1ZGYgKGRlZmF1bHQpPGJyLz5waWNrbGUsIEJhdGNoRXZhbFB5dGhvblwifCBSMVtcInlvdXIgZnVuY3Rpb24gY2FsbGVkPGJyLz5vbmNlIHBlciBST1c8YnIvPihQeXRob24gc2NhbGFycylcIl1cbiAgICBKVk0gLS0-fFwiQHVkZih1c2VBcnJvdz1UcnVlKTxici8-QXJyb3cgYmF0Y2hlcywgQXJyb3dFdmFsUHl0aG9uXCJ8IFIyW1wiYmF0Y2ggdW5wYWNrZWQsPGJyLz5mdW5jdGlvbiBjYWxsZWQ8YnIvPm9uY2UgcGVyIFJPV1wiXVxuICAgIEpWTSAtLT58XCJAcGFuZGFzX3VkZjxici8-QXJyb3cgYmF0Y2hlcywgQXJyb3dFdmFsUHl0aG9uXCJ8IEIxW1wiZnVuY3Rpb24gY2FsbGVkPGJyLz5vbmNlIHBlciBCQVRDSDxici8-KHBhbmRhcy5TZXJpZXMpXCJdXG4gICAgSlZNIC0tPnxcIkBhcnJvd191ZGY8YnIvPkFycm93IGJhdGNoZXMsIEFycm93RXZhbFB5dGhvblwifCBCMltcImZ1bmN0aW9uIGNhbGxlZDxici8-b25jZSBwZXIgQkFUQ0g8YnIvPihweWFycm93LkFycmF5KVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIEpWTVtcIkpWTSBleGVjdXRvcjxici8-KFNwYXJrIHJvd3MpXCJdXG4gICAgSlZNIC0tPnxcIkB1ZGYgKGRlZmF1bHQpPGJyLz5waWNrbGUsIEJhdGNoRXZhbFB5dGhvblwifCBSMVtcInlvdXIgZnVuY3Rpb24gY2FsbGVkPGJyLz5vbmNlIHBlciBST1c8YnIvPihQeXRob24gc2NhbGFycylcIl1cbiAgICBKVk0gLS0-fFwiQHVkZih1c2VBcnJvdz1UcnVlKTxici8-QXJyb3cgYmF0Y2hlcywgQXJyb3dFdmFsUHl0aG9uXCJ8IFIyW1wiYmF0Y2ggdW5wYWNrZWQsPGJyLz5mdW5jdGlvbiBjYWxsZWQ8YnIvPm9uY2UgcGVyIFJPV1wiXVxuICAgIEpWTSAtLT58XCJAcGFuZGFzX3VkZjxici8-QXJyb3cgYmF0Y2hlcywgQXJyb3dFdmFsUHl0aG9uXCJ8IEIxW1wiZnVuY3Rpb24gY2FsbGVkPGJyLz5vbmNlIHBlciBCQVRDSDxici8-KHBhbmRhcy5TZXJpZXMpXCJdXG4gICAgSlZNIC0tPnxcIkBhcnJvd191ZGY8YnIvPkFycm93IGJhdGNoZXMsIEFycm93RXZhbFB5dGhvblwifCBCMltcImZ1bmN0aW9uIGNhbGxlZDxici8-b25jZSBwZXIgQkFUQ0g8YnIvPihweWFycm93LkFycmF5KVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 1" width="622" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UDF type&lt;/th&gt;
&lt;th&gt;Operator&lt;/th&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Calls into your Python&lt;/th&gt;
&lt;th&gt;Receives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; (default, pre-4.2)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BatchEvalPython&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pickle&lt;/td&gt;
&lt;td&gt;once per row&lt;/td&gt;
&lt;td&gt;scalar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@udf(useArrow=True)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ArrowEvalPython&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrow&lt;/td&gt;
&lt;td&gt;once per row&lt;/td&gt;
&lt;td&gt;scalar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ArrowEvalPython&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrow&lt;/td&gt;
&lt;td&gt;once per batch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pandas.Series&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@arrow_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ArrowEvalPython&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrow&lt;/td&gt;
&lt;td&gt;once per batch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pyarrow.Array&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the exact code the benchmark ran for the arithmetic workload (&lt;code&gt;x * 2 + 1&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                    &lt;span class="c1"&gt;# once per row, pickle
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;py_arith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="nd"&gt;@udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;useArrow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# once per row, Arrow batches
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;use_arrow_arith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="nd"&gt;@pandas_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;             &lt;span class="c1"&gt;# once per batch, pandas.Series
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pandas_arith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="nd"&gt;@arrow_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;              &lt;span class="c1"&gt;# once per batch, pyarrow.Array
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;arrow_arith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for the string workload (&lt;code&gt;upper(x) + '_SUFFIX'&lt;/code&gt;), where the two vectorized options separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pandas_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pandas_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_SUFFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@arrow_udf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;arrow_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;upper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utf8_upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_SUFFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# see note below
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;binary_join_element_wise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the measurements show
&lt;/h2&gt;

&lt;p&gt;Median wall-clock seconds at 50M rows, with the multiple of the built-in time in parentheses (the mislabeled "Arrow config" row is omitted, since it was a second run of the default &lt;code&gt;@udf&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;arithmetic&lt;/th&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;normal CDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;built-in functions&lt;/td&gt;
&lt;td&gt;0.107&lt;/td&gt;
&lt;td&gt;0.486&lt;/td&gt;
&lt;td&gt;0.176&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL UDF&lt;/td&gt;
&lt;td&gt;0.095 (0.9x)&lt;/td&gt;
&lt;td&gt;0.477 (1.0x)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@arrow_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.725 (6.8x)&lt;/td&gt;
&lt;td&gt;1.545 (3.2x)&lt;/td&gt;
&lt;td&gt;0.762 (4.3x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.789 (7.4x)&lt;/td&gt;
&lt;td&gt;2.512 (5.2x)&lt;/td&gt;
&lt;td&gt;0.819 (4.7x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@udf(useArrow=True)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.228 (20.8x)&lt;/td&gt;
&lt;td&gt;4.430 (9.1x)&lt;/td&gt;
&lt;td&gt;2.942 (16.7x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; (default, pickle)&lt;/td&gt;
&lt;td&gt;5.199 (48.5x)&lt;/td&gt;
&lt;td&gt;6.216 (12.8x)&lt;/td&gt;
&lt;td&gt;6.183 (35.1x)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And at 100M rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;arithmetic&lt;/th&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;normal CDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;built-in functions&lt;/td&gt;
&lt;td&gt;0.147&lt;/td&gt;
&lt;td&gt;0.910&lt;/td&gt;
&lt;td&gt;0.267&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@arrow_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.332&lt;/td&gt;
&lt;td&gt;2.904&lt;/td&gt;
&lt;td&gt;1.333&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.400&lt;/td&gt;
&lt;td&gt;4.743&lt;/td&gt;
&lt;td&gt;1.449&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@udf(useArrow=True)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.188&lt;/td&gt;
&lt;td&gt;8.735&lt;/td&gt;
&lt;td&gt;5.658&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; (default, pickle)&lt;/td&gt;
&lt;td&gt;10.139&lt;/td&gt;
&lt;td&gt;12.212&lt;/td&gt;
&lt;td&gt;12.060&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stand out, and each one follows from the execution model rather than the transport.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arrow batching helps a row-at-a-time UDF, but only about 2x.&lt;/strong&gt; &lt;code&gt;useArrow=True&lt;/code&gt; took arithmetic from 5.20s to 2.23s and the CDF from 6.18s to 2.94s, because &lt;code&gt;ArrowEvalPython&lt;/code&gt; moves data in efficient batches instead of pickling rows. It still calls your function once per value, though, and that per-call cost is a hard ceiling: across every workload and scale, the row-at-a-time UDFs topped out at roughly 8 to 10 million rows per second (50M rows in 5.2s; 100M in 10.1s), which is about the rate at which CPython can be entered and exited no matter what's computed inside.&lt;/p&gt;

&lt;p&gt;The vectorized UDFs cross into Python once per batch instead, and they were about 3x faster again than &lt;code&gt;useArrow=True&lt;/code&gt; on arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Between the vectorized options, the gap is almost entirely strings.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;@arrow_udf&lt;/code&gt; speedup over &lt;code&gt;@pandas_udf&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;arithmetic&lt;/th&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;CDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50M rows&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;td&gt;1.63x&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100M rows&lt;/td&gt;
&lt;td&gt;1.05x&lt;/td&gt;
&lt;td&gt;1.63x&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That split is what you'd expect from how the two libraries lay out data. A numeric pandas Series is a NumPy array underneath, so converting from Arrow is cheap and the arithmetic is the same vectorized arithmetic either way. Strings are different; pandas has traditionally stored them as an array of Python objects, each its own heap allocation, while Arrow keeps them in one contiguous buffer plus an array of offsets. Converting to pandas means building millions of Python objects, and every operation afterwards walks memory scattered across the heap.&lt;/p&gt;

&lt;p&gt;If anything the benchmark understates the gap, because my &lt;code&gt;@arrow_udf&lt;/code&gt; string function builds a Python list of suffixes the length of every batch before joining them, which is exactly the per-element Python work &lt;code&gt;pyarrow.compute&lt;/code&gt; exists to avoid. The CDF case is close for the opposite reason: &lt;code&gt;pyarrow.compute&lt;/code&gt; has no &lt;code&gt;erf&lt;/code&gt;, so my "Arrow" CDF converts to NumPy and calls SciPy, just like the pandas version does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale changes which choice matters.&lt;/strong&gt; Arithmetic workload, median seconds (the &lt;code&gt;@arrow_udf&lt;/code&gt; column is blank below 50M because it wasn't a separate case in those earlier runs):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;built-in&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@arrow_udf&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@pandas_udf&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;useArrow=True&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;@udf&lt;/code&gt; (pickle)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;0.073&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.181&lt;/td&gt;
&lt;td&gt;0.212&lt;/td&gt;
&lt;td&gt;0.161&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;0.077&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.200&lt;/td&gt;
&lt;td&gt;0.248&lt;/td&gt;
&lt;td&gt;0.258&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10M&lt;/td&gt;
&lt;td&gt;0.074&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.299&lt;/td&gt;
&lt;td&gt;0.568&lt;/td&gt;
&lt;td&gt;1.092&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50M&lt;/td&gt;
&lt;td&gt;0.107&lt;/td&gt;
&lt;td&gt;0.725&lt;/td&gt;
&lt;td&gt;0.789&lt;/td&gt;
&lt;td&gt;2.228&lt;/td&gt;
&lt;td&gt;5.199&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100M&lt;/td&gt;
&lt;td&gt;0.147&lt;/td&gt;
&lt;td&gt;1.332&lt;/td&gt;
&lt;td&gt;1.400&lt;/td&gt;
&lt;td&gt;4.188&lt;/td&gt;
&lt;td&gt;10.139&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 100K rows every Python UDF was within 2x to 6x of the built-ins, and the plain pickled &lt;code&gt;@udf&lt;/code&gt; was actually the fastest Python option on arithmetic, because fixed costs (starting Python workers, setting up batches) dominate everything at that size. The tiers only separate cleanly somewhere around 10M rows.&lt;/p&gt;

&lt;p&gt;If your job processes a few hundred thousand rows, UDF type is unlikely to be what's making it slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'd trust the seconds over the multipliers
&lt;/h2&gt;

&lt;p&gt;There's a second correction to my own write-up, and it applies to almost any benchmark reported as "Nx slower." My report said row-at-a-time UDFs showed superlinear growth, from 48.5x at 50M to 69.2x at 100M. &lt;strong&gt;That's true of the ratio and false of the thing you care about:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;default &lt;code&gt;@udf&lt;/code&gt;, arithmetic&lt;/th&gt;
&lt;th&gt;50M&lt;/th&gt;
&lt;th&gt;100M&lt;/th&gt;
&lt;th&gt;growth for 2x rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@udf&lt;/code&gt; absolute time&lt;/td&gt;
&lt;td&gt;5.199s&lt;/td&gt;
&lt;td&gt;10.139s&lt;/td&gt;
&lt;td&gt;1.95x (linear)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;built-in absolute time&lt;/td&gt;
&lt;td&gt;0.107s&lt;/td&gt;
&lt;td&gt;0.147s&lt;/td&gt;
&lt;td&gt;1.37x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ratio&lt;/td&gt;
&lt;td&gt;48.5x&lt;/td&gt;
&lt;td&gt;69.2x&lt;/td&gt;
&lt;td&gt;"superlinear"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ratio grew because its denominator grew slowly; the built-in at this scale is mostly fixed overhead.&lt;/p&gt;

&lt;p&gt;The baseline has another problem I can't fully explain away. SQL UDFs, which compile to the same plan as built-ins, came out slightly faster than them at every scale (0.64x to 0.98x). The likeliest cause is ordering: the built-in case always ran first in each workload and may have absorbed start-up cost despite the warmup. If so, every multiplier here is somewhat inflated. An earlier set of my own 50M runs, before I raised driver memory, had the built-in at 0.25s and the pickled &lt;code&gt;@udf&lt;/code&gt; at about 22x, while the &lt;code&gt;@udf&lt;/code&gt;'s own time moved only about 10%.&lt;/p&gt;

&lt;p&gt;The headline multiplier more than doubled on the strength of the baseline alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has changed, and what I didn't measure
&lt;/h2&gt;

&lt;p&gt;Spark 4.2 &lt;a href="https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;turns Arrow optimization on by default&lt;/a&gt; for regular Python UDFs, after an attempt to do the same for 4.0 was reverted (&lt;a href="https://issues.apache.org/jira/browse/SPARK-48516" rel="noopener noreferrer"&gt;SPARK-48516&lt;/a&gt;). On 4.2 a plain &lt;code&gt;@udf&lt;/code&gt; should therefore behave like my &lt;code&gt;useArrow=True&lt;/code&gt; rows, a free 2x for anyone who never touches the setting; I haven't rerun the benchmark on 4.2, so &lt;strong&gt;that's an expectation, not a measurement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I also only benchmarked scalar UDFs. The partition-level APIs &lt;code&gt;mapInPandas&lt;/code&gt; and &lt;code&gt;mapInArrow&lt;/code&gt;, and the grouped &lt;code&gt;applyInPandas&lt;/code&gt; and &lt;code&gt;applyInArrow&lt;/code&gt;, follow the same logic about what your function receives (the Arrow variants skip pandas conversion entirely), and they're the right tools when you need to change the number of rows or see a whole partition or group at once. I have no measurements for them, and I'd rather say so than borrow numbers.&lt;/p&gt;

&lt;p&gt;The benchmark's own limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One machine, &lt;code&gt;local[*]&lt;/code&gt;, via Spark Connect; hardware not recorded.&lt;/li&gt;
&lt;li&gt;Synthetic, low-cardinality data (&lt;code&gt;spark.range&lt;/code&gt; plus two derived columns, short strings), cached in memory before timing.&lt;/li&gt;
&lt;li&gt;Each case materialized with a count, not written anywhere.&lt;/li&gt;
&lt;li&gt;One warmup, median of three timed runs, no variance recorded.&lt;/li&gt;
&lt;li&gt;The 100K to 10M runs predate the 32GB driver setting and the separate &lt;code&gt;@arrow_udf&lt;/code&gt; case, so there's no genuine &lt;code&gt;@arrow_udf&lt;/code&gt; data below 50M.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this changes the ordering, which held everywhere, but the exact multipliers are specific to this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing, based on what was measured
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEFbXCJOZWVkIGN1c3RvbSBsb2dpY1wiXSAtLT4gQntcIkV4cHJlc3NpYmxlIHdpdGggYnVpbHQtaW48YnIvPmZ1bmN0aW9ucyBvciBhIFNRTCBVREY_XCJ9XG4gICAgQiAtLT58eWVzfCBDW1wiVXNlIHRoYXQuPGJyLz5-MXg6IG5vdGhpbmcgaW4gUHl0aG9uIGNhbWUgY2xvc2VcIl1cbiAgICBCIC0tPnxub3wgRHtcIkNhbiB5b3VyIGZ1bmN0aW9uPGJyLz53b3JrIG9uIGEgd2hvbGUgYmF0Y2g_XCJ9XG4gICAgRCAtLT58eWVzfCBFe1wiTWFwcyBvbnRvIHB5YXJyb3cuY29tcHV0ZSw8YnIvPmVzcGVjaWFsbHkgc3RyaW5ncz9cIn1cbiAgICBFIC0tPnx5ZXN8IEZbXCJAYXJyb3dfdWRmPGJyLz5mYXN0ZXN0OyAxLjZ4IG92ZXIgcGFuZGFzIG9uIHN0cmluZ3NcIl1cbiAgICBFIC0tPnxcIm5lZWRzIHBhbmRhcyAvIE51bVB5IC8gU2NpUHlcInwgR1tcIkBwYW5kYXNfdWRmPGJyLz53aXRoaW4gfjUtOSUgb24gbnVtZXJpYyB3b3JrXCJdXG4gICAgRCAtLT58XCJubywgbmVlZHMgb25lIHZhbHVlIGF0IGEgdGltZVwifCBIW1wiQHVkZih1c2VBcnJvdz1UcnVlKTxici8-b3IgdGhlIGRlZmF1bHQgb24gU3BhcmsgNC4yPGJyLz4xLjQtMi40eCBvdmVyIHBpY2tsZVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIEFbXCJOZWVkIGN1c3RvbSBsb2dpY1wiXSAtLT4gQntcIkV4cHJlc3NpYmxlIHdpdGggYnVpbHQtaW48YnIvPmZ1bmN0aW9ucyBvciBhIFNRTCBVREY_XCJ9XG4gICAgQiAtLT58eWVzfCBDW1wiVXNlIHRoYXQuPGJyLz5-MXg6IG5vdGhpbmcgaW4gUHl0aG9uIGNhbWUgY2xvc2VcIl1cbiAgICBCIC0tPnxub3wgRHtcIkNhbiB5b3VyIGZ1bmN0aW9uPGJyLz53b3JrIG9uIGEgd2hvbGUgYmF0Y2g_XCJ9XG4gICAgRCAtLT58eWVzfCBFe1wiTWFwcyBvbnRvIHB5YXJyb3cuY29tcHV0ZSw8YnIvPmVzcGVjaWFsbHkgc3RyaW5ncz9cIn1cbiAgICBFIC0tPnx5ZXN8IEZbXCJAYXJyb3dfdWRmPGJyLz5mYXN0ZXN0OyAxLjZ4IG92ZXIgcGFuZGFzIG9uIHN0cmluZ3NcIl1cbiAgICBFIC0tPnxcIm5lZWRzIHBhbmRhcyAvIE51bVB5IC8gU2NpUHlcInwgR1tcIkBwYW5kYXNfdWRmPGJyLz53aXRoaW4gfjUtOSUgb24gbnVtZXJpYyB3b3JrXCJdXG4gICAgRCAtLT58XCJubywgbmVlZHMgb25lIHZhbHVlIGF0IGEgdGltZVwifCBIW1wiQHVkZih1c2VBcnJvdz1UcnVlKTxici8-b3IgdGhlIGRlZmF1bHQgb24gU3BhcmsgNC4yPGJyLz4xLjQtMi40eCBvdmVyIHBpY2tsZVwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 2" width="751" height="1296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whichever you choose, check which "Arrow" your setting actually refers to.&lt;/strong&gt; Mine didn't, and I wrote a benchmark about exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://issues.apache.org/jira/browse/SPARK-53014" rel="noopener noreferrer"&gt;SPARK-53014&lt;/a&gt;: Arrow UDF public API (&lt;code&gt;@arrow_udf&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://issues.apache.org/jira/browse/SPARK-40307" rel="noopener noreferrer"&gt;SPARK-40307&lt;/a&gt;: Arrow-optimized Python UDFs (&lt;code&gt;useArrow&lt;/code&gt;), Spark 3.5.0.&lt;/li&gt;
&lt;li&gt;Benchmark code and raw results: &lt;code&gt;spark-udf-benchmark&lt;/code&gt; (Spark 4.1.0, Spark Connect, &lt;code&gt;local[*]&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spark</category>
      <category>pyspark</category>
      <category>arrow</category>
      <category>benchmarks</category>
    </item>
  </channel>
</rss>
