<?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>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>Explaining the Python-JVM Boundary in Spark, and What Arrow Changed</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.&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;p&gt;The rest of this post is the story of Spark adopting it, one door at a time.&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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIEpWTVtcIkpWTSBkcml2ZXJcIl1cbiAgICAgICAgQVtcIkludGVybmFsUm93IGJhdGNoZXNcIl0gLS0-IEJbXCJBcnJvdyBSZWNvcmRCYXRjaGVzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggUFlbXCJQeXRob24gZHJpdmVyXCJdXG4gICAgICAgIENbXCJweWFycm93IHJlYWRzIFJlY29yZEJhdGNoZXM8YnIvPihhIGNvcHkgYWNyb3NzIHByb2Nlc3Nlcyw8YnIvPm5vIHBlci1yb3cgZGVjb2RpbmcpXCJdIC0tPiBEW1wiVGFibGUudG9fcGFuZGFzKCk8YnIvPih6ZXJvLWNvcHkgZm9yIG1hbnk8YnIvPm51bWVyaWMgdHlwZXMpXCJdXG4gICAgZW5kXG4gICAgQiAtLT58XCJBcnJvdyBJUEMgc3RyZWFtXCJ8IENcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIEpWTVtcIkpWTSBkcml2ZXJcIl1cbiAgICAgICAgQVtcIkludGVybmFsUm93IGJhdGNoZXNcIl0gLS0-IEJbXCJBcnJvdyBSZWNvcmRCYXRjaGVzXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggUFlbXCJQeXRob24gZHJpdmVyXCJdXG4gICAgICAgIENbXCJweWFycm93IHJlYWRzIFJlY29yZEJhdGNoZXM8YnIvPihhIGNvcHkgYWNyb3NzIHByb2Nlc3Nlcyw8YnIvPm5vIHBlci1yb3cgZGVjb2RpbmcpXCJdIC0tPiBEW1wiVGFibGUudG9fcGFuZGFzKCk8YnIvPih6ZXJvLWNvcHkgZm9yIG1hbnk8YnIvPm51bWVyaWMgdHlwZXMpXCJdXG4gICAgZW5kXG4gICAgQiAtLT58XCJBcnJvdyBJUEMgc3RyZWFtXCJ8IENcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 2" 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;h2&gt;
  
  
  What actually crosses the socket
&lt;/h2&gt;

&lt;p&gt;Before going further it's worth seeing what's moving, because every later boundary sends the same thing. &lt;a href="https://arrow.apache.org/docs/python/ipc.html" rel="noopener noreferrer"&gt;Arrow's IPC stream format&lt;/a&gt; is 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;The strings are the detail to hold on to: &lt;strong&gt;in Arrow, a million strings are one buffer and an array of offsets.&lt;/strong&gt; 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 I'll leave numbers out of this section; 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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIENsaWVudFtcIlNwYXJrIENvbm5lY3QgY2xpZW50IChubyBKVk0pXCJdXG4gICAgICAgIEMxW1wiUHl0aG9uIGFwcCBidWlsZHM8YnIvPmEgbG9naWNhbCBwbGFuXCJdXG4gICAgICAgIEMyW1wiUmVjZWl2ZXMgdGhlIEFycm93IHN0cmVhbSw8YnIvPnJlY29uc3RydWN0cyB0aGUgRGF0YUZyYW1lXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggU2VydmVyW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXIgKGZ1bGwgU3BhcmsgcnVudGltZSlcIl1cbiAgICAgICAgUzFbXCJDYXRhbHlzdCBvcHRpbWl6ZXIsPGJyLz5waHlzaWNhbCBleGVjdXRpb25cIl1cbiAgICAgICAgUzJbXCJSZXN1bHRzIGNvbnZlcnRlZCB0bzxici8-QXJyb3cgUmVjb3JkQmF0Y2hlc1wiXVxuICAgIGVuZFxuICAgIEMxIC0tPnxcInVucmVzb2x2ZWQgcGxhbjxici8-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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIENsaWVudFtcIlNwYXJrIENvbm5lY3QgY2xpZW50IChubyBKVk0pXCJdXG4gICAgICAgIEMxW1wiUHl0aG9uIGFwcCBidWlsZHM8YnIvPmEgbG9naWNhbCBwbGFuXCJdXG4gICAgICAgIEMyW1wiUmVjZWl2ZXMgdGhlIEFycm93IHN0cmVhbSw8YnIvPnJlY29uc3RydWN0cyB0aGUgRGF0YUZyYW1lXCJdXG4gICAgZW5kXG4gICAgc3ViZ3JhcGggU2VydmVyW1wiU3BhcmsgQ29ubmVjdCBzZXJ2ZXIgKGZ1bGwgU3BhcmsgcnVudGltZSlcIl1cbiAgICAgICAgUzFbXCJDYXRhbHlzdCBvcHRpbWl6ZXIsPGJyLz5waHlzaWNhbCBleGVjdXRpb25cIl1cbiAgICAgICAgUzJbXCJSZXN1bHRzIGNvbnZlcnRlZCB0bzxici8-QXJyb3cgUmVjb3JkQmF0Y2hlc1wiXVxuICAgIGVuZFxuICAgIEMxIC0tPnxcInVucmVzb2x2ZWQgcGxhbjxici8-KFByb3RvYnVmIG92ZXIgZ1JQQylcInwgUzEgLS0-IFMyIC0tPnxcIkFycm93IElQQyBvdmVyIGdSUENcInwgQzJcbiIsICJtZXJtYWlkIjogeyJ0aGVtZSI6ICJkZWZhdWx0In19%3FbgColor%3DFFFFFF" alt="diagram 3" 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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIEJlZm9yZVtcIlBpY2tsZSBAdWRmXCJdXG4gICAgICAgIEExW1wiSlZNOiBlYWNoIHJvd1wiXSAtLT4gQTJbXCJwaWNrbGUodmFsdWUpXCJdIC0tPiBBM1tcInNvY2tldFwiXSAtLT4gQTRbXCJQeXRob246IHVucGlja2xlXCJdIC0tPiBBNVtcImNhbGwgZnVuY3Rpb25cIl1cbiAgICBlbmRcbiAgICBzdWJncmFwaCBBZnRlcltcIkFycm93LW9wdGltaXplZCBAdWRmXCJdXG4gICAgICAgIEIxW1wiSlZNOiBiYXRjaCBvZiByb3dzXCJdIC0tPiBCMltcIkFycm93IFJlY29yZEJhdGNoXCJdIC0tPiBCM1tcInNvY2tldFwiXSAtLT4gQjRbXCJQeXRob246IHVucGFjayBiYXRjaFwiXSAtLT4gQjVbXCJjYWxsIGZ1bmN0aW9uIHBlciB2YWx1ZVwiXVxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%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%2FeyJjb2RlIjogImZsb3djaGFydCBMUlxuICAgIHN1YmdyYXBoIEJlZm9yZVtcIlBpY2tsZSBAdWRmXCJdXG4gICAgICAgIEExW1wiSlZNOiBlYWNoIHJvd1wiXSAtLT4gQTJbXCJwaWNrbGUodmFsdWUpXCJdIC0tPiBBM1tcInNvY2tldFwiXSAtLT4gQTRbXCJQeXRob246IHVucGlja2xlXCJdIC0tPiBBNVtcImNhbGwgZnVuY3Rpb25cIl1cbiAgICBlbmRcbiAgICBzdWJncmFwaCBBZnRlcltcIkFycm93LW9wdGltaXplZCBAdWRmXCJdXG4gICAgICAgIEIxW1wiSlZNOiBiYXRjaCBvZiByb3dzXCJdIC0tPiBCMltcIkFycm93IFJlY29yZEJhdGNoXCJdIC0tPiBCM1tcInNvY2tldFwiXSAtLT4gQjRbXCJQeXRob246IHVucGFjayBiYXRjaFwiXSAtLT4gQjVbXCJjYWxsIGZ1bmN0aW9uIHBlciB2YWx1ZVwiXVxuICAgIGVuZFxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 4" 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;Line the UDF types up by what happens to the data and the progression is plain:&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;That's the strings detail from the socket section paying off. 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%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIFExe1wiRXhwcmVzc2libGUgd2l0aCBidWlsdC1pbjxici8-U3BhcmsgZnVuY3Rpb25zIG9yIFNRTD9cIn0gLS0-fHllc3wgQTFbXCJVc2UgdGhvc2UuIE5vdGhpbmcgaW4gUHl0aG9uIGNhbWUgY2xvc2UuXCJdXG4gICAgUTEgLS0-fG5vfCBRM3tcIkV4cHJlc3NpYmxlIHdpdGg8YnIvPnB5YXJyb3cuY29tcHV0ZT9cIn1cbiAgICBRMyAtLT58eWVzfCBBM1tcIkBhcnJvd191ZGY8YnIvPihsYXJnZXN0IHdpbiBvbiBzdHJpbmdzKVwiXVxuICAgIFEzIC0tPnxub3wgUTR7XCJOZWVkcyBwYW5kYXMgLyBOdW1QeSAvIFNjaVB5P1wifVxuICAgIFE0IC0tPnx5ZXN8IEE0W1wiQHBhbmRhc191ZGZcIl1cbiAgICBRNCAtLT58bm98IEE1W1wicGxhaW4gQHVkZjogQXJyb3cgdHJhbnNwb3J0PGJyLz5ieSBkZWZhdWx0IG9uIFNwYXJrIDQuMlwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%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%2FeyJjb2RlIjogImZsb3djaGFydCBURFxuICAgIFExe1wiRXhwcmVzc2libGUgd2l0aCBidWlsdC1pbjxici8-U3BhcmsgZnVuY3Rpb25zIG9yIFNRTD9cIn0gLS0-fHllc3wgQTFbXCJVc2UgdGhvc2UuIE5vdGhpbmcgaW4gUHl0aG9uIGNhbWUgY2xvc2UuXCJdXG4gICAgUTEgLS0-fG5vfCBRM3tcIkV4cHJlc3NpYmxlIHdpdGg8YnIvPnB5YXJyb3cuY29tcHV0ZT9cIn1cbiAgICBRMyAtLT58eWVzfCBBM1tcIkBhcnJvd191ZGY8YnIvPihsYXJnZXN0IHdpbiBvbiBzdHJpbmdzKVwiXVxuICAgIFEzIC0tPnxub3wgUTR7XCJOZWVkcyBwYW5kYXMgLyBOdW1QeSAvIFNjaVB5P1wifVxuICAgIFE0IC0tPnx5ZXN8IEE0W1wiQHBhbmRhc191ZGZcIl1cbiAgICBRNCAtLT58bm98IEE1W1wicGxhaW4gQHVkZjogQXJyb3cgdHJhbnNwb3J0PGJyLz5ieSBkZWZhdWx0IG9uIFNwYXJrIDQuMlwiXVxuIiwgIm1lcm1haWQiOiB7InRoZW1lIjogImRlZmF1bHQifX0%3D%3FbgColor%3DFFFFFF" alt="diagram 5" width="800" height="400"&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;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 6" 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;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 there's no new 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>
    <item>
      <title>How to Build an Open Lakehouse on Your Laptop</title>
      <dc:creator>lnc</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:42:20 +0000</pubDate>
      <link>https://dev.to/lisancao/how-to-build-an-open-lakehouse-on-your-laptop-2k35</link>
      <guid>https://dev.to/lisancao/how-to-build-an-open-lakehouse-on-your-laptop-2k35</guid>
      <description>&lt;p&gt;An &lt;em&gt;open&lt;/em&gt; lakehouse is one where every layer (storage, table format, engine, catalog, and the ML and AI tools on top) is built on open standards, so no layer is locked to a single vendor. I wrote about what that means and why it matters in &lt;a href="https://www.databricks.com/blog/what-open-lakehouse-open-data-standards-explained" rel="noopener noreferrer"&gt;What is an open lakehouse? Open data standards, explained&lt;/a&gt;. *&lt;em&gt;The practical payoff is that a team can change processing engines, add a query engine, adopt a second table format or swap a model framework without re-platforming the layers above or below. *&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;In practice, a each of these open source technologies will be a hosted service that each do one job: object storage holds the files, a table format turns files into tables, a catalog names the tables, Spark reads and writes them, a stream brings in what's happening now, a pipeline framework derives the tables people actually query, an orchestrator runs it all on a schedule, and experiment tracking records what you compute from it. This post builds all of it on your laptop, starting from an empty folder, with nothing but Docker and Python. Every file you need is in the post, in full: a &lt;code&gt;compose.yaml&lt;/code&gt; that grows by a few services per section, a handful of config files, and short Python scripts. You run plain &lt;code&gt;docker compose&lt;/code&gt; and &lt;code&gt;python&lt;/code&gt; commands, and nothing else.&lt;/p&gt;

&lt;p&gt;This post builds this stack manually layer by layer. If you want to just have a full packaged lakehouse, check out the project &lt;a href="https://github.com/open-lakehouse/open-lakehouse" rel="noopener noreferrer"&gt;open-lakehouse&lt;/a&gt;. It comes with several agent skills, a cli, and a reference in case you get stuck on one of the steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this tutorial covers
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjm4a8z3k9y4mfphh5et.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjm4a8z3k9y4mfphh5et.png" alt=" " width="800" height="1071"&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;Layer&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;th&gt;Image or package&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;SeaweedFS, an S3-compatible object store&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chrislusf/seaweedfs:3.80&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8333&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;a Spark master, a worker and a Spark Connect server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache/spark:4.2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15002 (UI 8080)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table format&lt;/td&gt;
&lt;td&gt;Delta Lake, with UniForm for Iceberg readers&lt;/td&gt;
&lt;td&gt;Delta 4.4.0, loaded by Spark&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog&lt;/td&gt;
&lt;td&gt;Unity Catalog OSS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unitycatalog/unitycatalog:v0.6.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8081&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg readers&lt;/td&gt;
&lt;td&gt;PyIceberg and DuckDB&lt;/td&gt;
&lt;td&gt;on your machine&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipelines&lt;/td&gt;
&lt;td&gt;Spark Declarative Pipelines&lt;/td&gt;
&lt;td&gt;part of Spark 4.2&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event log&lt;/td&gt;
&lt;td&gt;Kafka, single node, KRaft mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache/kafka:4.2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9092&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;Structured Streaming, including Real-Time Mode&lt;/td&gt;
&lt;td&gt;part of Spark 4.2&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration&lt;/td&gt;
&lt;td&gt;Airflow, in standalone mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache/airflow:3.3.2-python3.12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8085&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML&lt;/td&gt;
&lt;td&gt;MLflow tracking server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ghcr.io/mlflow/mlflow:v3.16.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything runs on one Spark version, 4.2.0, and the pieces that plug into Spark are pinned to match it. There's no separate database: Airflow and MLflow keep their metadata in SQLite and Unity Catalog in its embedded H2 database, which is plenty for one laptop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic Requirements
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker with the Compose plugin&lt;/strong&gt;, Compose v2.17 or newer. On Linux that's Docker Engine plus the &lt;code&gt;docker-compose-plugin&lt;/code&gt; package; on macOS, &lt;a href="https://docs.docker.com/desktop/setup/install/mac-install/" rel="noopener noreferrer"&gt;Docker Desktop&lt;/a&gt;; on Windows, Docker Desktop with WSL2, and you follow the Linux steps inside your WSL distribution (keep the project folder in your WSL home directory, not under &lt;code&gt;/mnt/c&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.10 or newer&lt;/strong&gt;, with &lt;code&gt;venv&lt;/code&gt; (on Ubuntu and WSL, &lt;code&gt;sudo apt install python3-venv&lt;/code&gt; if &lt;code&gt;python3 -m venv&lt;/code&gt; complains).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory and disk.&lt;/strong&gt; The whole stack uses about 9 GB of RAM once everything is up, so a 16 GB machine is comfortable; in Docker Desktop, raise the memory limit under Settings &amp;gt; Resources to at least 10 GB. Plan for about 12 GB of disk for images and downloads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran every step below on Ubuntu 24.04 with Docker Engine, copying each file and command out of this post into an empty folder.&lt;/p&gt;

&lt;p&gt;Every port the stack publishes is written in &lt;code&gt;compose.yaml&lt;/code&gt; as &lt;code&gt;${NAME:-default}&lt;/code&gt;, for example &lt;code&gt;"${S3_PORT:-8333}:8333"&lt;/code&gt;. If one of the defaults is already taken on your machine, pick another in a &lt;code&gt;.env&lt;/code&gt; file next to &lt;code&gt;compose.yaml&lt;/code&gt; (a line like &lt;code&gt;S3_PORT=18333&lt;/code&gt;; Compose reads that file on every command), and point the scripts at it with the matching variable from &lt;code&gt;common.py&lt;/code&gt; below (&lt;code&gt;export S3_ENDPOINT=http://localhost:18333&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  A folder and a Python environment
&lt;/h2&gt;

&lt;p&gt;Make an empty folder and give it a virtual environment with the clients this post uses:&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;mkdir &lt;/span&gt;open-lakehouse &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;open-lakehouse
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;pyspark-client&lt;span class="o"&gt;==&lt;/span&gt;4.2.0 &lt;span class="nv"&gt;pandas&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;2.3.3 &lt;span class="nv"&gt;boto3&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;1.43.93 kafka-python&lt;span class="o"&gt;==&lt;/span&gt;2.3.0 &lt;span class="nv"&gt;pyiceberg&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;0.12.0 &lt;span class="nv"&gt;duckdb&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;1.5.6 mlflow-skinny&lt;span class="o"&gt;==&lt;/span&gt;3.16.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pyspark-client&lt;/code&gt; is PySpark without the JVM: a thin client that sends query plans to a Spark Connect server and gets results back, so your machine never runs Spark itself. The others are the S3 client (&lt;code&gt;boto3&lt;/code&gt;), a Kafka producer for the test data, the two Iceberg readers, and the MLflow client. (&lt;code&gt;pandas&lt;/code&gt; is pinned below 3.0, which PySpark 4.2 doesn't fully support yet.) Open a new terminal later and you'll need &lt;code&gt;source .venv/bin/activate&lt;/code&gt; again in that folder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where everything goes
&lt;/h3&gt;

&lt;p&gt;Everything in this post lives in that one &lt;code&gt;open-lakehouse&lt;/code&gt; folder. By the end it looks 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;open-lakehouse/
├── .venv/                      the Python environment you just made
├── compose.yaml                every service, added layer by layer
├── s3.json                     SeaweedFS access keys
├── server.properties           Unity Catalog settings
├── spark-conf/
│   └── spark-defaults.conf     Spark settings, mounted into the Spark containers
├── pipeline/
│   ├── spark-pipeline.yml      the declarative pipeline's spec
│   └── transformations/
│       ├── bronze.py
│       ├── silver.sql
│       └── gold.sql
├── dags/
│   ├── medallion.py            the Airflow DAG that runs the pipeline
│   └── maintenance.py          the Airflow DAG that compacts and vacuums
├── common.py                   shared endpoints, imported by every script below
├── create_bucket.py
├── hello_spark.py
├── generate_orders.py
├── load_orders.py
├── register_orders.py
├── enable_uniform.py
├── read_iceberg.py
├── prepare_medallion.py
├── read_gold.py
├── stream_clean.py
├── stream_totals.py
├── check_stream.py
└── log_run.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label above each code block is the file's path, so &lt;code&gt;File: open-lakehouse/spark-conf/spark-defaults.conf&lt;/code&gt; means create that file in that subfolder. The Python scripts all sit at the top of the folder, next to &lt;code&gt;common.py&lt;/code&gt;, so they can import it. &lt;strong&gt;Run every command from inside &lt;code&gt;open-lakehouse&lt;/code&gt;, with the virtual environment active&lt;/strong&gt; (&lt;code&gt;source .venv/bin/activate&lt;/code&gt;); &lt;code&gt;docker compose&lt;/code&gt; finds &lt;code&gt;compose.yaml&lt;/code&gt; there, and &lt;code&gt;python generate_orders.py&lt;/code&gt; finds the script there.&lt;/p&gt;

&lt;p&gt;Every script imports its endpoints and clients from one small module, so the addresses live in one place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/common.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Endpoints and clients that every script in this tutorial shares.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&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;SparkSession&lt;/span&gt;

&lt;span class="n"&gt;SPARK_REMOTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;SPARK_REMOTE&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;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="n"&gt;S3_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;S3_ENDPOINT&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;http://localhost:8333&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;UC_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;UC_URL&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;http://localhost:8081&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;KAFKA_BOOTSTRAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;KAFKA_BOOTSTRAP&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="n"&gt;MLFLOW_TRACKING_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;MLFLOW_TRACKING_URI&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;http://localhost:5000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;S3_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S3_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&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;lakehouse-secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spark&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 Spark Connect session: a thin client, the work runs in the cluster.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&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="n"&gt;SPARK_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;def&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;An S3 client for SeaweedFS.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;region_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;us-east-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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;uc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Call the Unity Catalog REST API and return the JSON reply.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;UC_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/2.1/unity-catalog&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&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;body&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="n"&gt;headers&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;Content-Type&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;application/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;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;reply&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;json&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="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defaults are the ports the stack publishes on your machine. &lt;code&gt;s3()&lt;/code&gt; talks to the object store, &lt;code&gt;uc()&lt;/code&gt; to the catalog's REST API, and &lt;code&gt;spark()&lt;/code&gt; opens a Spark Connect session, which is all the scripts need to run on the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage: SeaweedFS
&lt;/h2&gt;

&lt;p&gt;A lakehouse keeps its tables as ordinary files in object storage: Parquet data files plus the table format's own log next to them. &lt;a href="https://github.com/seaweedfs/seaweedfs" rel="noopener noreferrer"&gt;SeaweedFS&lt;/a&gt; is a small open-source object store that speaks the S3 API, so Spark writes to it the same way it would write to Amazon S3, and every tool in this post that reads S3 can read it too.&lt;/p&gt;

&lt;p&gt;SeaweedFS needs to know who may use its S3 API. This identities file creates one user with a local key pair and full rights:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/s3.json&lt;/code&gt;&lt;/strong&gt;&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;"identities"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lakehouse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"credentials"&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;"accessKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lakehouse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"secretKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lakehouse-secret"&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;"actions"&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="s2"&gt;"Admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"List"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tagging"&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="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;Then the first version of &lt;code&gt;compose.yaml&lt;/code&gt;, with one service:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;&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;seaweedfs&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;chrislusf/seaweedfs:3.80&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weed&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server -dir=/data -s3 -s3.config=/etc/seaweedfs/s3.json -volume.max=0 -master.volumeSizeLimitMB=1024&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;${S3_PORT:-8333}:8333"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./s3.json:/etc/seaweedfs/s3.json:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;seaweedfs-data:/data&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="s"&gt;wget -qO /dev/null http://127.0.0.1:8333/status&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;30&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;seaweedfs-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;weed server -s3&lt;/code&gt; runs the whole object store in one process (the master, a volume server, the filer and the S3 gateway); &lt;code&gt;-volume.max=0&lt;/code&gt; and &lt;code&gt;-master.volumeSizeLimitMB=1024&lt;/code&gt; let it add 1 GB storage volumes as the data grows. The named volume &lt;code&gt;seaweedfs-data&lt;/code&gt; keeps the files across restarts. The healthcheck asks the S3 gateway for its status, and &lt;code&gt;--wait&lt;/code&gt; makes &lt;code&gt;docker compose up&lt;/code&gt; return only once every service with a healthcheck reports healthy:&lt;br&gt;
&lt;/p&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;To check it, create the bucket the rest of the stack uses, write an object, and list it back:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/create_bucket.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create the `lakehouse` bucket, write one object and list it back.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&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="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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_buckets&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Buckets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;existing&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="nf"&gt;create_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello, lakehouse&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&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;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&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;Contents&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;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Size&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;bytes&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python create_bucket.py
&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;hello.txt 17 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  st_PY["your Python"]:::new
  subgraph st_ST["storage"]
    st_SW[("SeaweedFS :8333&amp;lt;br/&amp;gt;bucket lakehouse")]:::new
  end
  st_PY --&amp;gt;|"S3 API"| st_SW&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Compute: Spark 4.2 and Spark Connect
&lt;/h2&gt;

&lt;p&gt;Spark is the engine: it reads and writes every table, and everything above this layer either feeds it or tells it what to run. The stack runs a small standalone cluster (a master and one worker) plus a &lt;strong&gt;Spark Connect server&lt;/strong&gt;. Connect splits the classic Spark application in two: the server owns the driver, the catalog settings and the storage credentials, and clients send it query plans over gRPC as thin Python processes. Every client in this post, from your terminal to Airflow, talks to the same server.&lt;/p&gt;

&lt;p&gt;Spark reads its settings from &lt;code&gt;spark-defaults.conf&lt;/code&gt;. Make a folder for 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;mkdir &lt;/span&gt;spark-conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/spark-conf/spark-defaults.conf&lt;/code&gt;&lt;/strong&gt;&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="c"&gt;# Delta Lake
&lt;/span&gt;&lt;span class="err"&gt;spark.sql.extensions&lt;/span&gt;                             &lt;span class="err"&gt;io.delta.sql.DeltaSparkSessionExtension&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.spark_catalog&lt;/span&gt;                  &lt;span class="err"&gt;org.apache.spark.sql.delta.catalog.DeltaCatalog&lt;/span&gt;

&lt;span class="c"&gt;# SeaweedFS, through Hadoop's S3A connector, for both s3:// and s3a:// paths
&lt;/span&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;//seaweedfs:8333&lt;/span&gt;
&lt;span class="err"&gt;spark.hadoop.fs.s3a.access.key&lt;/span&gt;                   &lt;span class="err"&gt;lakehouse&lt;/span&gt;
&lt;span class="err"&gt;spark.hadoop.fs.s3a.secret.key&lt;/span&gt;                   &lt;span class="err"&gt;lakehouse-secret&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="err"&gt;spark.hadoop.fs.s3a.connection.ssl.enabled&lt;/span&gt;       &lt;span class="err"&gt;false&lt;/span&gt;
&lt;span class="err"&gt;spark.hadoop.fs.s3.impl&lt;/span&gt;                          &lt;span class="err"&gt;org.apache.hadoop.fs.s3a.S3AFileSystem&lt;/span&gt;
&lt;span class="err"&gt;spark.hadoop.fs.AbstractFileSystem.s3.impl&lt;/span&gt;       &lt;span class="err"&gt;org.apache.hadoop.fs.s3a.S3A&lt;/span&gt;

&lt;span class="c"&gt;# Resources: a cap, so other applications still get cores, and idle executors go back
&lt;/span&gt;&lt;span class="err"&gt;spark.driver.memory&lt;/span&gt;                              &lt;span class="err"&gt;2g&lt;/span&gt;
&lt;span class="err"&gt;spark.executor.memory&lt;/span&gt;                            &lt;span class="err"&gt;1g&lt;/span&gt;
&lt;span class="err"&gt;spark.executor.cores&lt;/span&gt;                             &lt;span class="err"&gt;2&lt;/span&gt;
&lt;span class="err"&gt;spark.cores.max&lt;/span&gt;                                  &lt;span class="err"&gt;4&lt;/span&gt;
&lt;span class="err"&gt;spark.dynamicAllocation.enabled&lt;/span&gt;                  &lt;span class="err"&gt;true&lt;/span&gt;
&lt;span class="err"&gt;spark.dynamicAllocation.shuffleTracking.enabled&lt;/span&gt;  &lt;span class="err"&gt;true&lt;/span&gt;
&lt;span class="err"&gt;spark.dynamicAllocation.executorIdleTimeout&lt;/span&gt;      &lt;span class="err"&gt;60s&lt;/span&gt;
&lt;span class="err"&gt;spark.dynamicAllocation.shuffleTracking.timeout&lt;/span&gt;  &lt;span class="err"&gt;120s&lt;/span&gt;
&lt;span class="err"&gt;spark.dynamicAllocation.cachedExecutorIdleTimeout&lt;/span&gt; &lt;span class="err"&gt;300s&lt;/span&gt;

&lt;span class="c"&gt;# Where --packages keeps the JARs it downloads (a volume, so it happens once)
&lt;/span&gt;&lt;span class="err"&gt;spark.jars.ivy&lt;/span&gt;                                   &lt;span class="err"&gt;/opt/spark/work-dir/ivy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first block turns on Delta Lake. The second points Hadoop's S3A connector at SeaweedFS for both &lt;code&gt;s3a://&lt;/code&gt; and &lt;code&gt;s3://&lt;/code&gt; paths, so you can write &lt;code&gt;s3://lakehouse/...&lt;/code&gt; everywhere. The third keeps the Connect server, a single long-running application, from holding the whole worker forever: it caps the server at four cores, so a job you submit to the cluster later still gets some, and hands idle executors back to the worker after a minute or two (shuffle tracking alone keeps an executor as long as it holds shuffle files, which in a long-lived Connect session can mean forever). The last line tells Spark where to cache the JARs it downloads.&lt;/p&gt;

&lt;p&gt;Now add the three Spark services to &lt;code&gt;compose.yaml&lt;/code&gt;, under &lt;code&gt;services:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;services:&lt;/code&gt;&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.2.0&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-master&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.master.Master --host spark-master&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;${SPARK_UI_PORT:-8080}:8080"&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="s"&gt;curl -sf http://localhost:8080 &amp;gt; /dev/null&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;30&lt;/span&gt;

  &lt;span class="na"&gt;spark-worker&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-worker&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.worker.Worker --host spark-worker spark://spark-master:7077&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_CORES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6"&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_MEMORY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4g&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-connect&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;/opt/spark/sbin/start-connect-server.sh&lt;/span&gt;
      &lt;span class="s"&gt;--master spark://spark-master:7077&lt;/span&gt;
      &lt;span class="s"&gt;--packages io.delta:delta-spark_4.2_2.13:4.4.0,io.delta:delta-iceberg_2.13:4.4.0,io.unitycatalog:unitycatalog-spark_4.2_2.13:0.6.0,org.apache.hadoop:hadoop-aws:3.5.0,org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0&lt;/span&gt;
      &lt;span class="s"&gt;--exclude-packages io.delta:delta-spark_4.1_2.13&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_NO_DAEMONIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;${SPARK_CONNECT_PORT:-15002}:15002"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./spark-conf:/opt/spark/conf&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/dev/tcp/localhost/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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and their shared volume, under &lt;code&gt;volumes:&lt;/code&gt; at the bottom:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;volumes:&lt;/code&gt;&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-work&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three run the official &lt;code&gt;apache/spark:4.2.0&lt;/code&gt; image, with no custom build. The master and worker are Spark's standalone cluster; the worker offers six cores and 4 GB to applications. The Connect server is started with &lt;code&gt;--packages&lt;/code&gt;, which resolves Spark's plugins from Maven Central when it starts and puts them on the classpath of the driver and every executor:&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;Version&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;io.delta:delta-spark_4.2_2.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.4.0&lt;/td&gt;
&lt;td&gt;Delta Lake, built for Spark 4.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;io.delta:delta-iceberg_2.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.4.0&lt;/td&gt;
&lt;td&gt;UniForm: Delta tables that also write Iceberg metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;io.unitycatalog:unitycatalog-spark_4.2_2.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6.0&lt;/td&gt;
&lt;td&gt;Spark's connector to Unity Catalog, matching the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.hadoop:hadoop-aws&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.5.0&lt;/td&gt;
&lt;td&gt;the S3A filesystem; it must match the Hadoop inside the Spark image, and it brings the AWS SDK with it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.spark:spark-sql-kafka-0-10_2.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.2.0&lt;/td&gt;
&lt;td&gt;the Kafka source and sink for Structured Streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;--exclude-packages&lt;/code&gt; drops one dependency: &lt;code&gt;delta-iceberg&lt;/code&gt; asks for the Spark 4.1 build of Delta, and the Spark 4.2 build is already on the list. The first start downloads about 800 MB (most of it the AWS SDK), so it takes longer than the later ones, which reuse the downloads the &lt;code&gt;spark-work&lt;/code&gt; volume keeps (&lt;code&gt;docker compose logs -f spark-connect&lt;/code&gt; in another terminal shows the progress). The worker mounts the same volume, which the streaming section uses for its checkpoints.&lt;/p&gt;

&lt;p&gt;Here's the whole file at this point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;&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;seaweedfs&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;chrislusf/seaweedfs:3.80&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weed&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server -dir=/data -s3 -s3.config=/etc/seaweedfs/s3.json -volume.max=0 -master.volumeSizeLimitMB=1024&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;${S3_PORT:-8333}:8333"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./s3.json:/etc/seaweedfs/s3.json:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;seaweedfs-data:/data&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="s"&gt;wget -qO /dev/null http://127.0.0.1:8333/status&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;30&lt;/span&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.2.0&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-master&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.master.Master --host spark-master&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;${SPARK_UI_PORT:-8080}:8080"&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="s"&gt;curl -sf http://localhost:8080 &amp;gt; /dev/null&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;30&lt;/span&gt;

  &lt;span class="na"&gt;spark-worker&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-worker&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.worker.Worker --host spark-worker spark://spark-master:7077&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_CORES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6"&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_MEMORY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4g&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-connect&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;/opt/spark/sbin/start-connect-server.sh&lt;/span&gt;
      &lt;span class="s"&gt;--master spark://spark-master:7077&lt;/span&gt;
      &lt;span class="s"&gt;--packages io.delta:delta-spark_4.2_2.13:4.4.0,io.delta:delta-iceberg_2.13:4.4.0,io.unitycatalog:unitycatalog-spark_4.2_2.13:0.6.0,org.apache.hadoop:hadoop-aws:3.5.0,org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0&lt;/span&gt;
      &lt;span class="s"&gt;--exclude-packages io.delta:delta-spark_4.1_2.13&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_NO_DAEMONIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;${SPARK_CONNECT_PORT:-15002}:15002"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./spark-conf:/opt/spark/conf&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/dev/tcp/localhost/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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;seaweedfs-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spark-work&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the cluster. The Connect server's healthcheck passes once its gRPC port accepts connections, so &lt;code&gt;--wait&lt;/code&gt; returns when the server is ready for queries:&lt;br&gt;
&lt;/p&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;Then send it a query:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/hello_spark.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Send one query to the cluster through Spark Connect.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connected to Spark &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&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="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;sum(id) AS 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;show&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 shell"&gt;&lt;code&gt;python hello_spark.py
&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;Connected to Spark 4.2.0 at sc://localhost:15002
+------------+
|       total|
+------------+
|499999500000|
+------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sum ran on the worker; your terminal only sent the plan and printed the result. The Spark UI at &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt; lists the worker and the running "Spark Connect server" application.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  sp_PY["PySpark client"]:::new
  sp_SC["Spark Connect&amp;lt;br/&amp;gt;:15002"]:::new
  sp_CL["master + worker"]:::new
  sp_SW[("SeaweedFS")]:::old
  sp_PY --&amp;gt;|"gRPC"| sp_SC
  sp_SC --&amp;gt; sp_CL
  sp_CL --&amp;gt;|"S3A"| sp_SW&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Some data: a ghost-kitchen generator
&lt;/h2&gt;

&lt;p&gt;A lakehouse needs something to hold. This generator makes up orders for a ghost kitchen, a delivery-only restaurant cooking for several brands in four cities. Each order produces three events (&lt;code&gt;order_created&lt;/code&gt;, &lt;code&gt;order_ready&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt;), with the brand, item count and total as a JSON string in &lt;code&gt;body&lt;/code&gt;. It misbehaves on purpose, the way real feeds do: some events arrive twice and a few lose their location, so the later layers have something to clean up. It writes a week of history to the bucket as Parquet, or streams live events into Kafka for the streaming section:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/generate_orders.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Ghost-kitchen orders: a week of history for the bucket, or a live stream for Kafka.

    python generate_orders.py files    # 7 days of events -&amp;gt; s3://lakehouse/landing/
    python generate_orders.py stream   # one day of events, live -&amp;gt; Kafka topic `orders`
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;KAFKA_BOOTSTRAP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;

&lt;span class="n"&gt;LOCATIONS&lt;/span&gt; &lt;span class="o"&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;Oakland&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;Berkeley&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;San Francisco&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;San Jose&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;BRANDS&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;Pizza Planet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;16.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;Wok This Way&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;21.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;Taco Loco&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Curry House&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;23.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;Sushi Express&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;29.0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Random&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One order&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s events: created, ready in the kitchen, delivered.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getrandbits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;010&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCATIONS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BRANDS&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&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="mi"&gt;4&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;=&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;BRANDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.2&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="n"&gt;steps&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_created&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_ready&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&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;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;return&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&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_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kind&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&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;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;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;location&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;items&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;total&lt;/span&gt;&lt;span class="p"&gt;}),&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;steps&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;messy&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Real feeds misbehave: a few events lose their location, some arrive twice.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&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;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&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;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;event&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="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;out&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;event&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;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&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;out&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;week&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&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;for&lt;/span&gt; &lt;span class="n"&gt;day&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="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&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;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&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="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;79200&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;start&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="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;messy&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;put_parquet&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&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;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows&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;write_files&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s3&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;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;week&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&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;astype&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;Int64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# keep the gaps as nulls
&lt;/span&gt;        &lt;span class="nf"&gt;put_parquet&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;landing/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&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;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCATIONS&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="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())})&lt;/span&gt;
    &lt;span class="nf"&gt;put_parquet&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;landing/locations/locations.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cities&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;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_second&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;kafka&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;KafkaProducer&lt;/span&gt;

    &lt;span class="n"&gt;producer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KafkaProducer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bootstrap_servers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KAFKA_BOOTSTRAP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_serializer&lt;/span&gt;&lt;span class="o"&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;encode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="n"&gt;value_serializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# new order ids on every run
&lt;/span&gt;    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;week&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;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&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;e&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="n"&gt;stamps&lt;/span&gt; &lt;span class="o"&gt;=&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&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="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;event&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="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stamps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# a resent event keeps its first timestamp
&lt;/span&gt;            &lt;span class="n"&gt;late&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;  &lt;span class="c1"&gt;# and a few arrive three minutes late
&lt;/span&gt;            &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&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;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;late&lt;/span&gt; &lt;span class="k"&gt;else&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;stamps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;milliseconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&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="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&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;value&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="n"&gt;event&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="n"&gt;stamps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&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="nf"&gt;print&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;sent &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; events&lt;/span&gt;&lt;span class="sh"&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;sleep&lt;/span&gt;&lt;span class="p"&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;per_second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent &lt;/span&gt;&lt;span class="si"&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;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; events to the topic orders&lt;/span&gt;&lt;span class="sh"&gt;"&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;__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;__main__&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;write_files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python generate_orders.py files
&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;landing/orders/2026-09-01.parquet  4,720 rows
landing/orders/2026-09-02.parquet  4,382 rows
landing/orders/2026-09-03.parquet  5,479 rows
landing/orders/2026-09-04.parquet  4,508 rows
landing/orders/2026-09-05.parquet  5,194 rows
landing/orders/2026-09-06.parquet  4,962 rows
landing/orders/2026-09-07.parquet  4,776 rows
landing/locations/locations.parquet  4 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's seeded, so you get the same week I did. The files land in a &lt;code&gt;landing/&lt;/code&gt; prefix in the bucket, the usual first stop for raw data before it becomes a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table format: Delta Lake
&lt;/h2&gt;

&lt;p&gt;Parquet files in a bucket aren't a table yet. A &lt;strong&gt;table format&lt;/strong&gt; adds a transaction log next to the data files, where each write is a new, atomic commit that says which files make up the table now. That log is what gives you ACID writes on object storage, readers that never see half a write, and time travel. This stack uses &lt;a href="https://delta.io/" rel="noopener noreferrer"&gt;Delta Lake&lt;/a&gt;, the format Unity Catalog OSS writes.&lt;/p&gt;

&lt;p&gt;This script writes the week's events as a Delta table in two commits, the first day and then the other six, then reads the table as it is now and as it was at version 0:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/load_orders.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Write the week&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s events as a Delta table in two commits, then time-travel.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spark&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;TABLE_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/tables/orders_raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/landing/orders/&lt;/span&gt;&lt;span class="sh"&gt;"&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;event_date&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_date&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="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Commit 0: the first day. Commit 1: the other six.
&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;where&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="s"&gt;2026-09-01&lt;/span&gt;&lt;span class="sh"&gt;'"&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;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;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE_PATH&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;where&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 &amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2026-09-01&lt;/span&gt;&lt;span class="sh"&gt;'"&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;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;delta&lt;/span&gt;&lt;span class="sh"&gt;"&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;append&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;session&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;DESCRIBE HISTORY delta.`&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TABLE_PATH&lt;/span&gt;&lt;span class="si"&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="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;version&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;operation&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;operationMetrics.numOutputRows&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;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="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;delta&lt;/span&gt;&lt;span class="sh"&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="n"&gt;TABLE_PATH&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;then&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="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;delta&lt;/span&gt;&lt;span class="sh"&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;versionAsOf&lt;/span&gt;&lt;span class="sh"&gt;"&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;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE_PATH&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;print&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;rows now: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;   rows at version 0: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Underneath, it's files in the bucket: Parquet data plus the _delta_log.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tables/orders_raw/&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;Contents&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;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python load_orders.py
&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;+-------+---------+-------------+
|version|operation|numOutputRows|
+-------+---------+-------------+
|      1|    WRITE|        29301|
|      0|    WRITE|         4720|
+-------+---------+-------------+

rows now: 34,021   rows at version 0: 4,720

tables/orders_raw/_delta_log/00000000000000000000.crc
tables/orders_raw/_delta_log/00000000000000000000.json
tables/orders_raw/_delta_log/00000000000000000001.crc
tables/orders_raw/_delta_log/00000000000000000001.json
tables/orders_raw/_delta_log/_staged_commits/
tables/orders_raw/part-00000-7f3f327f-b1e1-4f75-99de-ccfbc82c1b65-c000.snappy.parquet
tables/orders_raw/part-00000-92a94a61-efd3-4a85-924d-e3481bde0ed6-c000.snappy.parquet
tables/orders_raw/part-00001-9db8f5d3-76b4-4a7b-972b-b3b51c7f29e3-c000.snappy.parquet
tables/orders_raw/part-00001-fd4c4fe3-30ec-43f1-a3b6-3b5128241edd-c000.snappy.parquet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version 0 still exists, because the append added files and a commit rather than rewriting anything. Underneath, the table is Parquet files plus a &lt;code&gt;_delta_log&lt;/code&gt; folder with one JSON commit per version (the &lt;code&gt;.crc&lt;/code&gt; files are checksums, and &lt;code&gt;_staged_commits/&lt;/code&gt; is an empty folder Delta keeps for commits that go through a catalog). The log is the table, and this listing shows it: there are four Parquet files, but the commits list only three of them. The fourth is an empty file from a task that had no rows for the first day, and since no commit references it, no reader ever sees it.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  dl_SC["Spark Connect"]:::old
  subgraph dl_SW["SeaweedFS"]
    dl_LOG["_delta_log&amp;lt;br/&amp;gt;a commit per version"]:::new
    dl_PQ["Parquet&amp;lt;br/&amp;gt;data files"]:::new
  end
  dl_SC --&amp;gt;|"commit"| dl_LOG
  dl_SC --&amp;gt;|"write"| dl_PQ
  dl_LOG -.-&amp;gt;|"lists"| dl_PQ&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Catalog: Unity Catalog OSS
&lt;/h2&gt;

&lt;p&gt;A path like &lt;code&gt;s3://lakehouse/tables/orders_raw&lt;/code&gt; works, but nobody wants to pass paths around. A &lt;strong&gt;catalog&lt;/strong&gt; gives tables names (&lt;code&gt;catalog.schema.table&lt;/code&gt;), records where each one lives, and is the one place engines ask about tables. &lt;a href="https://www.unitycatalog.io/" rel="noopener noreferrer"&gt;Unity Catalog OSS&lt;/a&gt; also governs access and hands engines short-lived storage credentials for the tables they ask for, which is why it needs the S3 settings too:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/server.properties&lt;/code&gt;&lt;/strong&gt;&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="py"&gt;server.env&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="py"&gt;server.authorization&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;disable&lt;/span&gt;

&lt;span class="c"&gt;# The bucket Unity Catalog hands out credentials for, and the key pair it uses
&lt;/span&gt;&lt;span class="py"&gt;s3.bucketPath.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse&lt;/span&gt;
&lt;span class="py"&gt;s3.region.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;span class="py"&gt;s3.accessKey.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;
&lt;span class="py"&gt;s3.secretKey.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;lakehouse-secret&lt;/span&gt;
&lt;span class="py"&gt;s3.sessionToken.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;unused&lt;/span&gt;
&lt;span class="py"&gt;s3.endpoint.0&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://seaweedfs:8333&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;s3.bucketPath.0&lt;/code&gt; is the bucket root the credentials cover. UC 0.6.0 skips a bucket's settings unless all four credential fields are filled in, so the session token gets a placeholder; SeaweedFS ignores it.&lt;/p&gt;

&lt;p&gt;Add the catalog server, under &lt;code&gt;services:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;services:&lt;/code&gt;&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;unity-catalog&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;unitycatalog/unitycatalog:v0.6.0&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;${UC_PORT:-8081}:8080"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./server.properties:/home/unitycatalog/etc/conf/server.properties:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;uc-data:/home/unitycatalog/etc/db&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="s"&gt;wget -qO /dev/null http://127.0.0.1:8080/api/2.1/unity-catalog/catalogs&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;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and its volume, under &lt;code&gt;volumes:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;volumes:&lt;/code&gt;&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;uc-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spark needs to know about the catalog too. Add these lines to the end of &lt;code&gt;spark-conf/spark-defaults.conf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/spark-conf/spark-defaults.conf&lt;/code&gt;&lt;/strong&gt;, add at the end&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="c"&gt;# Unity Catalog: Spark's catalog `lakehouse` is the Unity Catalog catalog `lakehouse`
&lt;/span&gt;&lt;span class="err"&gt;spark.sql.catalog.lakehouse&lt;/span&gt;                      &lt;span class="err"&gt;io.unitycatalog.spark.UCSingleCatalog&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.lakehouse.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;//unity-catalog:8080&lt;/span&gt;
&lt;span class="err"&gt;spark.sql.catalog.lakehouse.token&lt;/span&gt;                &lt;span class="err"&gt;unused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Unity Catalog connector serves one catalog, and the Spark catalog's name is the Unity Catalog name it serves, so &lt;code&gt;lakehouse.tutorial.orders_raw&lt;/code&gt; in Spark is the table &lt;code&gt;orders_raw&lt;/code&gt; in schema &lt;code&gt;tutorial&lt;/code&gt; of the catalog &lt;code&gt;lakehouse&lt;/code&gt;. Start Unity Catalog, then recreate the Connect server so it reads the new settings (its downloads are in the volume, so this takes seconds):&lt;br&gt;
&lt;/p&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;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt; spark-connect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This script creates the &lt;code&gt;lakehouse&lt;/code&gt; catalog through the REST API (a catalog can't be created from Spark), registers the Delta table you wrote, queries it by name, and asks the catalog what it holds:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/register_orders.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create the `lakehouse` catalog, register orders_raw in it, and query it by name.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&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;uc&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&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;c&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;uc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&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;/catalogs&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;catalogs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="nf"&gt;uc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&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;/catalogs&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&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;storage_root&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;s3://lakehouse/managed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&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 SCHEMA IF NOT EXISTS lakehouse.tutorial&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;session&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 lakehouse.tutorial.orders_raw
    USING delta LOCATION &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/tables/orders_raw&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="n"&gt;session&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 event_date, count(*) AS events
    FROM lakehouse.tutorial.orders_raw
    GROUP BY event_date ORDER BY event_date
&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;span class="c1"&gt;# Ask the catalog itself, over its REST API.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;uc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&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;/tables?catalog_name=lakehouse&amp;amp;schema_name=tutorial&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;tables&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;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;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;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;table_type&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data_source_format&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;storage_location&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python register_orders.py
&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;+----------+------+
|event_date|events|
+----------+------+
|2026-09-01|  4720|
|2026-09-02|  4382|
|2026-09-03|  5479|
|2026-09-04|  4508|
|2026-09-05|  5194|
|2026-09-06|  4962|
|2026-09-07|  4776|
+----------+------+

orders_raw EXTERNAL DELTA s3://lakehouse/tables/orders_raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two kinds of Delta table live in this catalog. &lt;code&gt;orders_raw&lt;/code&gt; is &lt;strong&gt;external&lt;/strong&gt;: you chose its location and registered it. The pipeline section creates &lt;strong&gt;catalog-managed&lt;/strong&gt; tables, where the catalog picks the location under the catalog's &lt;code&gt;storage_root&lt;/code&gt; (&lt;code&gt;s3://lakehouse/managed&lt;/code&gt; here) and owns the table's lifecycle. The locations are &lt;code&gt;s3://&lt;/code&gt; URIs because that's how Unity Catalog records them; &lt;code&gt;spark-defaults.conf&lt;/code&gt; maps &lt;code&gt;s3://&lt;/code&gt; to the S3A filesystem, so Spark follows them.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  uc_SC["Spark Connect"]:::old
  uc_UC["Unity Catalog&amp;lt;br/&amp;gt;:8081"]:::new
  uc_SW[("SeaweedFS")]:::old
  uc_SC --&amp;gt;|"UC connector"| uc_UC
  uc_UC -.-&amp;gt;|"locations,&amp;lt;br/&amp;gt;credentials"| uc_SW
  uc_SC --&amp;gt;|"S3A"| uc_SW&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Open formats: read the same table as Iceberg
&lt;/h2&gt;

&lt;p&gt;Delta is one open table format; &lt;a href="https://iceberg.apache.org/" rel="noopener noreferrer"&gt;Apache Iceberg&lt;/a&gt; is another, and plenty of engines read Iceberg. You don't have to pick one. Both keep the data in Parquet and differ in the metadata they put next to it, so one copy of the files can carry both kinds of metadata. Delta calls that &lt;strong&gt;UniForm&lt;/strong&gt;: the table stays a Delta table that Spark writes, and with each commit Delta also writes Iceberg metadata for the same files. Delta writes, Iceberg engines read.&lt;/p&gt;

&lt;p&gt;The conversion lives in the &lt;code&gt;delta-iceberg&lt;/code&gt; package the Connect server already loaded. Three table properties turn it on: Iceberg as a universal format, the IcebergCompatV2 table feature, and column mapping by name, since Iceberg tracks columns by ID rather than by position:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/enable_uniform.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turn on UniForm: orders_raw stays a Delta table and also gets Iceberg metadata.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;s3&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;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;
    ALTER TABLE lakehouse.tutorial.orders_raw SET TBLPROPERTIES (
        &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delta.universalFormat.enabledFormats&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="s"&gt;iceberg&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="s"&gt;delta.enableIcebergCompatV2&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="s"&gt;true&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="s"&gt;delta.columnMapping.mode&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="s"&gt;name&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;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tables/orders_raw/metadata/&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;Contents&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;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python enable_uniform.py
&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;tables/orders_raw/metadata/00000-01bfacd6-e573-475c-917f-34bc3a647658.metadata.json
tables/orders_raw/metadata/ec2026c7-0ade-4f27-a333-598ff9fa2096-m0.avro
tables/orders_raw/metadata/snap-2861189516262794144-1-ec2026c7-0ade-4f27-a333-598ff9fa2096.avro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ALTER&lt;/code&gt; was itself a Delta commit, and UniForm converted it: a &lt;code&gt;metadata.json&lt;/code&gt;, a manifest list (&lt;code&gt;snap-...&lt;/code&gt;) and a manifest (&lt;code&gt;...-m0&lt;/code&gt;), all pointing at the Parquet files that were already there. Nothing was rewritten; Iceberg readers match the existing files' columns by name.&lt;/p&gt;

&lt;p&gt;Now read the table as Iceberg, with two readers that know nothing about Delta and don't use Spark: &lt;a href="https://py.iceberg.apache.org/" rel="noopener noreferrer"&gt;PyIceberg&lt;/a&gt;, Iceberg's Python library, and &lt;a href="https://duckdb.org/" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt; with its &lt;code&gt;iceberg&lt;/code&gt; extension. Both open the newest &lt;code&gt;metadata.json&lt;/code&gt; in the bucket (Iceberg calls a table opened straight from its metadata file a static table):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/read_iceberg.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Read orders_raw as an Iceberg table with PyIceberg and DuckDB. No Spark involved.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S3_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S3_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyiceberg.table&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StaticTable&lt;/span&gt;

&lt;span class="c1"&gt;# The table's current Iceberg metadata is the newest metadata.json UniForm wrote.
&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tables/orders_raw/metadata/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Contents&lt;/span&gt;&lt;span class="sh"&gt;"&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;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.metadata.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;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LastModified&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;location&lt;/span&gt; &lt;span class="o"&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;s3://lakehouse/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;newest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&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;Reading &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StaticTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&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;s3.endpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3.access-key-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;S3_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3.secret-access-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;S3_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3.region&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;us-east-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="n"&gt;rows&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;scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selected_fields&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;event_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;to_arrow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;num_rows&lt;/span&gt;
&lt;span class="nf"&gt;print&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;PyIceberg: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows (Iceberg v&lt;/span&gt;&lt;span class="si"&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;format_version&lt;/span&gt;&lt;span class="si"&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;converted from Delta version &lt;/span&gt;&lt;span class="si"&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;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delta-version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;duck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;duck&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;INSTALL iceberg; LOAD iceberg; INSTALL httpfs; LOAD httpfs;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;duck&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;
    CREATE SECRET (TYPE s3, KEY_ID &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, SECRET &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_SECRET&lt;/span&gt;&lt;span class="si"&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="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,
                   ENDPOINT &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&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;)[1]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, URL_STYLE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, USE_SSL false)
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;duck&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;
    SELECT event_date, count(*) AS events
    FROM iceberg_scan(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)
    GROUP BY event_date ORDER BY event_date
&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python read_iceberg.py
&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;Reading s3://lakehouse/tables/orders_raw/metadata/00000-01bfacd6-e573-475c-917f-34bc3a647658.metadata.json

PyIceberg: 34,021 rows (Iceberg v2, converted from Delta version 2)

┌────────────┬────────┐
│ event_date │ events │
│    date    │ int64  │
├────────────┼────────┤
│ 2026-09-01 │   4720 │
│ 2026-09-02 │   4382 │
│ 2026-09-03 │   5479 │
│ 2026-09-04 │   4508 │
│ 2026-09-05 │   5194 │
│ 2026-09-06 │   4962 │
│ 2026-09-07 │   4776 │
└────────────┴────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rows per day as the Delta query in the catalog section, through Iceberg metadata. "Converted from Delta version 2" is the &lt;code&gt;ALTER&lt;/code&gt; commit, and every later commit to the table gets new Iceberg metadata, so the readers keep up with the writes. (DuckDB downloads its &lt;code&gt;iceberg&lt;/code&gt; and &lt;code&gt;httpfs&lt;/code&gt; extensions the first time.)&lt;/p&gt;

&lt;p&gt;Unity Catalog speaks Iceberg too: its Iceberg REST catalog, at &lt;code&gt;/api/2.1/unity-catalog/iceberg&lt;/code&gt; with the catalog name as the &lt;code&gt;warehouse&lt;/code&gt;, lists the catalog-managed UniForm tables. To hand one of them to an Iceberg client, the server reads the table's metadata file with its own S3 client, which UC 0.6.0 sets up for AWS S3, so on this laptop stack the readers open the metadata from the bucket directly instead.&lt;/p&gt;

&lt;p&gt;Spark writes Delta here, and Iceberg only through UniForm. Native Iceberg writes from Spark 4.2 arrive with Iceberg's next release, which adds a Spark 4.2 runtime.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  ib_SC["Spark Connect"]:::old
  subgraph ib_T["orders_raw"]
    ib_LOG["_delta_log"]:::old
    ib_PQ["Parquet&amp;lt;br/&amp;gt;data files"]:::old
    ib_MD["metadata/&amp;lt;br/&amp;gt;Iceberg"]:::new
  end
  ib_RD["PyIceberg&amp;lt;br/&amp;gt;DuckDB"]:::new
  ib_SC --&amp;gt;|"Delta commit"| ib_LOG
  ib_LOG -.-&amp;gt;|"UniForm"| ib_MD
  ib_MD -.-&amp;gt;|"lists"| ib_PQ
  ib_RD --&amp;gt;|"reads"| ib_MD&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Pipelines: bronze, silver, gold with Spark Declarative Pipelines
&lt;/h2&gt;

&lt;p&gt;So far every table was written by hand. In a real lakehouse the interesting tables are &lt;em&gt;derived&lt;/em&gt;: cleaned events from raw ones, aggregates from cleaned ones. &lt;a href="https://spark.apache.org/docs/latest/declarative-pipelines-programming-guide.html" rel="noopener noreferrer"&gt;Spark Declarative Pipelines&lt;/a&gt; (SDP) lets you declare each table as a query; SDP works out the dependency graph from the tables each query reads and materializes them in order. Here it's the classic medallion layout:&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;Tables&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;bronze&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;orders_bronze&lt;/code&gt;, &lt;code&gt;locations&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the landing files, as they arrived&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;silver&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orders_silver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;each event once, with a known location, the JSON parsed and the city attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gold&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;revenue_by_brand&lt;/code&gt;, &lt;code&gt;revenue_by_city_day&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the numbers people ask for&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A pipeline is a spec file plus a folder of transformations. Make the folders:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; pipeline/transformations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spec names the catalog and schema the tables go to, and where SDP keeps its own state:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/pipeline/spark-pipeline.yml&lt;/code&gt;&lt;/strong&gt;&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;medallion&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;lakehouse&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;medallion&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://lakehouse/pipelines/medallion&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;transformations/**&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bronze reads files, so it's Python, the one place a query needs a DataFrame reader:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/pipeline/transformations/bronze.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Bronze: the landing files, as they arrived.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&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;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="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Unity Catalog manages these tables: it picks their location under the
# catalog's storage root. These properties mark a Delta table as catalog-managed.
&lt;/span&gt;&lt;span class="n"&gt;MANAGED&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;delta.feature.catalogManaged&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;supported&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;delta.checkpoint.writeStatsAsJson&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta.checkpoint.writeStatsAsStruct&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="p"&gt;}&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="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MANAGED&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_bronze&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/landing/orders/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MANAGED&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;locations&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/landing/locations/&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;Silver and gold are SQL. Each &lt;code&gt;CREATE MATERIALIZED VIEW&lt;/code&gt; names the tables it reads, and that's all SDP needs to order them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/pipeline/transformations/silver.sql&lt;/code&gt;&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;-- Silver: each event once, with a known location, the order details parsed&lt;/span&gt;
&lt;span class="c1"&gt;-- out of the JSON body, and the city attached.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;orders_silver&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;
&lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'delta.feature.catalogManaged'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsJson'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsStruct'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&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;event_id&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;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;to_timestamp&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;ts&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;event_time&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;get_json_object&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;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.brand'&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;brand&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;get_json_object&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;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.total'&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;DOUBLE&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;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_bronze&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt; &lt;span class="n"&gt;l&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;location_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location_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;strong&gt;File: &lt;code&gt;open-lakehouse/pipeline/transformations/gold.sql&lt;/code&gt;&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;-- Gold: the numbers people ask for, built from silver.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;revenue_by_brand&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;
&lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'delta.feature.catalogManaged'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsJson'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsStruct'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&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;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;brand&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;total&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;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&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;avg_order&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_silver&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'order_created'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;revenue_by_city_day&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;
&lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'delta.feature.catalogManaged'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsJson'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s1"&gt;'delta.checkpoint.writeStatsAsStruct'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&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;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;to_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&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;total&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_silver&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'order_created'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are catalog-managed tables, so each one carries the three table properties that mark a Delta table as catalog-managed (Unity Catalog requires both checkpoint-stats properties when it creates one), and none gives a location. SDP also won't replace a table that already exists in Unity Catalog, so a run starts by clearing the previous run's tables:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/prepare_medallion.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get lakehouse.medallion ready for a pipeline run: create it, or clear the last run.

SDP can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t replace a table that already exists in Unity Catalog, so a rerun of the
pipeline starts by dropping the tables the previous run created.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&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 SCHEMA IF NOT EXISTS lakehouse.medallion&lt;/span&gt;&lt;span class="sh"&gt;"&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;session&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 lakehouse.medallion&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="n"&gt;session&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;DROP TABLE lakehouse.medallion.&lt;/span&gt;&lt;span class="si"&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="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dropped lakehouse.medallion.&lt;/span&gt;&lt;span class="si"&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="si"&gt;}&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse.medallion is ready&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;Now prepare the schema, check the graph without writing anything, and run it. The pipeline CLI ships with &lt;code&gt;pyspark-client&lt;/code&gt;; it runs on your machine, reads the spec and the transformations, and sends the graph to the Connect server, which does 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;python prepare_medallion.py
&lt;span class="nv"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sc://localhost:15002 python &lt;span class="nt"&gt;-m&lt;/span&gt; pyspark.pipelines.cli dry-run &lt;span class="nt"&gt;--spec&lt;/span&gt; pipeline/spark-pipeline.yml
&lt;span class="nv"&gt;SPARK_REMOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sc://localhost:15002 python &lt;span class="nt"&gt;-m&lt;/span&gt; pyspark.pipelines.cli run &lt;span class="nt"&gt;--spec&lt;/span&gt; pipeline/spark-pipeline.yml
&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;2026-10-01 14:36:58: Loading definitions. Root directory: '.../open-lakehouse/pipeline'.
2026-10-01 14:36:58: Found 3 files matching glob 'transformations/**/*'
...
2026-10-01 21:37:09: Flow lakehouse.medallion.orders_bronze has COMPLETED.
2026-10-01 21:37:09: Flow lakehouse.medallion.locations has COMPLETED.
2026-10-01 21:37:15: Flow lakehouse.medallion.orders_silver has COMPLETED.
2026-10-01 21:37:19: Flow lakehouse.medallion.revenue_by_city_day has COMPLETED.
2026-10-01 21:37:19: Flow lakehouse.medallion.revenue_by_brand has COMPLETED.
2026-10-01 21:37:21: Run is COMPLETED.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dry run resolves every table and dependency before any data moves, which makes it the cheap thing to run first. Bronze's two tables run side by side, silver waits for both, and the two gold tables wait for silver. To check, read a gold table by name and list what the pipeline put in the catalog:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/read_gold.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Read a gold table by name, and list what the pipeline put in the catalog.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&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;uc&lt;/span&gt;

&lt;span class="nf"&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 * FROM lakehouse.medallion.revenue_by_brand ORDER BY revenue DESC
&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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;uc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&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;/tables?catalog_name=lakehouse&amp;amp;schema_name=medallion&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;tables&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;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;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;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;table_type&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data_source_format&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python read_gold.py
&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;+-------------+------+---------+---------+
|        brand|orders|  revenue|avg_order|
+-------------+------+---------+---------+
|Sushi Express|  2140|160332.61|    74.92|
|  Curry House|  2219|133432.76|    60.13|
| Wok This Way|  2152|118668.13|    55.14|
| Pizza Planet|  2233| 93402.24|    41.83|
|    Taco Loco|  2168| 71548.53|     33.0|
+-------------+------+---------+---------+

locations MANAGED DELTA
orders_bronze MANAGED DELTA
orders_silver MANAGED DELTA
revenue_by_brand MANAGED DELTA
revenue_by_city_day MANAGED DELTA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order counts are the distinct orders with a known location; the duplicates and the orders that lost their location stopped at silver. A managed table's location is the catalog's business, so the listing shows them as &lt;code&gt;MANAGED&lt;/code&gt;. To run the pipeline again, run &lt;code&gt;python prepare_medallion.py&lt;/code&gt; first.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  pl_SPEC["spark-pipeline.yml&amp;lt;br/&amp;gt;bronze.py, *.sql"]:::new
  pl_SC["Spark Connect"]:::old
  subgraph pl_MD["lakehouse.medallion"]
    pl_BR[("bronze")]:::new
    pl_SI[("silver")]:::new
    pl_GO[("gold")]:::new
  end
  pl_SPEC --&amp;gt;|"graph"| pl_SC
  pl_SC --&amp;gt; pl_BR
  pl_BR --&amp;gt; pl_SI
  pl_SI --&amp;gt; pl_GO&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Streaming: Real-Time Mode and micro-batches
&lt;/h2&gt;

&lt;p&gt;The week of files is history; a stream is what's happening now. Kafka is the event log the orders flow through, and Spark's Structured Streaming reads it. By default Structured Streaming works in &lt;strong&gt;micro-batches&lt;/strong&gt;: it collects whatever arrived since the last batch, processes it, commits, and starts over, so each record waits for the next batch. &lt;strong&gt;Real-Time Mode&lt;/strong&gt; keeps the query's tasks running for a long batch instead and passes each record through the moment it arrives; the trigger's duration only sets how often the query writes a checkpoint. In Spark 4.2, Real-Time Mode runs stateless queries (filters, projections and the like, with no aggregation or deduplication), reads from Kafka and writes to Kafka, in the &lt;code&gt;update&lt;/code&gt; output mode. Spark 4.2 is also the release that gave PySpark its &lt;code&gt;trigger(realTime=...)&lt;/code&gt; keyword, so it works from Python over Spark Connect like everything else here.&lt;/p&gt;

&lt;p&gt;This section builds two queries on the order stream: a stateless one in Real-Time Mode that writes clean orders back to Kafka, and a stateful one (deduplication and running totals) as an ordinary micro-batch query that writes into a Delta table in Unity Catalog.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kafka
&lt;/h3&gt;

&lt;p&gt;Add Kafka, under &lt;code&gt;services:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;services:&lt;/code&gt;&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;kafka&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/kafka:4.2.0&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;${KAFKA_PORT:-9092}:9094"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_NODE_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_PROCESS_ROLES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broker,controller&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_QUORUM_VOTERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1@kafka:9093&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_LISTENER_NAMES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTROLLER&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_LISTENERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:9094&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_ADVERTISED_LISTENERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_PORT:-9092}&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_LISTENER_SECURITY_PROTOCOL_MAP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_INTER_BROKER_LISTENER_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_MIN_ISR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&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="s"&gt;/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list&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;10s&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;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the official Apache Kafka image in KRaft mode: one process is both the broker and its own controller, with no ZooKeeper. It has two listeners because its clients are in two places. Spark, in the Compose network, connects to &lt;code&gt;kafka:9092&lt;/code&gt;; your machine connects to &lt;code&gt;localhost:9092&lt;/code&gt;, which Docker forwards to the &lt;code&gt;EXTERNAL&lt;/code&gt; listener on container port 9094, and that listener tells clients to come back to &lt;code&gt;localhost&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;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;Create the input topic and the Real-Time Mode query's output topic. &lt;code&gt;orders&lt;/code&gt; gets two partitions, so the Real-Time Mode query reads it with two parallel tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;kafka /opt/kafka/bin/kafka-topics.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; kafka:9092 &lt;span class="nt"&gt;--create&lt;/span&gt; &lt;span class="nt"&gt;--topic&lt;/span&gt; orders &lt;span class="nt"&gt;--partitions&lt;/span&gt; 2
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;kafka /opt/kafka/bin/kafka-topics.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; kafka:9092 &lt;span class="nt"&gt;--create&lt;/span&gt; &lt;span class="nt"&gt;--topic&lt;/span&gt; orders-clean &lt;span class="nt"&gt;--partitions&lt;/span&gt; 2
&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;Created topic orders.
Created topic orders-clean.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A stateless query in Real-Time Mode
&lt;/h3&gt;

&lt;p&gt;The first query keeps the events a downstream service would act on: new orders with a known location. It pulls the brand and total out of the JSON body and writes each one to &lt;code&gt;orders-clean&lt;/code&gt;, along with the time the input reached Kafka, so you can time it later. It's an ordinary streaming query with a different trigger:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/stream_clean.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Real-Time Mode: clean each order event from Kafka into another topic as it arrives.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;spark&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;EVENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id STRING, event_type STRING, ts STRING, location_id INT, order_id STRING, body STRING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&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="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&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;kafka:9092&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Kafka as the cluster sees it
&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;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="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&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="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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;clean&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;where&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.event_type = &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="s"&gt; AND e.location_id IS NOT NULL&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="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;e.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;key&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_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;struct&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.event_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;e.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;get_json_object&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.body&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;$.brand&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;brand&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;get_json_object&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.body&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="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;double&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&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;unix_millis&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;received_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# when it reached Kafka
&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;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;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;clean&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;queryName&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_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;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;orders-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;/opt/spark/work-dir/checkpoints/orders_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="c1"&gt;# the output mode Real-Time Mode supports
&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="c1"&gt;# how often to checkpoint, not a latency target
&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;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;orders -&amp;gt; orders-clean, in Real-Time Mode. Ctrl+C to stop.&lt;/span&gt;&lt;span class="sh"&gt;"&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaitTermination&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;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query runs on the Connect server, so it reaches Kafka as &lt;code&gt;kafka:9092&lt;/code&gt;, its name inside the Compose network. Its checkpoint goes in the &lt;code&gt;spark-work&lt;/code&gt; volume that the Connect server and the worker share. (SeaweedFS's S3 API handles everything Delta writes, but Spark's streaming checkpoints don't start on it: Spark expects a new checkpoint's &lt;code&gt;offsets&lt;/code&gt; folder to list as empty, and SeaweedFS lists the folder's own marker object.)&lt;/p&gt;

&lt;h3&gt;
  
  
  A stateful query into Delta
&lt;/h3&gt;

&lt;p&gt;The second query keeps a running count of orders and the revenue for each kitchen location. Two stateful steps do the work: &lt;code&gt;dropDuplicates&lt;/code&gt; remembers the event ids it has seen and drops repeats, because a duplicate order would otherwise count twice, and &lt;code&gt;groupBy(...).agg(...)&lt;/code&gt; keeps the running totals. Real-Time Mode in Spark 4.2 doesn't run stateful queries, so this one is a micro-batch query, once a second:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/stream_totals.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Micro-batch: deduplicate orders and keep running totals per location, in Delta.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;spark&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;EVENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id STRING, event_type STRING, ts STRING, location_id INT, order_id STRING, body STRING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&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.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;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;session&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 lakehouse.tutorial.live_totals (
        location_id INT, orders BIGINT, revenue DOUBLE, last_order_at TIMESTAMP
    ) USING delta LOCATION &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3://lakehouse/tables/live_totals&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="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&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;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;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;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="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&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="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;e.ts&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_time&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;where&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="s"&gt;order_created&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND location_id IS NOT NULL&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;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;event_time&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 minutes&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 long to remember an event id
&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_time&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;location_id&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;*&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;orders&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;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;sum&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;get_json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&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="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;double&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;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;max&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_time&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;last_order_at&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;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;totals&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;queryName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;live_totals&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;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;delta&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;complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# each batch rewrites the four rows with the current totals
&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;/opt/spark/work-dir/checkpoints/live_totals&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;processingTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 second&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;toTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse.tutorial.live_totals&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;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;orders -&amp;gt; lakehouse.tutorial.live_totals, every second. Ctrl+C to stop.&lt;/span&gt;&lt;span class="sh"&gt;"&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaitTermination&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;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here. The filter comes before the deduplication, so the state only holds events that count. The watermark bounds that state: without it, &lt;code&gt;dropDuplicates&lt;/code&gt; would keep every event id forever, and with it, ids more than 10 minutes (in event time) behind the newest event are dropped from state, which is also why &lt;code&gt;event_time&lt;/code&gt; is part of the deduplication key. The generator resends its duplicates right away and sends a few events three minutes late, so 10 minutes covers both. The &lt;code&gt;complete&lt;/code&gt; output mode suits a result this small: every batch rewrites four rows, so the table always holds the current totals, and each batch is a Delta commit you can time-travel to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run them
&lt;/h3&gt;

&lt;p&gt;Start each query in a terminal of its own (&lt;code&gt;source .venv/bin/activate&lt;/code&gt; first), and leave them running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python stream_clean.py
&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;python stream_totals.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With both waiting, stream a day of orders into Kafka from a third terminal. It sends 50 events a second and stops by itself after about two minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python generate_orders.py stream
&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;sent 1,000 events
...
sent 4,625 events to the topic orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are a few records as the Real-Time Mode query wrote them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;kafka /opt/kafka/bin/kafka-console-consumer.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; kafka:9092 &lt;span class="nt"&gt;--topic&lt;/span&gt; orders-clean &lt;span class="nt"&gt;--from-beginning&lt;/span&gt; &lt;span class="nt"&gt;--max-messages&lt;/span&gt; 3
&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;{"event_id":"4de57cb207-0","location_id":4,"brand":"Curry House","total":23.98,"received_ms":1790890668647}
{"event_id":"904638cd9b-0","location_id":2,"brand":"Wok This Way","total":23.63,"received_ms":1790890668769}
{"event_id":"bdbede7a39-0","location_id":3,"brand":"Pizza Planet","total":33.93,"received_ms":1790890668890}
Processed a total of 3 messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check both queries, compare their output with the input. This script reads the topics once, as batches: it counts the &lt;code&gt;order_created&lt;/code&gt; events with a location two ways (all records, and distinct event ids), reads the current totals from the Delta table, and measures how long each record spent inside the Real-Time Mode query, from its input's Kafka timestamp to its output's:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/check_stream.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Check both streaming queries against what the generator sent.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;spark&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;EVENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id STRING, event_type STRING, ts STRING, location_id INT, order_id STRING, body STRING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spark&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;topic&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Everything in a Kafka topic so far, read once as a batch.&lt;/span&gt;&lt;span class="sh"&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;session&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;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;subscribe&lt;/span&gt;&lt;span class="sh"&gt;"&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="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="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;topic&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;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&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;where&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.event_type = &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="s"&gt; AND e.location_id IS NOT NULL&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;*&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;records&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;count_distinct&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.event_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;distinct&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;first&lt;/span&gt;&lt;span class="p"&gt;()&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;session&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;lakehouse.tutorial.live_totals&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="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;totals&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;span class="n"&gt;truncate&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="nf"&gt;print&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;order_created records sent:        &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distinct order_created event ids:  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;distinct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders in live_totals:             &lt;/span&gt;&lt;span class="si"&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;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;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;orders&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&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="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&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 long each record spent in the Real-Time Mode query, Kafka to Kafka.
&lt;/span&gt;&lt;span class="n"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;topic&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-clean&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="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;unix_millis&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="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;get_json_object&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$.received_ms&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;long&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;ms&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;p50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p99&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spent&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;percentile_approx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms&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="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;])).&lt;/span&gt;&lt;span class="nf"&gt;first&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;print&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;orders-clean: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spent&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="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; records, p50 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p50&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms, p99 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p99&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms in Spark&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python check_stream.py
&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;+-----------+------+--------+-----------------------+
|location_id|orders|revenue |last_order_at          |
+-----------+------+--------+-----------------------+
|1          |361   |18666.46|2026-10-01 21:39:19.782|
|2          |354   |19276.01|2026-10-01 21:39:19.58 |
|3          |380   |19758.75|2026-10-01 21:39:19.802|
|4          |384   |19255.33|2026-10-01 21:39:19.883|
+-----------+------+--------+-----------------------+

order_created records sent:        1,512
distinct order_created event ids:  1,479
orders in live_totals:             1,479

orders-clean: 1,512 records, p50 1 ms, p99 3 ms in Spark
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The totals match the distinct count, not the raw one: the duplicate orders the generator sent never made it into a total. (Check too early, while the stateful query is still catching up, and its total trails the distinct count for a second.) The Real-Time Mode query, which has no state, passed every record through, duplicates included, in a millisecond or two each. A micro-batch query with a one-second trigger holds each record until its batch runs, half a second on average, before it does anything with it. In Spark 4.3, Real-Time Mode also runs stateful queries.&lt;/p&gt;

&lt;p&gt;Stop both queries with Ctrl+C in their terminals; each script catches it and stops its query on the server.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  ks_GEN["generator"]:::new
  ks_K["Kafka :9092&amp;lt;br/&amp;gt;topic orders"]:::new
  subgraph ks_SP["Spark 4.2"]
    ks_SL["stateless&amp;lt;br/&amp;gt;Real-Time Mode"]:::new
    ks_SF["stateful&amp;lt;br/&amp;gt;micro-batch"]:::new
  end
  ks_OUT["Kafka&amp;lt;br/&amp;gt;orders-clean"]:::new
  ks_T[("lakehouse.tutorial&amp;lt;br/&amp;gt;live_totals")]:::new
  ks_GEN --&amp;gt; ks_K
  ks_K --&amp;gt; ks_SL
  ks_K --&amp;gt; ks_SF
  ks_SL --&amp;gt;|"milliseconds"| ks_OUT
  ks_SF --&amp;gt;|"every second"| ks_T&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Orchestration: Airflow runs the pipeline and the maintenance
&lt;/h2&gt;

&lt;p&gt;Everything so far ran because you typed it. &lt;a href="https://airflow.apache.org/" rel="noopener noreferrer"&gt;Apache Airflow&lt;/a&gt; runs it on a schedule, retries it, and keeps a record of every run. It doesn't touch the data itself: its tasks talk to the same Connect server you've been using, so Airflow needs only &lt;code&gt;pyspark-client&lt;/code&gt;, not a JVM. Two DAGs, in a &lt;code&gt;dags&lt;/code&gt; folder:&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;mkdir &lt;/span&gt;dags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first runs the medallion pipeline every day. It does what you did by hand: clears the previous run's tables, runs the spec through &lt;code&gt;SparkPipelinesOperator&lt;/code&gt; from Airflow's Spark provider, and then checks that the gold table isn't empty. With a &lt;code&gt;spark_connect&lt;/code&gt; connection, the operator runs the same pipeline CLI you ran, inside the task, against the Connect server:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/dags/medallion.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run the medallion pipeline every day, on the cluster, through Spark Connect.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.providers.apache.spark.operators.spark_pipelines&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparkPipelinesOperator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medallion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@daily&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&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="n"&gt;catchup&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="nd"&gt;@task&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare&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 the schema, or drop the last run&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s tables (SDP won&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t replace them).&lt;/span&gt;&lt;span class="sh"&gt;"""&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;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://spark-connect: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;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 SCHEMA IF NOT EXISTS lakehouse.medallion&lt;/span&gt;&lt;span class="sh"&gt;"&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;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 lakehouse.medallion&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="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;DROP TABLE lakehouse.medallion.&lt;/span&gt;&lt;span class="si"&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="si"&gt;}&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_pipeline&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_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;/opt/airflow/pipeline/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="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_connect_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;span class="nd"&gt;@task&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_gold&lt;/span&gt;&lt;span class="p"&gt;():&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;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://spark-connect: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;brands&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;lakehouse.medallion.revenue_by_brand&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;print&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;revenue_by_brand has &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brands&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows&lt;/span&gt;&lt;span class="sh"&gt;"&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;brands&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the gold table is empty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;prepare&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;run_pipeline&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;check_gold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second compacts the two external tables every night and deletes the files no version from the last week needs. Compaction matters for both: the batch table was written as a few small files, and every micro-batch rewrites the streaming table as a few more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/dags/maintenance.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every night, compact the external Delta tables and clean up files nothing needs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;

&lt;span class="n"&gt;TABLES&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;lakehouse.tutorial.orders_raw&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;lakehouse.tutorial.live_totals&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="nc"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta_maintenance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 3 * * *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&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="n"&gt;catchup&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="nd"&gt;@task&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;optimize_and_vacuum&lt;/span&gt;&lt;span class="p"&gt;():&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;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://spark-connect: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;for&lt;/span&gt; &lt;span class="n"&gt;table&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;span class="n"&gt;before&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DESCRIBE DETAIL &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numFiles&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;OPTIMIZE &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&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="c1"&gt;# many small files into a few big ones
&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;VACUUM &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; RETAIN 168 HOURS&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="c1"&gt;# keep a week of history
&lt;/span&gt;            &lt;span class="n"&gt;after&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DESCRIBE DETAIL &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numFiles&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="sa"&gt;f&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&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; files -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;optimize_and_vacuum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It leaves the pipeline's tables alone: for catalog-managed tables the catalog owns maintenance, and Delta doesn't run client-side &lt;code&gt;OPTIMIZE&lt;/code&gt; and &lt;code&gt;VACUUM&lt;/code&gt; on them.&lt;/p&gt;

&lt;p&gt;Add Airflow, under &lt;code&gt;services:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;services:&lt;/code&gt;&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;airflow&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="na"&gt;dockerfile_inline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;FROM apache/airflow:3.3.2-python3.12&lt;/span&gt;
        &lt;span class="s"&gt;RUN pip install --no-cache-dir apache-airflow==3.3.2 apache-airflow-providers-apache-spark==6.3.2 pyspark-client==4.2.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standalone&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW__CORE__LOAD_EXAMPLES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_ALL_ADMINS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW_CONN_SPARK_CONNECT_DEFAULT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"conn_type":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"spark_connect",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"host":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"spark-connect",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"port":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;15002}'&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;${AIRFLOW_PORT:-8085}:8080"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;airflow-data:/opt/airflow&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./dags:/opt/airflow/dags&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./pipeline:/opt/airflow/pipeline&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="s"&gt;curl -sf http://localhost:8080/api/v2/monitor/health&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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and its volume, under &lt;code&gt;volumes:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;volumes:&lt;/code&gt;&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;airflow-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;build&lt;/code&gt; section is a two-line Dockerfile inside &lt;code&gt;compose.yaml&lt;/code&gt;: the official Airflow image plus the Spark provider and &lt;code&gt;pyspark-client&lt;/code&gt;, built the first time you start it. &lt;code&gt;airflow standalone&lt;/code&gt; runs every Airflow component in one container with a SQLite database, which is the simplest setup for one laptop. The &lt;code&gt;airflow-data&lt;/code&gt; volume keeps that database and the task logs when the container is recreated. The connection comes from an environment variable, and &lt;code&gt;SIMPLE_AUTH_MANAGER_ALL_ADMINS&lt;/code&gt; skips the login page, which is fine for a stack that only listens on your machine. The DAGs and the pipeline folder are mounted in, so the operator finds the same spec you ran.&lt;br&gt;
&lt;/p&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;DAGs start paused. Unpause both; the scheduler then starts each one's most recent scheduled run right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;airflow airflow dags unpause medallion
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;airflow airflow dags unpause delta_maintenance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give them a minute or two, then list the runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;airflow airflow dags list-runs medallion &lt;span class="nt"&gt;-o&lt;/span&gt; plain
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;airflow airflow dags list-runs delta_maintenance &lt;span class="nt"&gt;-o&lt;/span&gt; plain
&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;dag_id     run_id                                state    run_after                  logical_date               start_date                        end_date
medallion  scheduled__2026-10-01T00:00:00+00:00  success  2026-10-01T00:00:00+00:00  2026-10-01T00:00:00+00:00  2026-10-01T21:40:07.659874+00:00  2026-10-01T21:40:49.719529+00:00
dag_id             run_id                                state    run_after                  logical_date               start_date                        end_date
delta_maintenance  scheduled__2026-10-01T03:00:00+00:00  success  2026-10-01T03:00:00+00:00  2026-10-01T03:00:00+00:00  2026-10-01T21:40:09.561521+00:00  2026-10-01T21:40:41.881096+00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and read the results out of the task logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;airflow &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s2"&gt;"revenue_by_brand has [0-9]+ rows|lakehouse[.a-z_]+: [0-9]+ files -&amp;gt; [0-9]+"&lt;/span&gt; /opt/airflow/logs
&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;lakehouse.tutorial.orders_raw: 3 files -&amp;gt; 1
lakehouse.tutorial.live_totals: 2 files -&amp;gt; 1
revenue_by_brand has 5 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline rebuilt the medallion tables, and the maintenance DAG compacted each external table into one file. &lt;code&gt;orders_raw&lt;/code&gt; has UniForm on, so the compaction was converted to Iceberg too; read it as Iceberg again, and the readers find the new snapshot and the same rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python read_iceberg.py
&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;Reading s3://lakehouse/tables/orders_raw/metadata/00000-47aecc6b-89d1-40f8-97eb-b8c390180aca.metadata.json

PyIceberg: 34,021 rows (Iceberg v2, converted from Delta version 3)
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Airflow UI at &lt;a href="http://localhost:8085" rel="noopener noreferrer"&gt;http://localhost:8085&lt;/a&gt; shows the same runs in its grid view, with every task's log.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  af_AF["Airflow :8085&amp;lt;br/&amp;gt;two DAGs"]:::new
  af_SC["Spark Connect"]:::old
  af_UC["Unity Catalog"]:::old
  af_T[("medallion and&amp;lt;br/&amp;gt;tutorial tables")]:::old
  af_AF --&amp;gt;|"SDP run, OPTIMIZE"| af_SC
  af_SC --&amp;gt;|"names"| af_UC
  af_SC --&amp;gt; af_T&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  ML: MLflow tracks what you compute
&lt;/h2&gt;

&lt;p&gt;The last layer is where the tables get used. &lt;a href="https://mlflow.org/" rel="noopener noreferrer"&gt;MLflow&lt;/a&gt; records runs of whatever you compute from them, whether that's model training or a scheduled metric: parameters, metrics and artifacts, all searchable later. Here the tracking server keeps its metadata in SQLite and its artifacts in the bucket, and clients upload artifacts through the server, so only the server needs the S3 credentials.&lt;/p&gt;

&lt;p&gt;Add MLflow, under &lt;code&gt;services:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;services:&lt;/code&gt;&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;mlflow&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="na"&gt;dockerfile_inline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;FROM ghcr.io/mlflow/mlflow:v3.16.1&lt;/span&gt;
        &lt;span class="s"&gt;RUN pip install --no-cache-dir boto3==1.43.93&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;mlflow server --host 0.0.0.0 --port 5000 --workers 1&lt;/span&gt;
      &lt;span class="s"&gt;--backend-store-uri sqlite:////mlflow/mlflow.db&lt;/span&gt;
      &lt;span class="s"&gt;--artifacts-destination s3://lakehouse/mlflow&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MLFLOW_S3_ENDPOINT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://seaweedfs:8333&lt;/span&gt;
      &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lakehouse&lt;/span&gt;
      &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lakehouse-secret&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;${MLFLOW_PORT:-5000}:5000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mlflow-data:/mlflow&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="s"&gt;python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"&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;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and its volume, under &lt;code&gt;volumes:&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;, add under &lt;code&gt;volumes:&lt;/code&gt;&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;mlflow-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official image doesn't include &lt;code&gt;boto3&lt;/code&gt;, which the server needs to write to S3, so the build adds it. &lt;code&gt;--artifacts-destination&lt;/code&gt; is where the server puts artifacts it receives from clients.&lt;br&gt;
&lt;/p&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;This is a PySpark job that logs a run: it aggregates the gold table on the cluster, logs the table it read, three metrics and the table as a CSV artifact, then reads the run back from the tracking server and lists the artifact in the bucket:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/log_run.py&lt;/code&gt;&lt;/strong&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Log an MLflow run from a PySpark job, then read it back from the tracking server.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MLFLOW_TRACKING_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spark&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;gold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;lakehouse.medallion.revenue_by_brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracking_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MLFLOW_TRACKING_URI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_experiment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ghost-kitchen&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;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_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;brand-revenue&lt;/span&gt;&lt;span class="sh"&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;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gold&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;*&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;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;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;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;revenue&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;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;max&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&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;best_avg_order&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;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_table&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;lakehouse.medallion.revenue_by_brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_metrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asDict&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toPandas&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue_by_brand.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;logged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;logged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;logged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&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;for&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;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&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 artifact went through the tracking server into the bucket.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mlflow/&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;Contents&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;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python log_run.py
&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;2026/10/01 14:41:30 INFO mlflow.tracking.fluent: Experiment with name 'ghost-kitchen' does not exist. Creating a new experiment.
🏃 View run brand-revenue at: http://localhost:5000/#/experiments/1/runs/88457b52f19d43fea413f804bf7d6509
🧪 View experiment at: http://localhost:5000/#/experiments/1
brand-revenue: FINISHED
  best_avg_order = 74.92
  brands = 5.0
  revenue = 577384.27
mlflow/1/88457b52f19d43fea413f804bf7d6509/artifacts/revenue_by_brand.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run, its metrics and the artifact are also in the MLflow UI at &lt;a href="http://localhost:5000" rel="noopener noreferrer"&gt;http://localhost:5000&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  classDef new fill:#d97706,fill-opacity:0.72,stroke:#d97706,color:#ffffff
  classDef old fill:#4b5563,fill-opacity:0.72,stroke:#4b5563,color:#ffffff
  ml_JOB["PySpark job"]:::new
  ml_SC["Spark Connect"]:::old
  ml_ML["MLflow :5000"]:::new
  ml_SW[("SeaweedFS")]:::old
  ml_JOB --&amp;gt;|"reads gold"| ml_SC
  ml_JOB --&amp;gt;|"logs run"| ml_ML
  ml_ML --&amp;gt;|"artifacts"| ml_SW&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  You built it!
&lt;/h2&gt;

&lt;p&gt;The open lakehouse on Spark 4.2, as built in this post: your Python scripts, Airflow standalone and MLflow on top, all reaching one Spark Connect server; the generator feeding the landing files and the Kafka orders topic in KRaft mode; inside Spark 4.2, a Real-Time Mode query writing orders-clean back to Kafka and a stateful micro-batch query writing totals into Delta, beside the SDP medallion and batch writes; Unity Catalog OSS and SeaweedFS below, with PyIceberg and DuckDB reading the UniForm table's Iceberg metadata from SeaweedFS&lt;/p&gt;

&lt;p&gt;Reading it from the top: your scripts, Airflow's DAGs and your MLflow runs all reach Spark through one Connect endpoint. The generator feeds both the landing files in the bucket and the &lt;code&gt;orders&lt;/code&gt; topic. Spark turns that topic into clean events within milliseconds in Real-Time Mode, and into deduplicated running totals in a Delta table every second. It also runs the declarative pipeline, writes batches and compacts tables; Unity Catalog names every table and knows where it lives; SeaweedFS holds every file; and with UniForm on, Iceberg engines read the same files as Delta.&lt;/p&gt;

&lt;p&gt;Here's the whole &lt;code&gt;compose.yaml&lt;/code&gt; you built:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File: &lt;code&gt;open-lakehouse/compose.yaml&lt;/code&gt;&lt;/strong&gt;&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;seaweedfs&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;chrislusf/seaweedfs:3.80&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weed&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server -dir=/data -s3 -s3.config=/etc/seaweedfs/s3.json -volume.max=0 -master.volumeSizeLimitMB=1024&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;${S3_PORT:-8333}:8333"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./s3.json:/etc/seaweedfs/s3.json:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;seaweedfs-data:/data&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="s"&gt;wget -qO /dev/null http://127.0.0.1:8333/status&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;30&lt;/span&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.2.0&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-master&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.master.Master --host spark-master&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;${SPARK_UI_PORT:-8080}:8080"&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="s"&gt;curl -sf http://localhost:8080 &amp;gt; /dev/null&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;30&lt;/span&gt;

  &lt;span class="na"&gt;spark-worker&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-worker&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/spark/bin/spark-class org.apache.spark.deploy.worker.Worker --host spark-worker spark://spark-master:7077&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_CORES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6"&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_WORKER_MEMORY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4g&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spark-connect&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;/opt/spark/sbin/start-connect-server.sh&lt;/span&gt;
      &lt;span class="s"&gt;--master spark://spark-master:7077&lt;/span&gt;
      &lt;span class="s"&gt;--packages io.delta:delta-spark_4.2_2.13:4.4.0,io.delta:delta-iceberg_2.13:4.4.0,io.unitycatalog:unitycatalog-spark_4.2_2.13:0.6.0,org.apache.hadoop:hadoop-aws:3.5.0,org.apache.spark:spark-sql-kafka-0-10_2.13:4.2.0&lt;/span&gt;
      &lt;span class="s"&gt;--exclude-packages io.delta:delta-spark_4.1_2.13&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPARK_NO_DAEMONIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;${SPARK_CONNECT_PORT:-15002}:15002"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./spark-conf:/opt/spark/conf&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;spark-work:/opt/spark/work-dir&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark-master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/dev/tcp/localhost/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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;

  &lt;span class="na"&gt;unity-catalog&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;unitycatalog/unitycatalog:v0.6.0&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;${UC_PORT:-8081}:8080"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./server.properties:/home/unitycatalog/etc/conf/server.properties:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;uc-data:/home/unitycatalog/etc/db&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="s"&gt;wget -qO /dev/null http://127.0.0.1:8080/api/2.1/unity-catalog/catalogs&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;30&lt;/span&gt;

  &lt;span class="na"&gt;kafka&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/kafka:4.2.0&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;${KAFKA_PORT:-9092}:9094"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_NODE_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_PROCESS_ROLES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broker,controller&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_QUORUM_VOTERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1@kafka:9093&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_LISTENER_NAMES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTROLLER&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_LISTENERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:9094&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_ADVERTISED_LISTENERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_PORT:-9092}&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_LISTENER_SECURITY_PROTOCOL_MAP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_INTER_BROKER_LISTENER_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INTERNAL&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_MIN_ISR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&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="s"&gt;/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list&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;10s&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;30&lt;/span&gt;

  &lt;span class="na"&gt;airflow&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="na"&gt;dockerfile_inline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;FROM apache/airflow:3.3.2-python3.12&lt;/span&gt;
        &lt;span class="s"&gt;RUN pip install --no-cache-dir apache-airflow==3.3.2 apache-airflow-providers-apache-spark==6.3.2 pyspark-client==4.2.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standalone&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW__CORE__LOAD_EXAMPLES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_ALL_ADMINS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;AIRFLOW_CONN_SPARK_CONNECT_DEFAULT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"conn_type":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"spark_connect",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"host":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"spark-connect",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"port":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;15002}'&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;${AIRFLOW_PORT:-8085}:8080"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;airflow-data:/opt/airflow&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./dags:/opt/airflow/dags&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./pipeline:/opt/airflow/pipeline&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="s"&gt;curl -sf http://localhost:8080/api/v2/monitor/health&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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;

  &lt;span class="na"&gt;mlflow&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="na"&gt;dockerfile_inline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;FROM ghcr.io/mlflow/mlflow:v3.16.1&lt;/span&gt;
        &lt;span class="s"&gt;RUN pip install --no-cache-dir boto3==1.43.93&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;mlflow server --host 0.0.0.0 --port 5000 --workers 1&lt;/span&gt;
      &lt;span class="s"&gt;--backend-store-uri sqlite:////mlflow/mlflow.db&lt;/span&gt;
      &lt;span class="s"&gt;--artifacts-destination s3://lakehouse/mlflow&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MLFLOW_S3_ENDPOINT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://seaweedfs:8333&lt;/span&gt;
      &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lakehouse&lt;/span&gt;
      &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lakehouse-secret&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;${MLFLOW_PORT:-5000}:5000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mlflow-data:/mlflow&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="s"&gt;python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"&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;30&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;seaweedfs-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spark-work&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uc-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;airflow-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mlflow-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose ps&lt;/code&gt; shows the whole stack at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Service}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Status}}"&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;SERVICE         STATUS
airflow         Up 37 seconds (healthy)
kafka           Up 4 minutes (healthy)
mlflow          Up 37 seconds (healthy)
seaweedfs       Up 6 minutes (healthy)
spark-connect   Up 5 minutes (healthy)
spark-master    Up 6 minutes (healthy)
spark-worker    Up 6 minutes
unity-catalog   Up 5 minutes (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To stop for the day, &lt;code&gt;docker compose stop&lt;/code&gt;, and &lt;code&gt;docker compose up -d --wait&lt;/code&gt; brings it all back. &lt;code&gt;docker compose down&lt;/code&gt; removes the containers but keeps the named volumes, so your tables, the catalog and the downloaded JARs survive; &lt;code&gt;docker compose down -v&lt;/code&gt; removes the volumes too and starts you over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;This post builds the smallest version of the stack that teaches each layer. &lt;a href="https://github.com/open-lakehouse/open-lakehouse" rel="noopener noreferrer"&gt;open-lakehouse&lt;/a&gt; is the complete, maintained version of it: the same layers driven by one CLI, with CI, more demos (a Delta deep dive, Unity Catalog governance, a longer streaming benchmark, MLflow's model registry), Delta Sharing and a web dashboard. And if you want the story of the workbench this stack grew out of, that's 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;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>dataengineering</category>
      <category>architecture</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
