<?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: Aniketh Deshpande</title>
    <description>The latest articles on DEV Community by Aniketh Deshpande (@anikethsdeshpande).</description>
    <link>https://dev.to/anikethsdeshpande</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%2F212621%2F92bf2932-4c1c-4ae4-87c2-96232ff403dc.jpeg</url>
      <title>DEV Community: Aniketh Deshpande</title>
      <link>https://dev.to/anikethsdeshpande</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anikethsdeshpande"/>
    <language>en</language>
    <item>
      <title>What Actually Happens When You Call spark.read? One Line of Python, a Thousand Tasks</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:43:21 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/what-actually-happens-when-you-call-sparkread-one-line-of-python-a-thousand-tasks-4am7</link>
      <guid>https://dev.to/anikethsdeshpande/what-actually-happens-when-you-call-sparkread-one-line-of-python-a-thousand-tasks-4am7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;spark.read.parquet(path)&lt;/code&gt; reads almost nothing. The real work starts at the first &lt;em&gt;action&lt;/em&gt;, when Spark&lt;br&gt;
turns your DataFrame into four plans, slices your files into tasks, ships those tasks to executors and, depending on&lt;br&gt;
what you asked for, shuffles data across the network. This article follows one DataFrame through all of that, in&lt;br&gt;
five scenarios: a plain read, a read with filters, a sort, a join, and finally the whole pipeline end to end.&lt;/p&gt;

&lt;p&gt;⏱️ &lt;strong&gt;30-minute read.&lt;/strong&gt; Every scenario comes with the code, the &lt;code&gt;explain()&lt;/code&gt; output, the Spark UI you'd see, and a&lt;br&gt;
diagram of what the cluster is doing. All facts were checked against &lt;strong&gt;Spark 3.5&lt;/strong&gt; and &lt;strong&gt;Spark 4.0&lt;/strong&gt;; defaults are&lt;br&gt;
the same in both unless noted.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Prologue: Maya's first ticket&lt;/li&gt;
&lt;li&gt;The setup: one cluster, one dataset&lt;/li&gt;
&lt;li&gt;Chapter 1: The line that does (almost) nothing&lt;/li&gt;
&lt;li&gt;Chapter 2: The first action, or the anatomy of a job&lt;/li&gt;
&lt;li&gt;Chapter 3: Reading less: filters, pruning and pushdown&lt;/li&gt;
&lt;li&gt;Chapter 4: Putting things in order: sort and shuffle&lt;/li&gt;
&lt;li&gt;Chapter 5: Meeting other tables: joins&lt;/li&gt;
&lt;li&gt;Chapter 6: The whole pipeline, end to end&lt;/li&gt;
&lt;li&gt;Cheat sheets&lt;/li&gt;
&lt;li&gt;Try it on your laptop&lt;/li&gt;
&lt;li&gt;Further reading&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Prologue: Maya's first ticket
&lt;/h2&gt;

&lt;p&gt;It's Monday. Maya has been a data engineer at RideHub for exactly four days, and she has just been handed her first real ticket:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;FIN-2231&lt;/strong&gt;: Finance needs the total fares for &lt;strong&gt;March&lt;/strong&gt;, counting only trips over &lt;strong&gt;$100&lt;/strong&gt;, broken down by driver and pickup zone. Top 10 drivers. By Thursday.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;She knows PySpark. She writes six lines:&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;trips&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;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;s3a://ridehub/trips/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;drivers&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;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;s3a://ridehub/drivers/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;zones&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;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;s3a://ridehub/zones/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trips&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;year&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_ts&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="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;month&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_ts&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="mi"&gt;3&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="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;fare&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;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zone_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;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;driver_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;zone_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;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;fare&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_fare&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&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;trips&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;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;total_fare&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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;report&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;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;s3a://ridehub/reports/fin-2231/&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;She hits run. &lt;strong&gt;Fourteen minutes&lt;/strong&gt; later, the job finishes. The numbers look right. She's about to close the ticket when Ravi, the staff engineer who sits across from her, rolls his chair over.&lt;/p&gt;

&lt;p&gt;"Fourteen minutes for a month of data?" he says. "Do you know what those six lines actually did?"&lt;/p&gt;

&lt;p&gt;"They... read the data?"&lt;/p&gt;

&lt;p&gt;"That's the one thing they almost didn't do." He pulls up the Spark UI. There are &lt;strong&gt;five jobs&lt;/strong&gt;, one of them with 16,384 tasks, and half a terabyte read from S3 to produce a report with ten rows. "Let's walk through it. By Thursday you'll be able to read this screen like a story."&lt;/p&gt;

&lt;p&gt;This article is that walk. We'll take Maya's pipeline apart one operation at a time, and for each one answer the same question: &lt;strong&gt;what does the cluster actually do when this line runs?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup: one cluster, one dataset
&lt;/h2&gt;

&lt;p&gt;So the numbers in the story mean something, here is what Maya is working with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cluster&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌─────────────────────────────┐
 │  Driver                     │   8 GB RAM, 2 cores
 │  (runs Maya's Python +      │   Holds the SparkSession, the plans,
 │   the JVM that plans jobs)  │   the scheduler and the Spark UI
 └──────────────┬──────────────┘
                │  schedules tasks over the network
   ┌────────────┼────────────┬─────────── ... ──────────┐
   ▼            ▼            ▼                          ▼
 ┌──────┐    ┌──────┐    ┌──────┐                   ┌──────┐
 │Exec 1│    │Exec 2│    │Exec 3│       ...         │Exec10│   10 executors
 │4 core│    │4 core│    │4 core│                   │4 core│   16 GB RAM each
 │16 GB │    │16 GB │    │16 GB │                   │16 GB │   = 40 task slots
 └──────┘    └──────┘    └──────┘                   └──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The data&lt;/strong&gt; lives on object storage, as Parquet, partitioned by day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3a://ridehub/trips/                      2.0 TB, 3.2 billion rows, 18 columns
├── dt=2025-10-01/
│   ├── part-00000-....snappy.parquet     ~128 MB each
│   ├── part-00001-....snappy.parquet
│   └── ...  (~44 files per day)
├── dt=2025-10-02/
├── ...
└── dt=2026-09-30/                        365 days ≈ 16,000 files

s3a://ridehub/drivers/   50 MB,  600,000 rows       (driver_id, driver_name, licence, payout, ...)
s3a://ridehub/zones/     20 KB,  263 rows           (zone_id, zone_name, borough)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;trips&lt;/code&gt; schema, trimmed to the columns the story uses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trip_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;driver_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;long&lt;/td&gt;
&lt;td&gt;joins to &lt;code&gt;drivers&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zone_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int&lt;/td&gt;
&lt;td&gt;pickup zone, joins to &lt;code&gt;zones&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pickup_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;double&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;date&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;partition column&lt;/strong&gt;: not stored in the files, it comes from the directory name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the session, with every setting at its default so the defaults are what we learn:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="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;appName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fin-2231&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;blockquote&gt;
&lt;p&gt;💡 All the &lt;code&gt;explain()&lt;/code&gt; output below is lightly trimmed (expression IDs like &lt;code&gt;#12&lt;/code&gt; and plan IDs removed) so it fits on&lt;br&gt;
a screen. The shape is exactly what you'll see.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Chapter 1: The line that does (almost) nothing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;trips&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;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;s3a://ridehub/trips/&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;Maya runs this in a notebook and it returns in &lt;strong&gt;1.8 seconds&lt;/strong&gt;. For a 2 TB dataset. Her first instinct is that Spark is fast. Ravi's correction: Spark hasn't read any trips yet. It did do three things, though, and one of them is a real Spark job.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 Resolving the data source
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.read&lt;/code&gt; gives you a &lt;code&gt;DataFrameReader&lt;/code&gt;. &lt;code&gt;.parquet(path)&lt;/code&gt; asks it to build a &lt;strong&gt;data source&lt;/strong&gt;. Spark has two data source APIs. The newer one (DataSource V2) is used for Delta, Iceberg, Kafka and most connectors; but for the built-in file formats, Spark still defaults to the original V1 path, because &lt;code&gt;spark.sql.sources.useV1SourceList&lt;/code&gt; lists &lt;code&gt;parquet, orc, csv, json, text, avro&lt;/code&gt;. So Parquet resolves to &lt;code&gt;ParquetFileFormat&lt;/code&gt; wrapped in a &lt;code&gt;HadoopFsRelation&lt;/code&gt;. This matters later, because V1 and V2 differ in exactly which optimizations they support.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Listing the files, which is Job 0
&lt;/h3&gt;

&lt;p&gt;To build that relation, Spark needs to know which files exist. It creates an &lt;code&gt;InMemoryFileIndex&lt;/code&gt; and walks the directory tree.&lt;/p&gt;

&lt;p&gt;The top level has 365 directories. That's more than &lt;code&gt;spark.sql.sources.parallelPartitionDiscovery.threshold&lt;/code&gt; (default &lt;strong&gt;32&lt;/strong&gt;), so Spark doesn't list them from the driver one by one. It &lt;strong&gt;launches a Spark job&lt;/strong&gt; that lists directories in parallel on the executors. If Maya opens the Spark UI right now, she sees it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jobs
 Job 0   Listing leaf files and directories for 365 paths   365 tasks   1.4 s   ✔
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is a list of ~16,000 &lt;code&gt;FileStatus&lt;/code&gt; objects (path, size, modification time) cached in driver memory. While walking the tree, Spark also runs &lt;strong&gt;partition discovery&lt;/strong&gt;: it sees &lt;code&gt;dt=2026-03-14&lt;/code&gt; in every path and turns &lt;code&gt;dt&lt;/code&gt; into a column. Because &lt;code&gt;spark.sql.sources.partitionColumnTypeInference.enabled&lt;/code&gt; is on, the value &lt;code&gt;2026-03-14&lt;/code&gt; is inferred as a &lt;code&gt;date&lt;/code&gt;, not a string.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ This listing is why "just calling &lt;code&gt;spark.read&lt;/code&gt;" on a bucket with a million small files can take minutes before&lt;br&gt;
any query runs. The fix is on the write side: fewer, bigger files, and a real table format (Hive metastore, Delta,&lt;br&gt;
Iceberg) that stores the file list in metadata instead of walking S3.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1.3 Reading one footer for the schema
&lt;/h3&gt;

&lt;p&gt;Maya didn't give a schema, so Spark infers one. For Parquet that's cheap: every Parquet file ends with a &lt;strong&gt;footer&lt;/strong&gt; that contains the full schema and statistics for every column chunk. With &lt;code&gt;spark.sql.parquet.mergeSchema&lt;/code&gt; at its default &lt;code&gt;false&lt;/code&gt;, Spark opens &lt;strong&gt;one&lt;/strong&gt; file, seeks to the end, reads the footer, and takes that schema as the schema of the whole dataset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; One Parquet file, from the back:

 ┌──────┬─────────────┬─────────────┬─────┬─────────────────────┬─────┬──────┐
 │ PAR1 │ row group 0 │ row group 1 │ ... │  footer (metadata)  │ len │ PAR1 │
 └──────┴─────────────┴─────────────┴─────┴──────────┬──────────┴─────┴──────┘
                                                     ▲
                                                     │  schema, row counts, min/max
                                      Spark reads only this (a few KB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that with CSV or JSON with &lt;code&gt;inferSchema=True&lt;/code&gt;: there is no footer, so Spark runs a &lt;strong&gt;full pass over the data&lt;/strong&gt; just to guess types. That pass is a job too, and on 2 TB it's an expensive one. That's reason number one to prefer columnar formats, and reason number one to pass an explicit schema when you can't.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.4 What &lt;code&gt;trips&lt;/code&gt; actually is
&lt;/h3&gt;

&lt;p&gt;After 1.8 seconds, the variable &lt;code&gt;trips&lt;/code&gt; holds a &lt;code&gt;DataFrame&lt;/code&gt;. In Spark 3.x/4.x a DataFrame is a &lt;code&gt;Dataset[Row]&lt;/code&gt;, and a Dataset is a thin wrapper around a &lt;strong&gt;logical plan&lt;/strong&gt;: a tree that describes &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt; to get it. Right now that tree has exactly one node:&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;trips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&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;== Parsed Logical Plan ==
Relation [trip_id,driver_id,zone_id,pickup_ts,fare,...,dt] parquet

== Analyzed Logical Plan ==
trip_id: string, driver_id: bigint, zone_id: int, pickup_ts: timestamp, fare: double, ..., dt: date
Relation [trip_id,driver_id,zone_id,pickup_ts,fare,...,dt] parquet

== Optimized Logical Plan ==
Relation [trip_id,driver_id,zone_id,pickup_ts,fare,...,dt] parquet

== Physical Plan ==
*(1) ColumnarToRow
+- FileScan parquet [trip_id,driver_id,zone_id,pickup_ts,fare,...,dt]
     Batched: true, Format: Parquet,
     Location: InMemoryFileIndex(1 paths)[s3a://ridehub/trips],
     PartitionFilters: [], PushedFilters: [],
     ReadSchema: struct&amp;lt;trip_id:string,driver_id:bigint,zone_id:int,pickup_ts:timestamp,fare:double,...&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what's in driver memory after &lt;code&gt;spark.read&lt;/code&gt;, and what isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; DRIVER MEMORY after spark.read.parquet(...)
 ┌─────────────────────────────────────────────────────────┐
 │  DataFrame `trips`                                      │
 │   └─ LogicalPlan: Relation(HadoopFsRelation)            │
 │        ├─ schema         (from one footer)              │
 │        ├─ partition cols (dt: date, from paths)         │
 │        └─ InMemoryFileIndex                             │
 │             └─ ~16,000 FileStatus (path, size, mtime)   │
 └─────────────────────────────────────────────────────────┘
 EXECUTOR MEMORY: nothing. Zero trip rows anywhere.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.5 Why laziness is the whole design, not a trick
&lt;/h3&gt;

&lt;p&gt;Every method Maya calls next (&lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, &lt;code&gt;groupBy&lt;/code&gt;, &lt;code&gt;orderBy&lt;/code&gt;) is a &lt;strong&gt;transformation&lt;/strong&gt;. Each one returns a new DataFrame whose plan is the old plan with one more node on top. Nothing executes. Only an &lt;strong&gt;action&lt;/strong&gt; (&lt;code&gt;count&lt;/code&gt;, &lt;code&gt;collect&lt;/code&gt;, &lt;code&gt;show&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;toPandas&lt;/code&gt;, &lt;code&gt;foreach&lt;/code&gt;) hands the plan to the engine.&lt;/p&gt;

&lt;p&gt;This is deliberate. Because Spark sees the &lt;em&gt;entire&lt;/em&gt; tree before it runs anything, it can push filters below joins, drop columns nobody reads, and decide how to join based on the size of what's left after filtering. An eager engine that executed &lt;code&gt;spark.read.parquet&lt;/code&gt; as "read 2 TB" could never do that. Hold on to this idea: &lt;strong&gt;almost every optimization in the rest of this article is possible only because the read was lazy.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;PySpark note.&lt;/strong&gt; Nothing Maya wrote runs in Python on the executors. The DataFrame API is a thin client: each&lt;br&gt;
call is forwarded to the JVM (over Py4J in classic mode, or over gRPC with Spark Connect in 3.4+) and builds the plan&lt;br&gt;
there. Python workers only appear on the executors when you use a Python UDF, &lt;code&gt;rdd.map&lt;/code&gt;, or &lt;code&gt;mapInPandas&lt;/code&gt;. Keep&lt;br&gt;
that in mind when we hit the UDF trap in Chapter 3.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Chapter 2: The first action, or the anatomy of a job
&lt;/h2&gt;

&lt;p&gt;Before touching the ticket, Maya wants a feel for the data:&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;trips&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;# 3,214,906,117
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes &lt;strong&gt;3 minutes 50 seconds&lt;/strong&gt;. Two terabytes in under four minutes on 40 cores felt impossibly fast to her, and Ravi says that's the second lesson: it was fast &lt;em&gt;because of what it didn't read&lt;/em&gt;. But first, the journey. A &lt;code&gt;count()&lt;/code&gt; goes through seven stops between Maya's notebook and the number on her screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Stop 1: four plans (Catalyst)
&lt;/h3&gt;

&lt;p&gt;The action creates a &lt;code&gt;QueryExecution&lt;/code&gt;, and the plan Maya built goes through Spark's optimizer, &lt;strong&gt;Catalyst&lt;/strong&gt;, in four phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   trips.count()
        │
        ▼
 ┌──────────────────────┐   "Unresolved": names are just strings.
 │ Parsed logical plan  │   count(1) over Relation
 └──────────┬───────────┘
            │  ANALYZER: resolve column names against the catalog/schema,
            │  check types, expand *, resolve functions
            ▼
 ┌──────────────────────┐   Every column now has a type and an ID (fare#12)
 │ Analyzed logical plan│
 └──────────┬───────────┘
            │  OPTIMIZER: ~100 rule-based rewrites, applied until nothing changes
            │  ColumnPruning, PushDownPredicates, ConstantFolding,
            │  CombineFilters, ReorderJoin, ...
            ▼
 ┌──────────────────────┐   Still "what", but a cheaper "what"
 │ Optimized logical    │
 └──────────┬───────────┘
            │  SPARK PLANNER: strategies turn logical nodes into physical
            │  operators (Aggregate → HashAggregate, Join → BroadcastHashJoin...)
            ▼
 ┌──────────────────────┐   "How": real operators with real algorithms
 │ Physical plan        │
 └──────────┬───────────┘
            │  PREPARATION: EnsureRequirements inserts Exchanges (shuffles),
            │  CollapseCodegenStages fuses operators, AQE wraps the whole thing
            ▼
 ┌──────────────────────┐
 │ Executed plan        │ ──▶  RDDs  ──▶  DAGScheduler
 └──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;count()&lt;/code&gt; the final physical plan is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[], functions=[count(1)])                      ← final sum, 1 partition
   +- Exchange SinglePartition, ENSURE_REQUIREMENTS                  ← a SHUFFLE
      +- HashAggregate(keys=[], functions=[partial_count(1)])        ← one count per partition
         +- ColumnarToRow
            +- FileScan parquet []                                   ← no columns!
                 Batched: true, Format: Parquet,
                 Location: InMemoryFileIndex(1 paths)[s3a://ridehub/trips],
                 ReadSchema: struct&amp;lt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read off this plan, bottom to top:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FileScan parquet []&lt;/code&gt; with &lt;code&gt;ReadSchema: struct&amp;lt;&amp;gt;&lt;/code&gt;.&lt;/strong&gt; The optimizer's &lt;code&gt;ColumnPruning&lt;/code&gt; rule noticed that &lt;code&gt;count(1)&lt;/code&gt; needs no columns at all. The Parquet reader will open each file, read the row count from each row group header, and return empty batches that carry only a row count. That's why 2 TB took four minutes: Spark read footers and headers, not data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two &lt;code&gt;HashAggregate&lt;/code&gt; nodes with an &lt;code&gt;Exchange&lt;/code&gt; between them.&lt;/strong&gt; Aggregation is split into a &lt;em&gt;partial&lt;/em&gt; step that runs where the data is, and a &lt;em&gt;final&lt;/em&gt; step that merges the partials. The &lt;code&gt;Exchange&lt;/code&gt; is a &lt;strong&gt;shuffle&lt;/strong&gt;: data moving between tasks over the network. Every &lt;code&gt;Exchange&lt;/code&gt; in a plan is a &lt;strong&gt;stage boundary&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AdaptiveSparkPlan isFinalPlan=false&lt;/code&gt;.&lt;/strong&gt; Adaptive Query Execution (on by default since 3.2) will re-optimize this plan at each shuffle boundary using real statistics. We'll see it earn its keep in Chapters 4 and 5.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  2.2 Stop 2: how many tasks? (file splitting)
&lt;/h3&gt;

&lt;p&gt;The physical plan is turned into RDDs. The &lt;code&gt;FileScan&lt;/code&gt; becomes a &lt;code&gt;FileScanRDD&lt;/code&gt;, and its number of partitions is the number of tasks in the first stage. Spark decides that with a small piece of arithmetic in &lt;code&gt;FilePartition.maxSplitBytes&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="c1"&gt;# Pseudocode of what the driver computes. Defaults in comments.
&lt;/span&gt;&lt;span class="n"&gt;max_partition_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;MB&lt;/span&gt;            &lt;span class="c1"&gt;# spark.sql.files.maxPartitionBytes
&lt;/span&gt;&lt;span class="n"&gt;open_cost&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;MB&lt;/span&gt;              &lt;span class="c1"&gt;# spark.sql.files.openCostInBytes
&lt;/span&gt;&lt;span class="n"&gt;min_partitions&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;default_parallelism&lt;/span&gt; &lt;span class="c1"&gt;# spark.sql.files.minPartitionNum (= total cores = 40)
&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;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="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;open_cost&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;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# 2.0 TB + 16,000 × 4 MB
&lt;/span&gt;&lt;span class="n"&gt;bytes_per_core&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;min_partitions&lt;/span&gt;                    &lt;span class="c1"&gt;# ≈ 51 GB
&lt;/span&gt;&lt;span class="n"&gt;max_split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_partition_bytes&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="n"&gt;open_cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_per_core&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# = 128 MB
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then Spark &lt;strong&gt;bin-packs&lt;/strong&gt;: files are sorted by size (largest first), split into chunks of at most &lt;code&gt;max_split&lt;/code&gt;, and chunks are packed into partitions until adding the next one would exceed &lt;code&gt;max_split&lt;/code&gt;. Each resulting &lt;code&gt;FilePartition&lt;/code&gt; is one task. With 16,000 files of ~128 MB:&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;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;files&lt;/td&gt;
&lt;td&gt;~16,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bytes per task&lt;/td&gt;
&lt;td&gt;128 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tasks in stage 0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,384&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;task slots&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;waves of tasks&lt;/td&gt;
&lt;td&gt;~410&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two consequences worth remembering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small files are expensive.&lt;/strong&gt; The &lt;code&gt;open_cost&lt;/code&gt; of 4 MB is a fake size added to every file so the packer doesn't put thousands of 10 KB files into one task. But each file is still a separate S3 &lt;code&gt;GET&lt;/code&gt; for the footer. 16,000 files of 128 MB is fine; 2 million files of 1 MB would be a disaster at the same total size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splittable vs not.&lt;/strong&gt; Parquet and ORC can be split at row-group boundaries, so one 1 GB file becomes 8 tasks. A &lt;strong&gt;gzip-compressed CSV or JSON file cannot be split&lt;/strong&gt;: one file, one task, no matter how big. That's the classic "one executor is busy, nine are idle" symptom.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2.3 Stop 3: stages and the DAGScheduler
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;count()&lt;/code&gt; ends in &lt;code&gt;sc.runJob(...)&lt;/code&gt;. The &lt;strong&gt;DAGScheduler&lt;/strong&gt; receives the final RDD and walks its lineage backwards. Every time it crosses a &lt;code&gt;ShuffleDependency&lt;/code&gt; (the RDD form of an &lt;code&gt;Exchange&lt;/code&gt;), it cuts a stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   RDD lineage (read bottom-up)                 Stages

   ResultRDD  (final HashAggregate)             ┌────────────────────────┐
       ▲  ShuffleDependency ◀──── stage cut ────│ Stage 1 (ResultStage)  │  1 task
       │                                        └────────────────────────┘
   MapPartitionsRDD (partial HashAggregate)     ┌────────────────────────┐
       ▲                                        │ Stage 0                │
   MapPartitionsRDD (ColumnarToRow)             │ (ShuffleMapStage)      │  16,384 tasks
       ▲                                        │                        │
   FileScanRDD (16,384 partitions)              └────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage 0 has no missing parents, so it's submitted first as a &lt;code&gt;TaskSet&lt;/code&gt; of 16,384 &lt;code&gt;ShuffleMapTask&lt;/code&gt;s. Stage 1 waits until every task of Stage 0 has reported back.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Stop 4: shipping tasks to executors
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;TaskSchedulerImpl&lt;/code&gt; takes the TaskSet and matches it against &lt;strong&gt;resource offers&lt;/strong&gt; from executors ("I have 4 free cores"). The scheduling is FIFO by default, respects locality (&lt;code&gt;PROCESS_LOCAL&lt;/code&gt; → &lt;code&gt;NODE_LOCAL&lt;/code&gt; → &lt;code&gt;RACK_LOCAL&lt;/code&gt; → &lt;code&gt;ANY&lt;/code&gt;, waiting &lt;code&gt;spark.locality.wait&lt;/code&gt; = 3 s per level), and, because the data is on S3 and not on local disks, every task here is &lt;code&gt;ANY&lt;/code&gt;: there's no local copy to prefer.&lt;/p&gt;

&lt;p&gt;Each &lt;code&gt;LaunchTask&lt;/code&gt; message is small. The task's code (the whole RDD chain plus the generated Java for the operators) is serialized &lt;strong&gt;once&lt;/strong&gt; into a &lt;code&gt;taskBinary&lt;/code&gt; and sent to executors as a broadcast variable, not 16,384 times. The per-task message only carries the partition description: which files, which byte ranges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; DRIVER                                               EXECUTOR 3
 ┌───────────────────────────┐                       ┌───────────────────────────┐
 │ DAGScheduler              │                       │ CoarseGrainedExecutor     │
 │   stage 0: 16,384 tasks   │                       │   Backend                 │
 │ TaskSchedulerImpl         │  LaunchTask(task 812: │   ┌─ task 812 ─┐          │
 │   TaskSetManager          │  file X[0:128MB])     │   ├─ task 813  │ 4 cores  │
 │ SchedulerBackend          │ --------------------&amp;gt; │   ├─ task 814  │ busy     │
 │   (RPC endpoint)          │                       │   └─ task 815 ─┘          │
 │ MapOutputTracker          │  &amp;lt;--------------------│ BlockManager              │
 │   (where shuffle          │  StatusUpdate(        │   shuffle files on local  │
 │    blocks live)           │  FINISHED, MapStatus) │   disk, broadcast cache   │
 └───────────────────────────┘                       └───────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.5 Stop 5: inside one task
&lt;/h3&gt;

&lt;p&gt;Task 812 lands on executor 3, core 2. From its point of view:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deserialize&lt;/strong&gt; the task binary (first task on this executor only; afterwards it's cached) and the partition: &lt;code&gt;dt=2026-01-19/part-00031.parquet&lt;/code&gt;, bytes &lt;code&gt;0..134217728&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open the file&lt;/strong&gt; through the Hadoop &lt;code&gt;S3AFileSystem&lt;/code&gt;, seek to the end, read the footer. Pick the &lt;strong&gt;row groups whose midpoint falls inside this task's byte range&lt;/strong&gt;. That midpoint rule is how two tasks that split one file never double-count a row group that straddles the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read with the vectorized reader.&lt;/strong&gt; &lt;code&gt;VectorizedParquetRecordReader&lt;/code&gt; produces &lt;code&gt;ColumnarBatch&lt;/code&gt;es of up to 4,096 rows (&lt;code&gt;spark.sql.parquet.columnarReaderBatchSize&lt;/code&gt;). For this query the batch has &lt;strong&gt;zero columns&lt;/strong&gt; and a row count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the generated code.&lt;/strong&gt; The &lt;code&gt;*(1)&lt;/code&gt; in &lt;code&gt;explain&lt;/code&gt; marks a &lt;em&gt;whole-stage codegen&lt;/em&gt; unit: Spark fused &lt;code&gt;ColumnarToRow&lt;/code&gt; and the partial &lt;code&gt;HashAggregate&lt;/code&gt; into one hand-written-looking Java class, compiled by Janino. For this plan the loop body is essentially &lt;code&gt;count += batch.numRows()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shuffle write.&lt;/strong&gt; The partial result, one row holding one long, goes through the shuffle writer: it's written to a file under &lt;code&gt;blockmgr-*/&lt;/code&gt; on the executor's local disk, with a companion &lt;code&gt;.index&lt;/code&gt; file, and the task reports a &lt;code&gt;MapStatus&lt;/code&gt; (where the block is, how big) back to the driver's &lt;code&gt;MapOutputTracker&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Multiply by 16,384. Average task time ~0.5 s, 40 at a time, roughly 3.5 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.6 Stop 6: the second stage and the result
&lt;/h3&gt;

&lt;p&gt;When the last &lt;code&gt;MapStatus&lt;/code&gt; arrives, the DAGScheduler submits Stage 1: &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;ResultTask&lt;/code&gt;. It asks the &lt;code&gt;MapOutputTracker&lt;/code&gt; where the 16,384 shuffle blocks are, fetches all of them (each is a few dozen bytes, but it's still 16,384 network requests, which is why this tiny stage takes a couple of seconds), sums them, and returns the single row to the driver.&lt;/p&gt;

&lt;p&gt;The task result is small, so it comes back inline in the status update (&lt;code&gt;DirectTaskResult&lt;/code&gt;, anything under &lt;code&gt;spark.task.maxDirectResultSize&lt;/code&gt; = 1 MB). Larger results are written to the executor's BlockManager and the driver fetches them; the total is capped by &lt;code&gt;spark.driver.maxResultSize&lt;/code&gt; (1 GB), which is the limit you hit when a &lt;code&gt;collect()&lt;/code&gt; is too greedy.&lt;/p&gt;

&lt;p&gt;The driver converts the row to a Python &lt;code&gt;int&lt;/code&gt;, Py4J hands it to the notebook, and Maya sees &lt;code&gt;3214906117&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.7 Stop 7: what the Spark UI shows
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jobs
 Job 0   Listing leaf files and directories for 365 paths     365 tasks      1.4 s
 Job 1   count at &amp;lt;cell&amp;gt;:1   Stage 0   16384 tasks   Input 2.0 TB   Shuffle Write 1.2 MB   3.7 min
 Job 2   count at &amp;lt;cell&amp;gt;:1   Stage 1       1 task    Shuffle Read 1.2 MB                     2 s

SQL / DataFrame tab → query 1
  Scan parquet:  number of files read 16,384 · size of files read 2.0 TB
                 number of output rows 3,214,906,117 · scan time 2.1 h (summed over tasks)
  HashAggregate: number of output rows 16,384 → Exchange → HashAggregate: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two jobs for one &lt;code&gt;count()&lt;/code&gt;? That's AQE: it submits each shuffle map stage as its own job so it can look at the real output sizes before planning the next stage. The SQL tab groups them back into one query.&lt;/p&gt;

&lt;p&gt;"Input 2.0 TB" is the size of files the tasks were &lt;em&gt;assigned&lt;/em&gt;. The bytes actually pulled from S3 were a tiny fraction, because of that empty &lt;code&gt;ReadSchema&lt;/code&gt;. Here's the whole job as a timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; time ──▶
 driver   │plan│sched│                                                        │fetch+sum│→ 3214906117
 exec 1   │    │     │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│         │
 exec 2   │    │     │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│         │
  ...     │    │     │  stage 0: 16,384 tasks, 4 per executor at a time       │         │
 exec 10  │    │     │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│         │
                     ◀──────────────────── ~3.5 min ─────────────────────────▶◀─ 2 s ──▶
                                                                              stage 1 (1 task)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maya now has the mental model that the rest of the story builds on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Action → plans → stages (cut at shuffles) → tasks (cut at file splits) → executors → results.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every remaining chapter changes just one of those arrows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 3: Reading less: filters, pruning and pushdown
&lt;/h2&gt;

&lt;p&gt;Now the ticket. March only, fares over $100, and Maya only needs a few columns:&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;march&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trips&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;dt&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;amp;&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;dt&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;lt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-31&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="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;fare&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;100&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;trip_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;driver_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;zone_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;fare&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;march&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;# 41,882,310
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;9 seconds.&lt;/strong&gt; From 3 minutes 50 to 9 seconds, on the same cluster, with no cache. That's not one optimization, it's three stacked on top of each other, and each one operates at a different granularity. Ravi draws a funnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 16,384 files, 2.0 TB         ← what spark.read "pointed at"
   ┌─────────────────────────────────────────┐
   │ 1. PARTITION PRUNING  (driver, planning)│   dt between 03-01 and 03-31
   └────────────────────┬────────────────────┘   → 31 directories
                        ▼
                  1,364 files, 170 GB
   ┌─────────────────────────────────────────┐
   │ 2. COLUMN PRUNING  (executor, per file) │   read 4 of 18 column chunks
   └────────────────────┬────────────────────┘   → ~38 GB of column data
                        ▼
   ┌─────────────────────────────────────────┐
   │ 3. PREDICATE PUSHDOWN (executor, Parquet│   fare &amp;gt; 100 vs row-group stats,
   │    reader: row groups → pages)          │   dictionaries, page indexes
   └────────────────────┬────────────────────┘   → skip chunks that can't match
                        ▼
   ┌─────────────────────────────────────────┐
   │ 4. ROW FILTER  (executor, Spark)        │   fare &amp;gt; 100 on every row that
   └────────────────────┬────────────────────┘   survived, in generated code
                        ▼
                 41,882,310 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.1 Partition pruning: never open the file
&lt;/h3&gt;

&lt;p&gt;The plan tells the story:&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;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&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;*(2) HashAggregate(keys=[], functions=[count(1)])
+- Exchange SinglePartition, ENSURE_REQUIREMENTS
   +- *(1) HashAggregate(keys=[], functions=[partial_count(1)])
      +- *(1) Project
         +- *(1) Filter (isnotnull(fare) AND (fare &amp;gt; 100.0))
            +- *(1) ColumnarToRow
               +- FileScan parquet [fare,dt]
                    Batched: true, Format: Parquet,
                    Location: InMemoryFileIndex(1 paths)[s3a://ridehub/trips],
                    PartitionFilters: [isnotnull(dt), (dt &amp;gt;= 2026-03-01), (dt &amp;lt;= 2026-03-31)],
                    PushedFilters:    [IsNotNull(fare), GreaterThan(fare,100.0)],
                    DataFilters:      [isnotnull(fare), (fare &amp;gt; 100.0)],
                    ReadSchema: struct&amp;lt;fare:double&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;FileScan&lt;/code&gt; line. The optimizer split Maya's two filters into two very different buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PartitionFilters&lt;/code&gt;&lt;/strong&gt; reference only partition columns. They are evaluated &lt;strong&gt;on the driver, at planning time&lt;/strong&gt;, against the &lt;code&gt;InMemoryFileIndex&lt;/code&gt; from Chapter 1. Spark walks its in-memory list of 365 directory values, keeps the 31 that satisfy &lt;code&gt;dt between ...&lt;/code&gt;, and builds &lt;code&gt;FilePartition&lt;/code&gt;s only from the 1,364 files inside them. The other 14,600 files are never opened, never even mentioned to an executor. &lt;strong&gt;Stage 0 now has 1,364 tasks instead of 16,384.&lt;/strong&gt; No S3 calls are needed for this, because the listing was already cached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DataFilters&lt;/code&gt;&lt;/strong&gt; reference regular columns. They can't eliminate files by name. They're sent to the executors in two forms, which brings us to the next two layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Since the file listing is reused, calling &lt;code&gt;spark.read.parquet&lt;/code&gt; once and filtering many times is cheaper than&lt;br&gt;
calling it once per query. In a notebook, keep the un-filtered DataFrame around.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3.2 Column pruning: open the file, skip most of it
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ReadSchema: struct&amp;lt;fare:double&amp;gt;&lt;/code&gt;. Maya selected five columns, but &lt;code&gt;count()&lt;/code&gt; needs only the one the filter touches, and &lt;code&gt;dt&lt;/code&gt; is a partition column that comes from the path, not from the file. So each task asks the Parquet reader for exactly &lt;strong&gt;one column chunk per row group&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's where Parquet's layout pays off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; One Parquet file (~128 MB)

 ┌───────────────────────────────────────────────────────────────────────────┐
 │ Row group 0  (~1M rows)                                                   │
 │  ┌──────────┬──────────┬─────────┬───────────┬──────────┬─── ... ──────┐ │
 │  │ trip_id  │ driver_id│ zone_id │ pickup_ts │  fare    │  (13 more)   │ │
 │  │ chunk    │ chunk    │ chunk   │ chunk     │  chunk   │              │ │
 │  │ 22 MB    │ 4 MB     │ 1 MB    │ 6 MB      │  3 MB    │              │ │
 │  └──────────┴──────────┴─────────┴───────────┴──────────┴──────────────┘ │
 │    each chunk = pages of ~1 MB, each page: header + (dictionary) + values │
 ├───────────────────────────────────────────────────────────────────────────┤
 │ Row group 1 ...                                                           │
 ├───────────────────────────────────────────────────────────────────────────┤
 │ Footer: schema, and for every chunk: offset, size, num values,            │
 │         min, max, null count, (optional) column index per page            │
 └───────────────────────────────────────────────────────────────────────────┘

 This query reads:  [footer] + [fare chunk of each row group]   ≈ 3 MB per row group
 A row-oriented format (CSV, JSON, Avro) would have to read all 128 MB.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reader seeks straight to the &lt;code&gt;fare&lt;/code&gt; chunk's offset (it got that from the footer), reads ~3 MB, and moves on. The S3 bill for this query is dominated by footers and &lt;code&gt;fare&lt;/code&gt; chunks: roughly 20–25% of the file bytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Predicate pushdown: let Parquet skip row groups and pages
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;PushedFilters: [IsNotNull(fare), GreaterThan(fare,100.0)]&lt;/code&gt;. These are Maya's filters translated into Parquet's own &lt;code&gt;FilterPredicate&lt;/code&gt; API and handed to the Parquet reader (&lt;code&gt;spark.sql.parquet.filterPushdown&lt;/code&gt;, on by default). The reader uses them at three levels, cheapest first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;level&lt;/th&gt;
&lt;th&gt;what Parquet checks&lt;/th&gt;
&lt;th&gt;skips&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Row group statistics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;footer says &lt;code&gt;fare: min=3.10, max=412.00&lt;/code&gt; for this chunk. Can &lt;code&gt;fare &amp;gt; 100&lt;/code&gt; be true? Yes → read. If &lt;code&gt;max&lt;/code&gt; were 95 → skip the whole row group&lt;/td&gt;
&lt;td&gt;whole row groups (~1M rows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dictionary filtering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;for dictionary-encoded chunks, test the predicate against the dictionary entries. No entry &amp;gt; 100 → skip&lt;/td&gt;
&lt;td&gt;whole row groups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Column index&lt;/strong&gt; (page skipping)&lt;/td&gt;
&lt;td&gt;Parquet ≥ 1.11 writes per-page min/max. Spark 3.2+ reads them and skips pages that can't match&lt;/td&gt;
&lt;td&gt;pages (~1 MB)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the catch Ravi wants Maya to notice: fares in a RideHub file are in pickup order, so &lt;strong&gt;every row group has fares from $3 to $400&lt;/strong&gt;. The min/max check never skips anything. The pushdown is correct and free, but it's not doing work &lt;em&gt;for this data layout&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is why "pushdown is enabled" and "pushdown is effective" are different statements, and it's where &lt;strong&gt;writing&lt;/strong&gt; matters as much as reading. If the files had been written with &lt;code&gt;sortWithinPartitions("fare")&lt;/code&gt;, each row group would cover a narrow fare range, and the reader would skip 80% of them. Delta's &lt;code&gt;ZORDER&lt;/code&gt; and Iceberg's sort orders exist for exactly this reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Why there's still a &lt;code&gt;Filter&lt;/code&gt; operator
&lt;/h3&gt;

&lt;p&gt;Maya spots it: &lt;code&gt;*(1) Filter (isnotnull(fare) AND (fare &amp;gt; 100.0))&lt;/code&gt; sits &lt;em&gt;above&lt;/em&gt; the scan, even though the same predicate was pushed &lt;em&gt;into&lt;/em&gt; the scan. Isn't that double work?&lt;/p&gt;

&lt;p&gt;It's deliberate. Statistics, dictionaries and page indexes all work at &lt;strong&gt;chunk granularity&lt;/strong&gt;: they can prove that a chunk contains no match, but a chunk that survives still contains rows that don't match (the $40 fares next to the $140 ones). The vectorized Parquet reader does not evaluate predicates per row, so Spark keeps its own row-level &lt;code&gt;Filter&lt;/code&gt; to finish the job. That operator runs inside the same codegen stage (&lt;code&gt;*(1)&lt;/code&gt;), one tight loop over the column vectors, so its cost is small.&lt;/p&gt;

&lt;p&gt;Where each filter form actually runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌──────────────┬────────────────────┬────────────────┬──────────────────────────┐
 │ form         │ where              │ when           │ granularity              │
 ├──────────────┼────────────────────┼────────────────┼──────────────────────────┤
 │ Partition    │ DRIVER             │ planning       │ directory / file         │
 │ filter       │ (FileIndex)        │                │ never opens the file     │
 ├──────────────┼────────────────────┼────────────────┼──────────────────────────┤
 │ Pushed       │ EXECUTOR           │ while reading  │ row group, page          │
 │ filter       │ (Parquet reader)   │                │ via min/max, dictionary  │
 ├──────────────┼────────────────────┼────────────────┼──────────────────────────┤
 │ Data filter  │ EXECUTOR           │ after reading  │ row                      │
 │ (Filter op)  │ (generated code)   │                │ final correctness check  │
 └──────────────┴────────────────────┴────────────────┴──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.5 The Spark UI after pruning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQL / DataFrame tab → query 2
  Scan parquet:  number of files read 1,364          (was 16,384)
                 number of partitions read 31        (dt values)
                 size of files read 170.5 GB
                 dynamic partition pruning time 0 ms
                 number of output rows 273,015,992   ← rows that left the scan
  Filter:        number of output rows 41,882,310    ← rows that passed fare &amp;gt; 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gap between the scan's output rows (273 M) and the filter's output rows (42 M) is the pushdown &lt;em&gt;not&lt;/em&gt; helping: 85% of the rows the reader produced were thrown away by the row-level filter. On a &lt;code&gt;fare&lt;/code&gt;-sorted layout, that first number would have been close to the second.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.6 The five ways to lose pruning by accident
&lt;/h3&gt;

&lt;p&gt;Each of these looks harmless and quietly turns the 9-second query back into a 4-minute one. The tell is always in &lt;code&gt;explain()&lt;/code&gt;. The first one is exactly what Maya's Monday code did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Filtering the wrong column (Maya's Monday mistake)&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="c1"&gt;# ✗ pickup_ts is a data column, not the partition column.
#   No partition pruning, and the month() wrapper means no pushdown either:
#   every row group of every file is decoded just to test the predicate.
&lt;/span&gt;&lt;span class="n"&gt;trips&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;year&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_ts&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="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;month&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_ts&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;#   PartitionFilters: []
#   PushedFilters:    []
#   DataFilters:      [(year(pickup_ts) = 2026), (month(pickup_ts) = 3)]
&lt;/span&gt;
&lt;span class="c1"&gt;# ✓ filter on the partition column, as a plain range
&lt;/span&gt;&lt;span class="n"&gt;trips&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;2026-03-31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;#   PartitionFilters: [isnotnull(dt), (dt &amp;gt;= 2026-03-01), (dt &amp;lt;= 2026-03-31)]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two columns mean the same thing to a human and completely different things to the file index. (A function &lt;em&gt;around the partition column itself&lt;/em&gt;, like &lt;code&gt;F.month("dt") == 3&lt;/code&gt;, still prunes on a plain file source: Spark evaluates it against the 365 directory values on the driver. Hive-metastore tables are less forgiving, because only simple comparisons can be pushed to the metastore.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A function around a data column&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="c1"&gt;# ✗ nothing pushed: Parquet has no stats for round(fare)
&lt;/span&gt;&lt;span class="n"&gt;trips&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fare&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;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;#   PushedFilters: []
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. A type mismatch that casts the column instead of the literal&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="c1"&gt;# driver_id is a long. Comparing it to a string makes Spark cast the COLUMN.
&lt;/span&gt;&lt;span class="n"&gt;trips&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;driver_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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4471&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;#   DataFilters: [(cast(driver_id as string) = 4471)]   PushedFilters: []
&lt;/span&gt;
&lt;span class="c1"&gt;# ✓ compare like with like
&lt;/span&gt;&lt;span class="n"&gt;trips&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;driver_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="mi"&gt;4471&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;#   PushedFilters: [IsNotNull(driver_id), EqualTo(driver_id,4471)]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. A Python UDF in the filter&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="nd"&gt;@F.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;boolean&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;is_premium&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fare&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;fare&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fare&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="n"&gt;trips&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;is_premium&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fare&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 plaintext"&gt;&lt;code&gt;*(2) Project
+- *(2) Filter pythonUDF0: boolean
   +- BatchEvalPython [is_premium(fare)]      ← rows leave the JVM, go to a Python
      +- *(1) ColumnarToRow                      worker process, and come back
         +- FileScan parquet [fare] PushedFilters: []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Catalyst can't see inside a Python function, so it pushes nothing and ships every row through a Python worker. The same filter as a native expression stays in the JVM, pushes down, and is 50–100× faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A &lt;code&gt;LIKE '%...%'&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;startswith&lt;/code&gt; becomes &lt;code&gt;StringStartsWith&lt;/code&gt; and is pushed; a &lt;code&gt;contains&lt;/code&gt; / &lt;code&gt;'%x%'&lt;/code&gt; can't use min/max statistics and isn't. Not a bug, just physics: there's no cheap way to prove a chunk contains no substring.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Rule of thumb.&lt;/strong&gt; After writing a filter, run &lt;code&gt;.explain()&lt;/code&gt; and read the &lt;code&gt;FileScan&lt;/code&gt; line. If the predicate you&lt;br&gt;
wrote isn't in &lt;code&gt;PartitionFilters&lt;/code&gt; or &lt;code&gt;PushedFilters&lt;/code&gt;, Spark is doing it the hard way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Maya adds a line to her notes: &lt;em&gt;"The filter I write is a wish. The FileScan line is what Spark actually does."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 4: Putting things in order: sort and shuffle
&lt;/h2&gt;

&lt;p&gt;Finance asked for a ranked report, so Maya tries the obvious thing on her March subset and writes it out:&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;march&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;fare&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;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;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;s3a://ridehub/scratch/march_sorted/&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;It takes &lt;strong&gt;2 minutes 10 seconds&lt;/strong&gt;. The count on the same data took 9. The Spark UI shows something new: &lt;strong&gt;three jobs for one action&lt;/strong&gt;, the first of which she never asked for, and 1.6 GB of shuffle between the last two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jobs
 Job 3   parquet at &amp;lt;cell&amp;gt;:3   (RangePartitioner sampling)   1,364 tasks                           11 s
 Job 4   parquet at &amp;lt;cell&amp;gt;:3   Stage 3   1364 tasks   Input 170 GB   Shuffle Write 1.6 GB      1.6 min
 Job 5   parquet at &amp;lt;cell&amp;gt;:3   Stage 4     25 tasks   Shuffle Read 1.6 GB   Output 25 files      14 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.1 What "sorted" means on a cluster
&lt;/h3&gt;

&lt;p&gt;On one machine, sorting is one algorithm. On a cluster, &lt;em&gt;globally&lt;/em&gt; sorted output means: every row in output file 1 is greater than every row in file 2, which is greater than every row in file 3, and each file is sorted internally. To arrange that, Spark has to decide &lt;strong&gt;which rows go to which file before it can sort anything&lt;/strong&gt;. That is a &lt;strong&gt;range partitioning&lt;/strong&gt; problem, and it needs to know the distribution of &lt;code&gt;fare&lt;/code&gt; to pick good boundaries. The plan shows both halves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*(2) Sort [fare DESC NULLS LAST], true, 0                              ← local sort, per partition
+- AQEShuffleRead coalesced
   +- ShuffleQueryStage 0
      +- Exchange rangepartitioning(fare DESC NULLS LAST, 200), ENSURE_REQUIREMENTS   ← the shuffle
         +- *(1) Project [trip_id, driver_id, zone_id, fare, dt]
            +- *(1) Filter (isnotnull(fare) AND (fare &amp;gt; 100.0))
               +- *(1) ColumnarToRow
                  +- FileScan parquet [trip_id,driver_id,zone_id,fare,dt] ... (same pruning as before)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Sort [...], true, 0&lt;/code&gt;: the &lt;code&gt;true&lt;/code&gt; means &lt;em&gt;global&lt;/em&gt;. The &lt;code&gt;Exchange rangepartitioning(..., 200)&lt;/code&gt; is what makes global possible, and 200 is &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt;, the default number of partitions after any shuffle.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Job 3: the sampling pass nobody asked for
&lt;/h3&gt;

&lt;p&gt;Before Spark can shuffle by range it needs the range boundaries. The &lt;code&gt;RangePartitioner&lt;/code&gt; gets them by &lt;strong&gt;sampling the input&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;target sample: &lt;code&gt;100 × 200 partitions = 20,000&lt;/code&gt; rows (&lt;code&gt;spark.sql.execution.rangeExchange.sampleSizePerPartition&lt;/code&gt; = 100),&lt;/li&gt;
&lt;li&gt;spread across the 1,364 input partitions, so each task reservoir-samples about &lt;strong&gt;44 rows&lt;/strong&gt; (Spark asks for &lt;code&gt;3 ×&lt;/code&gt; the target to tolerate uneven partitions),&lt;/li&gt;
&lt;li&gt;the samples are collected to the driver, sorted, and 199 boundary values are chosen so that each of the 200 ranges should get roughly 1/200 of the rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sampling is a real job over the real input: it opens the 1,364 files, applies the partition pruning, column pruning and the &lt;code&gt;fare &amp;gt; 100&lt;/code&gt; filter, and keeps a few dozen rows per task. On this data it costs 11 seconds, about the price of the &lt;code&gt;count()&lt;/code&gt;. &lt;strong&gt;A global sort reads its input twice.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          sampling job (Job 3)                         boundaries on the driver

  task 0  ─▶ 44 random fares ─┐
  task 1  ─▶ 44 random fares ─┤                sorted sample: 100.2 ... 412.0
   ...                        ├─▶ collect() ─▶ cut into 200 ranges:
  task 1363 ▶ 44 random fares ┘                p0: fare ≥ 398.1
                                               p1: 391.6 ≤ fare &amp;lt; 398.1
                                               ...
                                               p199: fare &amp;lt; 101.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.3 Stage 3: the shuffle write (map side)
&lt;/h3&gt;

&lt;p&gt;Now the main job. Each of the 1,364 map tasks reads its files (with all the pruning from Chapter 3), and for every surviving row:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;picks a destination partition&lt;/strong&gt; by binary-searching the 199 boundaries (&lt;code&gt;fare = 215.40&lt;/code&gt; → partition 87),&lt;/li&gt;
&lt;li&gt;hands the row to the &lt;strong&gt;shuffle writer&lt;/strong&gt;. Because Spark SQL rows are already in a compact binary format (&lt;code&gt;UnsafeRow&lt;/code&gt;), and there's no map-side aggregation, it uses the &lt;code&gt;UnsafeShuffleWriter&lt;/code&gt;: rows are serialized into memory pages and the sorter only moves 8-byte &lt;em&gt;pointers&lt;/em&gt; tagged with the partition ID. When memory runs low it spills sorted runs to local disk,&lt;/li&gt;
&lt;li&gt;at the end, merges the runs into &lt;strong&gt;exactly one data file and one index file&lt;/strong&gt; per task. The index says where each of the 200 partitions starts inside the data file.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; map task 812 output, on executor 3's local disk

 shuffle_0_812_0.data                                  shuffle_0_812_0.index
 ┌────────┬────────┬────────┬─────────┬────────┐       ┌────────────────────┐
 │ p0     │ p1     │ p2     │  ...    │ p199   │       │ offset of p0 = 0   │
 │ 4 KB   │ 6 KB   │ 5 KB   │         │ 7 KB   │  ◀──  │ offset of p1 = 4096│
 └────────┴────────┴────────┴─────────┴────────┘       │ ...                │
   rows with fare ≥ 398.1  │                           └────────────────────┘
                           └─ 391.6 ≤ fare &amp;lt; 398.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task reports a &lt;code&gt;MapStatus&lt;/code&gt; to the driver: "my output lives on executor 3, and partition &lt;em&gt;i&lt;/em&gt; is &lt;em&gt;n&lt;/em&gt; bytes" for all 200 values of &lt;em&gt;i&lt;/em&gt;. That's compressed to a few bytes per partition, but with 1,364 maps × 200 reduces the driver is tracking &lt;strong&gt;272,800 blocks&lt;/strong&gt; for this one shuffle.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.4 Stage 4: fetch, sort, write (reduce side)
&lt;/h3&gt;

&lt;p&gt;Reduce task &lt;em&gt;i&lt;/em&gt; now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;asks the &lt;code&gt;MapOutputTracker&lt;/code&gt; for the locations of block &lt;em&gt;i&lt;/em&gt; from every one of the 1,364 map outputs,&lt;/li&gt;
&lt;li&gt;fetches them with the &lt;code&gt;ShuffleBlockFetcherIterator&lt;/code&gt;, keeping up to &lt;code&gt;spark.reducer.maxSizeInFlight&lt;/code&gt; (48 MB) of requests outstanding, from the executors' block managers (or from the external shuffle service, if one is running so that executors can be removed without losing their shuffle files),&lt;/li&gt;
&lt;li&gt;runs &lt;code&gt;SortExec&lt;/code&gt;: an &lt;code&gt;UnsafeExternalSorter&lt;/code&gt; sorts rows in memory using an 8-byte &lt;strong&gt;prefix&lt;/strong&gt; of the sort key for fast comparisons, and spills to disk if execution memory (its share of the unified &lt;code&gt;spark.memory.fraction&lt;/code&gt; = 60% of heap) runs out,&lt;/li&gt;
&lt;li&gt;writes its partition as one Parquet file.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; shuffle = every reduce talks to every map

   map 0    map 1    map 2    ...   map 1363          1,364 map outputs
    │ │ │    │ │ │    │ │ │          │ │ │
    │ │ └────┼─┼─┼────┼─┼─┼──────────┼─┼─┘ ─────▶ reduce 199  (fare &amp;lt; 101.3)
    │ └──────┼─┼──────┼─┼────────────┼─┘   ─────▶ reduce 1
    └────────┼────────┼──────────────┘     ─────▶ reduce 0    (fare ≥ 398.1)
             ...                                  200 reduce tasks
                                                  each sorts its own range
                                                  → output is globally sorted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.5 Where did the 200 go? (AQE coalescing)
&lt;/h3&gt;

&lt;p&gt;The plan said 200 partitions, the UI shows &lt;strong&gt;25 tasks&lt;/strong&gt; in Stage 4. That's &lt;code&gt;AQEShuffleRead coalesced&lt;/code&gt;. When Stage 3 finished, AQE looked at the real &lt;code&gt;MapStatus&lt;/code&gt; sizes, saw that 1.6 GB split 200 ways is 8 MB per reducer, and merged adjacent ranges until each was close to &lt;code&gt;spark.sql.adaptive.advisoryPartitionSizeInBytes&lt;/code&gt; (64 MB). Because the ranges are contiguous, merging neighbours keeps the output globally sorted. Without AQE, Maya would have 200 tiny files and 200 tasks that spend more time fetching than sorting. With it, 25 files of ~64 MB. This is the single most useful thing AQE does, and it's why nobody tunes &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; by hand any more for most jobs.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.6 The twist: the top 10 took 10 seconds
&lt;/h3&gt;

&lt;p&gt;Maya only needs the top 10 for the ticket, so she tries:&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;march&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;fare&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="mi"&gt;10&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;10 seconds.&lt;/strong&gt; No sampling job. No shuffle. The plan has a different operator entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TakeOrderedAndProject(limit=11, orderBy=[fare DESC NULLS LAST], output=[trip_id,...])
+- *(1) Project ...
   +- *(1) Filter (isnotnull(fare) AND (fare &amp;gt; 100.0))
      +- *(1) ColumnarToRow
         +- FileScan parquet ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The optimizer saw &lt;code&gt;Sort&lt;/code&gt; directly under &lt;code&gt;Limit&lt;/code&gt; and replaced both with &lt;code&gt;TakeOrderedAndProject&lt;/code&gt;. Each of the 1,364 tasks keeps a bounded priority queue of the 11 largest fares it sees (11, not 10: &lt;code&gt;show&lt;/code&gt; asks for one extra row to know whether to print "only showing top 10 rows"), sends those 11 rows to the driver, and the driver merges 1,364 × 11 rows. One stage, no shuffle, a few hundred KB over the network.&lt;/p&gt;

&lt;p&gt;The same rewrite happens for &lt;code&gt;.orderBy().limit(n)&lt;/code&gt; followed by a write or a &lt;code&gt;collect()&lt;/code&gt;. It's a reminder that &lt;strong&gt;the physical plan depends on the whole tree&lt;/strong&gt;, not on the last thing you typed. &lt;code&gt;orderBy&lt;/code&gt; alone means a sampling job plus a full shuffle; &lt;code&gt;orderBy&lt;/code&gt; + &lt;code&gt;limit&lt;/code&gt; means a map-only top-k.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.7 Three sorts, three costs
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;you write&lt;/th&gt;
&lt;th&gt;physical plan&lt;/th&gt;
&lt;th&gt;shuffle?&lt;/th&gt;
&lt;th&gt;when to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderBy(c)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Exchange rangepartitioning&lt;/code&gt; + &lt;code&gt;Sort global=true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;yes&lt;/strong&gt;, plus a sampling job&lt;/td&gt;
&lt;td&gt;you need globally ordered &lt;em&gt;output&lt;/em&gt; (a report someone reads top to bottom, a sorted export)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sortWithinPartitions(c)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sort global=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;you want each output file sorted, usually so that Parquet min/max stats become useful (Chapter 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderBy(c).limit(n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TakeOrderedAndProject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;top-N / bottom-N&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repartitionByRange(n, c).sortWithinPartitions(c)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Exchange rangepartitioning&lt;/code&gt; + &lt;code&gt;Sort global=false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;the explicit form of a global sort with control over &lt;code&gt;n&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Maya's note for the day: &lt;em&gt;"&lt;code&gt;orderBy&lt;/code&gt; is the most expensive word in the API. Say &lt;code&gt;limit&lt;/code&gt; right after it, or mean it."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 5: Meeting other tables: joins
&lt;/h2&gt;

&lt;p&gt;The ticket wants names, not IDs. Two joins: &lt;code&gt;zones&lt;/code&gt; (20 KB) and &lt;code&gt;drivers&lt;/code&gt; (50 MB). Spark will treat them completely differently, and the reason is one number: &lt;strong&gt;&lt;code&gt;spark.sql.autoBroadcastJoinThreshold&lt;/code&gt; = 10 MB&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 The zones join: broadcast hash join
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;with_zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zone_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;with_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&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;*(2) Project [trip_id, driver_id, fare, dt, zone_name, borough]
+- *(2) BroadcastHashJoin [zone_id], [zone_id], Inner, BuildRight, false
   :- *(2) Filter ((isnotnull(fare) AND (fare &amp;gt; 100.0)) AND isnotnull(zone_id))
   :  +- *(2) ColumnarToRow
   :     +- FileScan parquet [trip_id,driver_id,zone_id,fare,dt] ...    ← trips: 1,364 tasks
   +- BroadcastExchange HashedRelationBroadcastMode(List(cast(zone_id as bigint))), [plan_id=…]
      +- *(1) Filter isnotnull(zone_id)
         +- *(1) ColumnarToRow
            +- FileScan parquet [zone_id,zone_name,borough] ...        ← zones: 1 task
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At planning time Spark asks each side for &lt;code&gt;stats.sizeInBytes&lt;/code&gt;. For a file source that's simply the &lt;strong&gt;sum of file sizes&lt;/strong&gt; (times &lt;code&gt;spark.sql.sources.fileCompressionFactor&lt;/code&gt;, default 1.0). Zones is 20 KB, far under 10 MB, so the planner picks a &lt;strong&gt;BroadcastHashJoin&lt;/strong&gt; with zones as the &lt;em&gt;build&lt;/em&gt; side. Notice there's &lt;strong&gt;no &lt;code&gt;Exchange&lt;/code&gt; on the trips side&lt;/strong&gt;. The 170 GB never moves.&lt;/p&gt;

&lt;p&gt;What happens at runtime, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; DRIVER                                            EXECUTORS (×10)
 ┌───────────────────────────────┐
 │ 1. BroadcastExchange starts   │
 │    on a background thread     │
 │ 2. runs a small job: scan     │ ◀── 1 task reads zones.parquet, returns 263 rows
 │    zones, collect() rows      │
 │ 3. builds the hash table      │
 │    HERE, on the driver:       │
 │    LongHashedRelation         │
 │    (zone_id → row)            │
 │ 4. sc.broadcast(relation)     │
 │    TorrentBroadcast: split    │ ──▶ 5. first task on each executor fetches the
 │    into 4 MB blocks, store in │      blocks (from the driver, then from peers,
 │    driver BlockManager        │      BitTorrent-style), caches the relation
 └───────────────────────────────┘      in its BlockManager; all 4 cores share it
                                        6. every trips task: for each row, probe
                                           the hash table by zone_id → join row
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1–4 are the extra "broadcast exchange" job you see in the UI, running concurrently with the main job's planning. The trips stage is then &lt;strong&gt;map-only&lt;/strong&gt;: 1,364 tasks, each probing an in-memory hash table inside the generated code. The join costs about as much as a filter.&lt;/p&gt;

&lt;p&gt;Three facts about broadcasts that bite later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hash table is built on the driver.&lt;/strong&gt; A "small" table that's 10 MB on disk as Parquet can be 100 MB as Java objects, and it's collected into driver memory first. That's why the default threshold is a conservative 10 MB and why bumping it to 1 GB "because the executors have RAM" is a classic way to OOM the driver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard limits:&lt;/strong&gt; a broadcast relation can't exceed &lt;strong&gt;8 GB&lt;/strong&gt; or &lt;strong&gt;512 million rows&lt;/strong&gt;; Spark throws rather than tries. And the build must finish within &lt;code&gt;spark.sql.broadcastTimeout&lt;/code&gt; (300 s), or you get the infamous &lt;em&gt;"Could not execute broadcast in 300 secs"&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can force or forbid it.&lt;/strong&gt; &lt;code&gt;F.broadcast(df)&lt;/code&gt; or the SQL hint &lt;code&gt;/*+ BROADCAST(z) */&lt;/code&gt; overrides the size estimate; &lt;code&gt;spark.sql.autoBroadcastJoinThreshold = -1&lt;/code&gt; disables automatic broadcasting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5.2 The drivers join: sort-merge join
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;with_driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;with_driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&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;*(5) Project [trip_id, zone_id, fare, dt, driver_name]
+- *(5) SortMergeJoin [driver_id], [driver_id], Inner
   :- *(2) Sort [driver_id ASC NULLS FIRST], false, 0
   :  +- Exchange hashpartitioning(driver_id, 200), ENSURE_REQUIREMENTS       ← shuffle trips
   :     +- *(1) Filter (... AND isnotnull(driver_id))
   :        +- *(1) ColumnarToRow
   :           +- FileScan parquet [trip_id,driver_id,zone_id,fare,dt] ...
   +- *(4) Sort [driver_id ASC NULLS FIRST], false, 0
      +- Exchange hashpartitioning(driver_id, 200), ENSURE_REQUIREMENTS       ← shuffle drivers
         +- *(3) Filter isnotnull(driver_id)
            +- *(3) ColumnarToRow
               +- FileScan parquet [driver_id,driver_name] ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drivers is 50 MB on disk. That's above the threshold, so the planner falls back to the default for large equi-joins: &lt;strong&gt;SortMergeJoin&lt;/strong&gt;. And a sort-merge join has a requirement that &lt;code&gt;EnsureRequirements&lt;/code&gt; enforces by inserting two &lt;code&gt;Exchange&lt;/code&gt;s: &lt;em&gt;both&lt;/em&gt; inputs must be &lt;strong&gt;hash-partitioned on the join key, with the same number of partitions&lt;/strong&gt;, and sorted by that key within each partition.&lt;/p&gt;

&lt;p&gt;The trick that makes this work is &lt;strong&gt;co-partitioning&lt;/strong&gt;. Both sides use the same function, &lt;code&gt;hash(driver_id) mod 200&lt;/code&gt;, so every trip with &lt;code&gt;driver_id = 4471&lt;/code&gt; and the one driver row with &lt;code&gt;driver_id = 4471&lt;/code&gt; land in the same partition number, which means the same reduce task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   trips (1,364 map tasks)                  drivers (1 map task)
   ┌──────┬──────┬──────┬───┐               ┌──────┬──────┬──────┬───┐
   │ p0   │ p1   │ p2   │...│               │ p0   │ p1   │ p2   │...│
   └──┬───┴──┬───┴──┬───┴───┘               └──┬───┴──┬───┴──┬───┴───┘
      │      │      │      hash(driver_id) mod 200 │      │      │
      ▼      │      │                              ▼      │      │
   ┌────────────────────────────────────────────────────────────────┐
   │ reduce task 0:  sort trips p0 by driver_id                     │
   │                 sort drivers p0 by driver_id                   │
   │                 walk both with two pointers, emit matches      │
   └────────────────────────────────────────────────────────────────┘
      ... × 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stage graph now has a shape Maya hasn't seen: &lt;strong&gt;two independent map stages that run in parallel&lt;/strong&gt;, then a reduce stage that depends on both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Stage 5: scan trips → filter → shuffle write (1,364 tasks, 600 MB written)
   Stage 6: scan drivers        → shuffle write (1 task, 8 MB written)
            │                                     │
            └────────────────┬────────────────────┘
                             ▼
   Stage 7: fetch both, sort both, merge-join (200 tasks → AQE coalesces)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every byte of the filtered trips (600 MB after column pruning and the fare filter) crosses the network, and then gets sorted. On the unfiltered dataset it would have been about 19 GB.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 AQE changes its mind at runtime
&lt;/h3&gt;

&lt;p&gt;Then something interesting happens. Maya runs it, opens the SQL tab, and the &lt;strong&gt;final&lt;/strong&gt; plan isn't a sort-merge join at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdaptiveSparkPlan isFinalPlan=true
+- *(3) Project [...]
   +- *(3) BroadcastHashJoin [driver_id], [driver_id], Inner, BuildRight, false
      :- AQEShuffleRead local                                  ← no network for trips
      :  +- ShuffleQueryStage 0
      :     +- Exchange hashpartitioning(driver_id, 200)
      :        +- ... FileScan parquet trips
      +- BroadcastQueryStage 2
         +- BroadcastExchange HashedRelationBroadcastMode(List(driver_id))
            +- AQEShuffleRead local
               +- ShuffleQueryStage 1
                  +- Exchange hashpartitioning(driver_id, 200)
                     +- ... FileScan parquet drivers [driver_id,driver_name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AQE executes the plan &lt;strong&gt;one shuffle stage at a time&lt;/strong&gt;, leaf-first. Stages 5 and 6 are both submitted.&lt;/li&gt;
&lt;li&gt;Stage 6 (drivers) finishes almost immediately. Its &lt;code&gt;MapStatus&lt;/code&gt;es say the shuffle output is &lt;strong&gt;8 MB&lt;/strong&gt;: after column pruning (&lt;code&gt;driver_id, driver_name&lt;/code&gt;, not the 18 columns on disk) and compression, drivers is a lot smaller than its 50 MB file size suggested.&lt;/li&gt;
&lt;li&gt;8 MB is under &lt;code&gt;spark.sql.adaptive.autoBroadcastJoinThreshold&lt;/code&gt; (defaults to the same 10 MB). AQE &lt;strong&gt;re-plans the join as a BroadcastHashJoin&lt;/strong&gt;, reading the drivers side from its shuffle output and broadcasting it.&lt;/li&gt;
&lt;li&gt;The trips side is awkward: its shuffle write is already running. Spark can't un-write those files, but it can avoid the expensive half of a shuffle. &lt;code&gt;AQEShuffleRead local&lt;/code&gt; means each reduce task reads &lt;strong&gt;only the map outputs on its own executor&lt;/strong&gt;, with no network transfer, no sort, and no 272,800-block fetch plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the join ended up costing one cheap shuffle write and a broadcast. Two lessons Ravi draws on the whiteboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Planning-time size estimates are about files; runtime sizes are about what's left.&lt;/strong&gt; The planner saw 50 MB of Parquet. The runtime saw 8 MB of two columns. AQE exists to close that gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AQE can't undo work already started.&lt;/strong&gt; If Maya had written &lt;code&gt;F.broadcast(drivers)&lt;/code&gt; herself, the trips side would never have been shuffled at all. When you &lt;em&gt;know&lt;/em&gt; a side is small, say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5.4 What if drivers had been 5 GB? (skew)
&lt;/h3&gt;

&lt;p&gt;Ravi's thought experiment: imagine &lt;code&gt;drivers&lt;/code&gt; were too big to broadcast and the sort-merge join stuck. Now imagine, as is true at RideHub, that 35% of March trips have &lt;code&gt;driver_id = -1&lt;/code&gt;, the placeholder for "driver unknown". Hash partitioning sends every one of those 14 million rows to &lt;strong&gt;one&lt;/strong&gt; reduce task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; reduce task sizes (bytes of shuffle read)

 p0   ████                                           8 MB
 p1   ███                                            7 MB
 ...
 p117 ████████████████████████████████████████████ 290 MB   ← hash(-1) mod 200
 ...
 p199 ████                                           8 MB

 199 tasks finish in 4 s. Task 117 runs for 6 minutes. The stage takes 6 minutes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;strong&gt;data skew&lt;/strong&gt;, and it's the most common reason a Spark job has one task that won't finish. AQE has a rule for it, &lt;code&gt;spark.sql.adaptive.skewJoin.enabled&lt;/code&gt; (on by default): after the map stages finish, any partition that is &lt;strong&gt;both&lt;/strong&gt; larger than &lt;code&gt;skewedPartitionFactor&lt;/code&gt; (5) × the median &lt;strong&gt;and&lt;/strong&gt; larger than &lt;code&gt;skewedPartitionThresholdInBytes&lt;/code&gt; (256 MB) is split into several reader tasks, each covering a slice of the map outputs. The other side's matching partition is read once by each slice, so every slice still sees all the rows it could match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   before                              after AQE skew handling
   reduce 117: trips p117 (290 MB)     reduce 117a: trips p117 maps 0–340    ⋈ drivers p117
               ⋈ drivers p117          reduce 117b: trips p117 maps 341–680  ⋈ drivers p117
                                       reduce 117c: trips p117 maps 681–1020 ⋈ drivers p117
                                       reduce 117d: trips p117 maps 1021–1363⋈ drivers p117
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan marks it &lt;code&gt;SortMergeJoin ... isSkew=true&lt;/code&gt; with &lt;code&gt;AQEShuffleRead skewed&lt;/code&gt;. Before AQE, you fixed this by hand with &lt;strong&gt;salting&lt;/strong&gt;: append a random suffix to the hot key on the big side, explode the small side across all suffixes, join on the salted key. Still useful for aggregations, which AQE's skew rule doesn't cover.&lt;/p&gt;

&lt;p&gt;And notice: a &lt;strong&gt;broadcast join has no skew problem at all&lt;/strong&gt;, because the big side is never partitioned by the key. Another reason to broadcast when you can.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.5 Dynamic partition pruning: the join that prunes the scan
&lt;/h3&gt;

&lt;p&gt;One more join, because it's the one that surprises people. Finance's follow-up: "same report, but only for public holidays". Holidays are a tiny table of dates:&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;us_holidays&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;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;s3a://ridehub/holidays/&lt;/span&gt;&lt;span class="sh"&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="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;country&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;US&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;trips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;us_holidays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dt&lt;/span&gt;&lt;span class="sh"&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="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;fare&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;100&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no literal &lt;code&gt;dt&lt;/code&gt; filter on trips, so by the rules of Chapter 3 Spark should scan all 365 partitions, then join. Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*(2) HashAggregate(...)
+- ...
   +- *(2) BroadcastHashJoin [dt], [dt], Inner, BuildRight
      :- *(2) Filter (isnotnull(fare) AND (fare &amp;gt; 100.0))
      :  +- *(2) ColumnarToRow
      :     +- FileScan parquet [fare,dt]
      :          PartitionFilters: [isnotnull(dt), dynamicpruningexpression(dt IN dynamicpruning#88)]
      :             +- SubqueryBroadcast dynamicpruning#88, 0, [dt]
      :                +- BroadcastExchange HashedRelationBroadcastMode(List(dt))
      :                   +- ... FileScan parquet holidays [dt] PushedFilters: [EqualTo(country,US)]
      +- BroadcastExchange ... (reused)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dynamicpruningexpression(dt IN dynamicpruning#88)&lt;/code&gt;: a &lt;strong&gt;partition filter whose value list isn't known until runtime&lt;/strong&gt;. This is &lt;strong&gt;Dynamic Partition Pruning&lt;/strong&gt; (Spark 3.0+, &lt;code&gt;spark.sql.optimizer.dynamicPartitionPruning.enabled&lt;/code&gt;). Spark runs the holidays side first (it was going to broadcast it for the join anyway, so the result is reused), collects the 11 distinct &lt;code&gt;dt&lt;/code&gt; values, and &lt;em&gt;then&lt;/em&gt; decides which trips files to list into tasks. 11 partitions instead of 365; &lt;strong&gt;484 tasks instead of 16,384&lt;/strong&gt;. The SQL tab even has a metric for it: &lt;code&gt;dynamic partition pruning time&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The conditions: the join key must be a &lt;strong&gt;partition column&lt;/strong&gt; of the big side, the small side must have a filter that makes it selective, and the small side must be broadcastable (or Spark must judge the pruning worth a separate subquery). Star-schema fact tables partitioned by date, joined to a filtered dimension, hit this constantly, and it's why "filter the dimension, join the fact" can be as fast as filtering the fact directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.6 How Spark picks a join
&lt;/h3&gt;

&lt;p&gt;Putting the chapter together, this is the decision the planner makes, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; is there a join hint?  ──yes──▶ obey it (BROADCAST / MERGE / SHUFFLE_HASH / SHUFFLE_REPLICATE_NL)
        │ no
        ▼
 equi-join (keys compared with =)?
        │ yes                                        │ no
        ▼                                            ▼
 one side ≤ autoBroadcastJoinThreshold?       one side broadcastable?
   yes → BroadcastHashJoin (map-only)           yes → BroadcastNestedLoopJoin
   no  ↓                                        no  → CartesianProduct (and a warning
 preferSortMergeJoin = false and                      about your life choices)
 one side much smaller than the other?
   yes → ShuffledHashJoin  (shuffle both, hash the small side, no sort)
   no  → SortMergeJoin     (shuffle both, sort both, merge)
        ↓ at runtime, AQE may still:
          • convert SMJ → BHJ if a shuffled side turns out small
          • split skewed partitions
          • coalesce small partitions
&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;strategy&lt;/th&gt;
&lt;th&gt;shuffles the big side?&lt;/th&gt;
&lt;th&gt;memory risk&lt;/th&gt;
&lt;th&gt;shines when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BroadcastHashJoin&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;driver + one copy per executor&lt;/td&gt;
&lt;td&gt;one side fits in memory (≲ tens of MB on disk)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ShuffledHashJoin&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;one partition of the small side per task&lt;/td&gt;
&lt;td&gt;both large, one clearly smaller, keys not sortable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SortMergeJoin&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;spills gracefully&lt;/td&gt;
&lt;td&gt;both large, the safe default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BroadcastNestedLoopJoin&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;O(n × m) CPU&lt;/td&gt;
&lt;td&gt;non-equi joins (&lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;) with a small side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Maya's note: &lt;em&gt;"A join is a question about sizes. Answer it before Spark guesses."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 6: The whole pipeline, end to end
&lt;/h2&gt;

&lt;p&gt;It's Wednesday afternoon. Maya rewrites FIN-2231 with everything from the week:&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;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trips&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;2026-03-31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 1. prunable: a plain range on the partition column
&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;fare&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;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="c1"&gt;# 2. native expression → pushed to Parquet
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zone_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;# 3. tiny: say so
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;# 4. small after pruning: say so, skip the shuffle
&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;driver_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;zone_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;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;fare&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_fare&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&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;trips&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;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;total_fare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                              &lt;span class="c1"&gt;# 5. followed by limit → top-k, no sort shuffle
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;report&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;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;s3a://ridehub/reports/fin-2231/&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;41 seconds.&lt;/strong&gt; Same cluster, same data, same answer. Before she ships it, Ravi makes her read the final plan out loud, bottom to top.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 The final plan, annotated
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&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;AdaptiveSparkPlan isFinalPlan=true
+- TakeOrderedAndProject(limit=10, orderBy=[total_fare DESC NULLS LAST],          ⑧ top-10, no range shuffle
     output=[driver_name, zone_name, total_fare, trips])
   +- *(3) HashAggregate(keys=[driver_name, zone_name],                           ⑦ final sum/count
            functions=[sum(fare), count(1)])
      +- AQEShuffleRead coalesced                                                 ⑥ 200 → 1 partition (62 MB)
         +- ShuffleQueryStage 2
            +- Exchange hashpartitioning(driver_name, zone_name, 200)             ⑤ THE one shuffle
               +- *(2) HashAggregate(keys=[driver_name, zone_name],               ④ partial sum/count per task
                        functions=[partial_sum(fare), partial_count(1)])
                  +- *(2) Project [fare, zone_name, driver_name]
                     +- *(2) BroadcastHashJoin [driver_id], [driver_id], Inner, BuildRight   ③ probe drivers table
                        :- *(2) Project [driver_id, fare, zone_name]
                        :  +- *(2) BroadcastHashJoin [zone_id], [zone_id], Inner, BuildRight ② probe zones table
                        :     :- *(2) Filter (((isnotnull(fare) AND (fare &amp;gt; 100.0))
                        :     :         AND isnotnull(zone_id)) AND isnotnull(driver_id))
                        :     :  +- *(2) ColumnarToRow
                        :     :     +- FileScan parquet [driver_id,zone_id,fare,dt]             ① the only big read
                        :     :          PartitionFilters: [isnotnull(dt), (dt &amp;gt;= 2026-03-01), (dt &amp;lt;= 2026-03-31)],
                        :     :          PushedFilters: [IsNotNull(fare), GreaterThan(fare,100.0),
                        :     :                          IsNotNull(zone_id), IsNotNull(driver_id)],
                        :     :          ReadSchema: struct&amp;lt;driver_id:bigint,zone_id:int,fare:double&amp;gt;
                        :     +- BroadcastQueryStage 0
                        :        +- BroadcastExchange HashedRelationBroadcastMode(List(cast(zone_id as bigint)))
                        :           +- *(1) ... FileScan parquet zones [zone_id,zone_name]
                        +- BroadcastQueryStage 1
                           +- BroadcastExchange HashedRelationBroadcastMode(List(driver_id))
                              +- ... FileScan parquet drivers [driver_id,driver_name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things Maya can now point at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;① &lt;code&gt;ReadSchema&lt;/code&gt; has &lt;strong&gt;three columns&lt;/strong&gt; out of eighteen, and &lt;code&gt;dt&lt;/code&gt; isn't in it (it comes from the path). The optimizer worked out that &lt;code&gt;trip_id&lt;/code&gt; is never used and dropped it, even though the joins and aggregates are five operators higher up.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Filter&lt;/code&gt; has grown two extra predicates she never wrote: &lt;code&gt;isnotnull(zone_id)&lt;/code&gt; and &lt;code&gt;isnotnull(driver_id)&lt;/code&gt;. That's &lt;code&gt;InferFiltersFromConstraints&lt;/code&gt;: an inner join can never match a null key, so the optimizer filters them out &lt;em&gt;before&lt;/em&gt; the join, and pushes those too.&lt;/li&gt;
&lt;li&gt;② ③ Two broadcast joins, &lt;strong&gt;zero &lt;code&gt;Exchange&lt;/code&gt;s under them&lt;/strong&gt;. The 170 GB of March is read once and never shuffled.&lt;/li&gt;
&lt;li&gt;④ ⑤ ⑦ The classic partial/exchange/final aggregate triplet. The shuffle carries pre-aggregated rows: at most one row per (driver, zone) per task, which is why it's 62 MB and not 600 MB.&lt;/li&gt;
&lt;li&gt;⑥ AQE collapsed the 200 reducers into 1, because 62 MB fits in a single advisory partition.&lt;/li&gt;
&lt;li&gt;⑧ &lt;code&gt;TakeOrderedAndProject&lt;/code&gt;: because &lt;code&gt;limit(10)&lt;/code&gt; sits directly on &lt;code&gt;orderBy&lt;/code&gt;, there's no range partitioning and no sampling job.&lt;/li&gt;
&lt;li&gt;The whole-stage codegen stage IDs: &lt;code&gt;*(2)&lt;/code&gt; covers scan → filter → join → join → partial aggregate. &lt;strong&gt;Five operators, one generated Java loop per task.&lt;/strong&gt; A row is read from a column vector, tested, probed twice, and folded into a hash map without ever being materialized as an object in between.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.2 The stage map
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Jobs for one action (report.write)

 Job 6   broadcast exchange (zones)     1 task    0.4 s  ─┐ started by background threads
 Job 7   broadcast exchange (drivers)   1 task    2.1 s  ─┘ as soon as the plan is ready

 Job 8   parquet at &amp;lt;cell&amp;gt;:11  (AQE: the shuffle map stage runs as its own job)
         ┌──────────────────────────────────────────────────────────────────────────┐
         │ Stage 8   1,364 tasks                                           34 s     │
         │  scan (3 cols, 31 dirs) → filter → ⋈ zones → ⋈ drivers → partial agg     │
         │  → shuffle write by hash(driver_name, zone_name)                         │
         │  Input 170 GB · Shuffle Write 62 MB                                      │
         └───────────────────────────────────┬──────────────────────────────────────┘
                                             ▼
 Job 9   parquet at &amp;lt;cell&amp;gt;:11  (the final stages, planned after Stage 8's sizes were known)
         ┌──────────────────────────────────────────────────────────────────────────┐
         │ Stage 9   1 task (coalesced from 200)                            3 s     │
         │  shuffle read 62 MB → final agg (2.1 M groups) → local top-10            │
         └───────────────────────────────────┬──────────────────────────────────────┘
                                             ▼
         ┌──────────────────────────────────────────────────────────────────────────┐
         │ Stage 10  1 task                                                 1 s     │
         │  merge top-10 → write 1 Parquet file + _SUCCESS                          │
         └──────────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.3 A day in the life of task 812
&lt;/h3&gt;

&lt;p&gt;To make it concrete, here is one task from Stage 8, millisecond by millisecond, as reconstructed from the task metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   0 ms   LaunchTask arrives at executor 3; core 2 is free
   2 ms   deserialize partition: dt=2026-03-14/part-00017.parquet, bytes [0, 134217728)
   4 ms   broadcast 0 (zones) and broadcast 1 (drivers): already in this executor's
          BlockManager, fetched by the first task that ran here. Zero network.
  41 ms   S3 GET: last 64 KB of the file → footer: 8 row groups, offsets of the
          driver_id / zone_id / fare chunks, min/max per chunk
  43 ms   PushedFilters vs stats: fare max is 388–412 in every row group → nothing skipped
          (the data is in pickup order; see Chapter 3)
  50 ms   row group 0: GET three column chunks (~7.5 MB total)
 120 ms     decode → ColumnarBatch(4096 rows) × ~250 batches
            generated loop per row:
              fare &amp;gt; 100 ?            → 13% survive
              zones.probe(zone_id)    → zone_name
              drivers.probe(driver_id)→ driver_name
              aggMap[(driver_name, zone_name)] += (fare, 1)
 ...        row groups 1–7, same story
 905 ms   aggregation map holds 38,412 groups
 906 ms   shuffle write: hash(keys) mod 200 → UnsafeShuffleWriter → 1 data file (1.1 MB) + index
 924 ms   MapStatus (executor 3, 200 block sizes) sent to the driver. Task FINISHED.
          Metrics: input 128 MB, records read 1,046,000, output rows 38,412,
                   shuffle write 1.1 MB, GC time 3 ms, spill 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1,364 of those, 40 at a time, 34 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.4 Before and after
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;line&lt;/th&gt;
&lt;th&gt;Monday's version&lt;/th&gt;
&lt;th&gt;what it made Spark do&lt;/th&gt;
&lt;th&gt;Wednesday's version&lt;/th&gt;
&lt;th&gt;what changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;date filter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;month(pickup_ts) == 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wrong column, wrapped in a function: no partition pruning, no pushdown. &lt;strong&gt;16,384 tasks&lt;/strong&gt;, ~500 GB of columns decoded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dt.between(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1,364 tasks&lt;/strong&gt;, 31 directories, 170 GB assigned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fare filter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fare &amp;gt; 100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pushed, but to every file&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;pushed, after pruning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zones join&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.join(zones)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;broadcast (size estimate was enough)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;broadcast(zones)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same, now explicit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drivers join&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.join(drivers)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sort-merge planned: trips shuffled and sorted by &lt;code&gt;driver_id&lt;/code&gt;; AQE converted to broadcast only after the shuffle write&lt;/td&gt;
&lt;td&gt;&lt;code&gt;broadcast(drivers)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no trips shuffle&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ranking&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orderBy().limit(10)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TakeOrderedAndProject&lt;/code&gt; (she got this right by accident)&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 min, 5 jobs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41 s, 4 jobs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing about the cluster changed. Every second saved came from giving Catalyst something it could reason about: a comparable column instead of a function, a size hint instead of a guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.5 Epilogue: Thursday
&lt;/h3&gt;

&lt;p&gt;Maya closes FIN-2231 with a day to spare. Ravi asks her to explain the Spark UI back to him, and she does it in five sentences:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"&lt;code&gt;spark.read&lt;/code&gt; built a plan and listed the files; that's Job 0. The write was the action, and it became a plan with&lt;br&gt;
one shuffle, so two stages, plus a tiny third one for the output file. The two little jobs before it were the&lt;br&gt;
broadcasts. Stage 8 had 1,364 tasks because March is 1,364 files of 128 MB, and each task read three columns,&lt;br&gt;
filtered, probed two hash tables and pre-aggregated. Everything after that was 62 MB."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Good," Ravi says. "Now: what do you think happens when you call &lt;code&gt;.write&lt;/code&gt;? Because that part has its own story." But that's the next article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cheat sheets
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does it shuffle?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation&lt;/th&gt;
&lt;th&gt;physical operator&lt;/th&gt;
&lt;th&gt;shuffle?&lt;/th&gt;
&lt;th&gt;stage boundary?&lt;/th&gt;
&lt;th&gt;notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.read.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FileScan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;may run a &lt;em&gt;listing&lt;/em&gt; job (&amp;gt; 32 dirs) and, for CSV/JSON with &lt;code&gt;inferSchema&lt;/code&gt;, a full-scan job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;select&lt;/code&gt;, &lt;code&gt;withColumn&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Project&lt;/code&gt;, &lt;code&gt;Filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;fused into the scan's codegen stage&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;
&lt;code&gt;HashAggregate&lt;/code&gt; ×2&lt;/td&gt;
&lt;td&gt;tiny&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;reads zero columns from Parquet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;groupBy().agg()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HashAggregate&lt;/code&gt; ×2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;partial aggregation first, so shuffle size ≈ groups per task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;distinct()&lt;/code&gt;, &lt;code&gt;dropDuplicates()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HashAggregate&lt;/code&gt; ×2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;same as groupBy on all columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;orderBy()&lt;/code&gt; / &lt;code&gt;sort()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Exchange rangepartitioning&lt;/code&gt; + &lt;code&gt;Sort&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;plus a &lt;strong&gt;sampling job&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sortWithinPartitions()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sort global=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderBy().limit(n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TakeOrderedAndProject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;map-only top-k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;limit(n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CollectLimit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;reads partitions incrementally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;join&lt;/code&gt; (small side ≤ 10 MB)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BroadcastHashJoin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no (big side)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;small side collected to driver, broadcast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;join&lt;/code&gt; (both large)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SortMergeJoin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;yes&lt;/strong&gt;, both sides&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;AQE may convert to broadcast at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;join&lt;/code&gt; (non-equi)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BroadcastNestedLoopJoin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;O(n×m); needs a broadcastable side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;repartition(n)&lt;/code&gt; / &lt;code&gt;repartition(col)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Exchange roundrobin&lt;/code&gt; / &lt;code&gt;hashpartitioning&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;coalesce(n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Coalesce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;merges partitions in place; can reduce parallelism of the &lt;em&gt;whole&lt;/em&gt; stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;union&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Union&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;just concatenates partitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window functions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Window&lt;/code&gt; + &lt;code&gt;Exchange&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;yes&lt;/strong&gt; (unless already partitioned)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;plus a sort by the window's order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cache()&lt;/code&gt; / &lt;code&gt;persist()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InMemoryTableScan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;lazy; materialized by the next action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;collect()&lt;/code&gt;, &lt;code&gt;toPandas()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CollectLimit&lt;/code&gt; / result task&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;bounded by &lt;code&gt;spark.driver.maxResultSize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;write.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InsertIntoHadoopFsRelation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;one file per task unless you repartition first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The settings that appeared in this story
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;setting&lt;/th&gt;
&lt;th&gt;default&lt;/th&gt;
&lt;th&gt;what it controls&lt;/th&gt;
&lt;th&gt;chapter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.sources.parallelPartitionDiscovery.threshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;directories before file listing becomes a Spark job&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.parquet.mergeSchema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;read one footer vs all footers for the schema&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.files.maxPartitionBytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128 MB&lt;/td&gt;
&lt;td&gt;max bytes per scan task&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.files.openCostInBytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4 MB&lt;/td&gt;
&lt;td&gt;fake size added per file when bin-packing&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.parquet.columnarReaderBatchSize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;rows per &lt;code&gt;ColumnarBatch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.driver.maxResultSize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;total size of results a single action may return to the driver&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.parquet.filterPushdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;hand predicates to the Parquet reader&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;reducers after a shuffle (before AQE adjusts it)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.adaptive.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true (3.2+)&lt;/td&gt;
&lt;td&gt;AQE as a whole&lt;/td&gt;
&lt;td&gt;4, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.adaptive.coalescePartitions.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;merge small post-shuffle partitions&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.adaptive.advisoryPartitionSizeInBytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64 MB&lt;/td&gt;
&lt;td&gt;target size when coalescing or splitting&lt;/td&gt;
&lt;td&gt;4, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.execution.rangeExchange.sampleSizePerPartition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;sample points per output partition for range sorts&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.reducer.maxSizeInFlight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;48 MB&lt;/td&gt;
&lt;td&gt;shuffle fetch requests outstanding per reducer&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.autoBroadcastJoinThreshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 MB&lt;/td&gt;
&lt;td&gt;planning-time broadcast decision (file sizes)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.adaptive.autoBroadcastJoinThreshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;= above&lt;/td&gt;
&lt;td&gt;runtime broadcast decision (shuffle sizes)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.broadcastTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;how long a broadcast build may take&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.adaptive.skewJoin.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;split skewed join partitions&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;spark.sql.adaptive.skewJoin.skewedPartitionFactor&lt;/code&gt; / &lt;code&gt;...ThresholdInBytes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 / 256 MB&lt;/td&gt;
&lt;td&gt;what counts as skewed&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark.sql.optimizer.dynamicPartitionPruning.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;prune partitions using the other side of a join&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Five questions to ask any plan
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What's in &lt;code&gt;PartitionFilters&lt;/code&gt;?&lt;/strong&gt; If your date/region/tenant filter isn't there, you're scanning everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's in &lt;code&gt;ReadSchema&lt;/code&gt;?&lt;/strong&gt; If it lists 40 columns and you use 3, something upstream (a UDF, a &lt;code&gt;select("*")&lt;/code&gt; into a cache) is defeating column pruning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many &lt;code&gt;Exchange&lt;/code&gt;s, and what's below each?&lt;/strong&gt; Each is a stage boundary and a network copy. A &lt;code&gt;Sort&lt;/code&gt; under one means a global sort; two under a join means sort-merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which joins are &lt;code&gt;BroadcastHashJoin&lt;/code&gt;?&lt;/strong&gt; Anything that should be and isn't is a size-estimate problem; fix it with &lt;code&gt;broadcast()&lt;/code&gt; or better statistics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is &lt;code&gt;isFinalPlan=true&lt;/code&gt;?&lt;/strong&gt; If not, you're reading the &lt;em&gt;initial&lt;/em&gt; plan; run the query and look at the SQL tab for what AQE actually did.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Reading the Spark UI like a story
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Jobs tab        Unexpected jobs are the first clue:
                 "Listing leaf files"        → the read listed &amp;gt; 32 directories
                 1,364-task job before a sort → RangePartitioner sampling
                 1-task jobs before a join    → broadcast builds
                 full-scan job on spark.read  → CSV/JSON inferSchema

 Stages tab      Task count vs cores (16,384 tasks on 40 cores = 410 waves).
                 Task duration quartiles: max ≫ 75th percentile = skew.
                 Shuffle Read/Write columns: where the bytes moved.
                 Spill (memory / disk): sorter or aggregate ran out of memory.

 SQL tab         Click the query → the plan as a graph, with live metrics:
                 Scan: files read, partitions read, size, rows out
                 Filter: rows out (compare with scan rows out = pushdown effectiveness)
                 Exchange: shuffle bytes written / fetched
                 Open "Details" at the bottom for the text plan, including isFinalPlan.

 Executors tab   GC time (should be &amp;lt; 10% of task time), storage memory used by
                 broadcasts and caches, failed tasks per executor.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Try it on your laptop
&lt;/h2&gt;

&lt;p&gt;Everything above reproduces on a single machine with &lt;code&gt;local[4]&lt;/code&gt;, just with smaller numbers. This generates a mini RideHub (about 300 MB) and sets you up to run every &lt;code&gt;explain()&lt;/code&gt; in the article:&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 pyspark   (3.5.x or 4.0.x)
&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="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;local[4]&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;appName&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-read-story&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;200&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 default; set explicitly so it's visible
&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/ridehub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# trips: 5M rows over 60 days, partitioned by dt, fares in random order (like RideHub's)
&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;range&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="mi"&gt;5_000_000&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;concat&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;t&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;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;trip_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="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;50_000&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;driver_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="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rand&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;263&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;zone_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="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rand&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;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;fare&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;expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_add(date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2026-02-01&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, cast(id % 60 as 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;dt&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;repartition&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                  &lt;span class="c1"&gt;# ~2 files per day
&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;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;dt&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;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;parquet&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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/trips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# drivers: 50k rows with some padding so the file is bigger than the broadcast threshold
&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;range&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="mi"&gt;50_000&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;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;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;driver_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;concat&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;Driver &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;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;driver_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;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha2&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;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;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;512&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;licence_hash&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;sha2&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;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="mi"&gt;7&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="mi"&gt;512&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;payout_token&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;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;parquet&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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/drivers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# zones: tiny
&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;range&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="mi"&gt;263&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;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;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;zone_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;concat&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;Zone &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;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;zone_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="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;parquet&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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/zones&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;Then walk the chapters, with the Spark UI open at &lt;code&gt;http://localhost:4040&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;trips&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;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="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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/trips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# Chapter 1: Jobs tab shows "Listing leaf files" (60 dirs &amp;gt; 32)
&lt;/span&gt;&lt;span class="n"&gt;drivers&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;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="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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/drivers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;zones&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;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="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;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/zones&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;trips&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;# Chapter 2: ReadSchema struct&amp;lt;&amp;gt;, 2 stages
&lt;/span&gt;&lt;span class="n"&gt;trips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;march&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;2026-03-31&lt;/span&gt;&lt;span class="sh"&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="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;fare&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;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                      &lt;span class="c1"&gt;# Chapter 3: PartitionFilters vs PushedFilters
&lt;/span&gt;&lt;span class="n"&gt;trips&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;month&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dt&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;#            the trap: PartitionFilters: []
&lt;/span&gt;
&lt;span class="n"&gt;march&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;fare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;              &lt;span class="c1"&gt;# Chapter 4: Exchange rangepartitioning + a sampling job
&lt;/span&gt;&lt;span class="n"&gt;march&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;fare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="c1"&gt;#            TakeOrderedAndProject
&lt;/span&gt;
&lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zone_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;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;# Chapter 5: BroadcastHashJoin
&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.autoBroadcastJoinThreshold&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;explain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;#            SortMergeJoin with two Exchanges
&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.autoBroadcastJoinThreshold&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;10MB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Run the join and compare the initial plan with the final one in the SQL tab (isFinalPlan=true)
&lt;/span&gt;&lt;span class="n"&gt;march&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two experiments worth doing on top:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn AQE off&lt;/strong&gt; (&lt;code&gt;spark.conf.set("spark.sql.adaptive.enabled", False)&lt;/code&gt;) and re-run the sort: you'll get 200 output files instead of a handful, and the join won't convert to broadcast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite &lt;code&gt;trips&lt;/code&gt; sorted by fare&lt;/strong&gt; (&lt;code&gt;.sortWithinPartitions("fare")&lt;/code&gt; before the write) and compare the scan's &lt;em&gt;rows output&lt;/em&gt; metric in the SQL tab for &lt;code&gt;fare &amp;gt; 300&lt;/code&gt;. That's Parquet statistics finally doing something.&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;&lt;strong&gt;Official docs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html" rel="noopener noreferrer"&gt;Spark SQL performance tuning&lt;/a&gt;: caching, join hints, and the full &lt;a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution" rel="noopener noreferrer"&gt;AQE section&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/docs/latest/configuration.html" rel="noopener noreferrer"&gt;Spark configuration reference&lt;/a&gt; and &lt;a href="https://spark.apache.org/docs/latest/configuration.html#runtime-sql-configuration" rel="noopener noreferrer"&gt;Spark SQL options&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-parquet.html" rel="noopener noreferrer"&gt;Parquet data source options&lt;/a&gt;: schema merging, partition discovery, pushdown settings&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://parquet.apache.org/docs/file-format/" rel="noopener noreferrer"&gt;Apache Parquet file format&lt;/a&gt; and the &lt;a href="https://github.com/apache/parquet-format/blob/master/PageIndex.md" rel="noopener noreferrer"&gt;page index spec&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Source files, if you want to see the real thing&lt;/strong&gt; (all under &lt;a href="https://github.com/apache/spark" rel="noopener noreferrer"&gt;&lt;code&gt;apache/spark&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/datasources/FilePartition.scala&lt;/code&gt;: &lt;code&gt;maxSplitBytes&lt;/code&gt; and the bin-packing from Chapter 2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/DataSourceScanExec.scala&lt;/code&gt;: &lt;code&gt;FileSourceScanExec&lt;/code&gt;, partition filters, dynamic pruning, scan metrics&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/exchange/ShuffleExchangeExec.scala&lt;/code&gt;: how a shuffle dependency is built, including the &lt;code&gt;RangePartitioner&lt;/code&gt; sampling&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;core/src/main/scala/org/apache/spark/Partitioner.scala&lt;/code&gt;: &lt;code&gt;RangePartitioner.sketch&lt;/code&gt;, the sampling job&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/exchange/BroadcastExchangeExec.scala&lt;/code&gt;: the driver-side build, the 8 GB / 512 M row limits, the timeout&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/SparkStrategies.scala&lt;/code&gt;: &lt;code&gt;JoinSelection&lt;/code&gt;, the decision tree in Chapter 5&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/core/.../execution/adaptive/AdaptiveSparkPlanExec.scala&lt;/code&gt;: stage-by-stage re-planning, coalescing, skew handling&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala&lt;/code&gt;: stages cut at shuffle dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Longer reads&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://books.japila.pl/spark-sql-internals/" rel="noopener noreferrer"&gt;The Internals of Spark SQL&lt;/a&gt; by Jacek Laskowski: an operator-by-operator reference&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Spark: The Definitive Guide&lt;/em&gt; (Chambers &amp;amp; Zaharia), Part II, for the DataFrame execution model in long form&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This is the first article in a series on distributed computing, told through one dataset and one team. Next up: what happens when you call &lt;code&gt;.write&lt;/code&gt;, and why your output has 200 files. If something here doesn't match what you see in your Spark UI, tell me in the comments: versions move, and I'd rather fix it than be quoted wrong.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spark</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>The Distributed Compute Playground: Everything You Can Plug In and Tune in an Apache Spark Job</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:34:42 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/the-distributed-compute-playground-everything-you-can-plug-in-and-tune-in-an-apache-spark-job-4l0b</link>
      <guid>https://dev.to/anikethsdeshpande/the-distributed-compute-playground-everything-you-can-plug-in-and-tune-in-an-apache-spark-job-4l0b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: An Apache Spark job is not one thing. It is a &lt;strong&gt;stack of nine layers&lt;/strong&gt;, and almost every layer has&lt;br&gt;
parts you can swap: where executors come from, how the code is written, which engine runs the plan, how joins&lt;br&gt;
and shuffles are decided, where shuffle files live, how memory is divided, how the scheduler reacts to slow and&lt;br&gt;
dying nodes, how bytes sit on disk, and how files are laid out so the planner can skip them. This article maps&lt;br&gt;
all of those options by the problem each one solves, with the &lt;code&gt;--conf&lt;/code&gt; line or PySpark call for every one of them.&lt;/p&gt;

&lt;p&gt;⏱️ &lt;strong&gt;40-minute read.&lt;/strong&gt; It is the reference companion to&lt;br&gt;
&lt;em&gt;What actually happens when you call &lt;code&gt;spark.read&lt;/code&gt;?&lt;/em&gt;, which followed one job through the&lt;br&gt;
internals. This one is the parts catalogue. Everything here was checked against &lt;strong&gt;Spark 3.5&lt;/strong&gt; and &lt;strong&gt;Spark 4.0&lt;/strong&gt;&lt;br&gt;
documentation; where a platform (Databricks, EMR, Dataproc) owns a feature, it says so.&lt;/p&gt;

&lt;p&gt;🎮 &lt;strong&gt;Rather break a Spark job than read about one?&lt;/strong&gt; Every component in this article is a chip you can drag in the&lt;br&gt;
free, open-source &lt;strong&gt;&lt;a href="https://github.com/AnikethSD/distributed-computing-playground" rel="noopener noreferrer"&gt;Distributed Compute Playground&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
Build a job layer by layer and it shows you the &lt;code&gt;explain()&lt;/code&gt; plan, the stages, the task counts and the runtime you&lt;br&gt;
just caused, then hands you 25 on-call tickets to fix. If it clicks, a ⭐ means the world.&lt;br&gt;
(More about it at the end)&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;How to read this article&lt;/li&gt;
&lt;li&gt;Layer 1: Cluster manager&lt;/li&gt;
&lt;li&gt;Layer 2: Code and APIs&lt;/li&gt;
&lt;li&gt;Layer 3a: Execution engine&lt;/li&gt;
&lt;li&gt;Layer 3b: Planner, joins and AQE&lt;/li&gt;
&lt;li&gt;Layer 4a: Shuffle&lt;/li&gt;
&lt;li&gt;Layer 4b: Memory, caching and serialization&lt;/li&gt;
&lt;li&gt;Layer 5: Scheduling and resilience&lt;/li&gt;
&lt;li&gt;Layer 6a: File and table formats&lt;/li&gt;
&lt;li&gt;Layer 6b: Data layout and the read path&lt;/li&gt;
&lt;li&gt;Combinations that are worth more than their parts&lt;/li&gt;
&lt;li&gt;Six starter profiles&lt;/li&gt;
&lt;li&gt;Cheat sheet: symptom to lever&lt;/li&gt;
&lt;li&gt;
Play it: the Distributed Compute Playground 🎮&lt;/li&gt;
&lt;li&gt;Further reading&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  How to read this article
&lt;/h2&gt;

&lt;p&gt;The previous article told a story: Maya, a new data engineer at RideHub, inherits a report that used to take six minutes and now takes fifty, and we followed one DataFrame through Catalyst, the file index, the scheduler and the shuffle to find out why. If you have not read it, you do not need to; this article stands alone. But the dataset is the same, so when an example says &lt;em&gt;trips&lt;/em&gt;, it means 2 TB of Parquet partitioned by &lt;code&gt;dt&lt;/code&gt;, 16,000 files, one year of ride-hailing history.&lt;/p&gt;

&lt;p&gt;This time the question is different. Not &lt;em&gt;what happens&lt;/em&gt;, but &lt;em&gt;what can I change&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The nine layers
&lt;/h3&gt;

&lt;p&gt;Every Spark job, whether it is a notebook cell or a 5 TB nightly join, sits on the same stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌──────────────────────────────────────────────────────────────────────┐
 │  L1  CLUSTER MANAGER      local[*] · Standalone · YARN · K8s · serverless │
 ├──────────────────────────────────────────────────────────────────────┤
 │  L2  CODE &amp;amp; APIs          DataFrame · RDD · UDF ladder · Connect ·      │
 │                           Thrift · Structured Streaming                 │
 ├────────────────────────────────┬─────────────────────────────────────┤
 │  L3  EXECUTION ENGINE          │  L3  PLANNER, JOINS &amp;amp; AQE           │
 │      Catalyst+Tungsten (JVM)   │      AQE · broadcast · DPP · CBO ·  │
 │      Gluten · Comet · Photon · │      salting · bucketing ·          │
 │      RAPIDS                    │      shuffle.partitions · pushdown  │
 ├────────────────────────────────┼─────────────────────────────────────┤
 │  L4  SHUFFLE                   │  L4  MEMORY, CACHING, SERIALIZATION │
 │      ESS · tracking · Celeborn │      executor shape · overhead ·    │
 │      Uniffle · Magnet · disks  │      off-heap · cache levels · Arrow│
 ├────────────────────────────────┴─────────────────────────────────────┤
 │  L5  SCHEDULING &amp;amp; RESILIENCE  dynamic allocation · speculation · FAIR · │
 │                               retries · decommission · spot · history   │
 ├────────────────────────────────┬─────────────────────────────────────┤
 │  L6  FILE / TABLE FORMAT       │  L6  DATA LAYOUT &amp;amp; READ PATH        │
 │      Parquet · ORC · Avro ·    │      partitionBy · bucketBy ·       │
 │      CSV · JSON · Delta ·      │      Z-ORDER · compaction ·         │
 │      Iceberg · Hudi · Hive     │      schema · committers · stats    │
 └──────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each section below takes one layer, lists every part you can plug into it, and for each part answers the same four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What problem does it solve?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does it cost?&lt;/strong&gt; (Every lever has a price. Some are hidden.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does it need?&lt;/strong&gt; (Prerequisites and conflicts. Dynamic allocation without a shuffle service is a config error, not a tuning choice.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you turn it on?&lt;/strong&gt; (The &lt;code&gt;--conf&lt;/code&gt; or the PySpark call.)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The eleven dimensions
&lt;/h3&gt;

&lt;p&gt;To compare parts that do completely different things, it helps to score them against the same questions. The playground uses eleven, and this article uses them informally too:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Batch throughput&lt;/td&gt;
&lt;td&gt;How many bytes per hour can the nightly job chew through?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive latency&lt;/td&gt;
&lt;td&gt;How fast does a dashboard query come back?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shuffle efficiency&lt;/td&gt;
&lt;td&gt;How much data crosses the network, and how painful is that?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory safety&lt;/td&gt;
&lt;td&gt;How close is the driver or an executor to an OutOfMemoryError?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skew resilience&lt;/td&gt;
&lt;td&gt;What happens when one key holds 30% of the rows?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I/O and small-file efficiency&lt;/td&gt;
&lt;td&gt;How much of the time is spent listing, opening and parsing files?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fault tolerance&lt;/td&gt;
&lt;td&gt;What happens when a node disappears mid-stage?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost efficiency&lt;/td&gt;
&lt;td&gt;Dollars per terabyte processed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python friendliness&lt;/td&gt;
&lt;td&gt;How much does writing it in Python cost compared with SQL?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming fitness&lt;/td&gt;
&lt;td&gt;Can it run forever with bounded state?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational simplicity&lt;/td&gt;
&lt;td&gt;How many things can page you at 04:00?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of the dangerous settings in this article look great on one dimension and are catastrophic on another. &lt;code&gt;spark.sql.autoBroadcastJoinThreshold = 2g&lt;/code&gt; is a shuffle-efficiency win and a memory-safety disaster. The point of scoring against all eleven is that you notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three kinds of "dangerous"
&lt;/h3&gt;

&lt;p&gt;Throughout, settings are marked three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Warning&lt;/strong&gt;: a legitimate tool that is wrong more often than it is right. Example: &lt;code&gt;MEMORY_ONLY&lt;/code&gt; persistence on a dataset bigger than RAM.&lt;/li&gt;
&lt;li&gt;🧨 &lt;strong&gt;Danger&lt;/strong&gt;: a setting that will eventually take production down, usually on the first busy day after the person who set it has left. Example: &lt;code&gt;spark.task.maxFailures = 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;strong&gt;Note&lt;/strong&gt;: a prerequisite or platform limit that is easy to miss. Example: Kubernetes has no external shuffle service.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer 1: Cluster manager
&lt;/h2&gt;

&lt;p&gt;The cluster manager answers one question: &lt;strong&gt;where do executors come from, and what happens to their disks when they go away?&lt;/strong&gt; That second half decides more than people expect. It is the reason dynamic allocation works out of the box on YARN and needs a workaround on Kubernetes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      driver
                        │ asks for N executors
            ┌───────────┴────────────┐
            ▼                        ▼
   ┌─────────────────┐      ┌─────────────────┐
   │ cluster manager │      │ cluster manager │
   │  (YARN / K8s /  │ ...  │   decides which │
   │  standalone)    │      │   node, how big │
   └────────┬────────┘      └─────────────────┘
            │ launches
   ┌────────▼────────┐  ┌─────────────────┐  ┌─────────────────┐
   │ executor (JVM)  │  │ executor (JVM)  │  │ executor (JVM)  │
   │ cores · heap ·  │  │                 │  │                 │
   │ LOCAL DISK ◄────┼──┼── shuffle files live here, and die with the node
   └─────────────────┘  └─────────────────┘  └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;local[*]&lt;/code&gt;: one JVM
&lt;/h3&gt;

&lt;p&gt;Driver and executors share one process on your laptop. &lt;code&gt;*&lt;/code&gt; means one task slot per core.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; running tests, reading &lt;code&gt;explain()&lt;/code&gt; output, learning. Everything in this article can be observed on &lt;code&gt;local[*]&lt;/code&gt; with a few hundred megabytes of Parquet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; no fault tolerance, one machine of RAM, one machine of disk for shuffle. Scores poorly on throughput and resilience by definition.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;code&gt;local[*]&lt;/code&gt; is a learning tool, not a deployment target. If the nightly job "works on my laptop" at 2 TB, it is because the laptop has the data cached and nobody is sharing it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.master&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;local[*]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Spark Standalone
&lt;/h3&gt;

&lt;p&gt;Spark's own master/worker cluster manager. No Hadoop, no Kubernetes, one process per node and a web UI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; a dedicated Spark cluster with the least moving parts. The worker can host the external shuffle service, so dynamic allocation works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; no queues, no multi-tenancy worth the name, no preemption. One application tends to take the whole cluster unless you cap &lt;code&gt;spark.cores.max&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.master&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;spark://master:7077&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/spark-standalone.html" rel="noopener noreferrer"&gt;Spark Standalone Mode&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hadoop YARN
&lt;/h3&gt;

&lt;p&gt;The classic. YARN runs a ResourceManager and a NodeManager per node; the NodeManager can host Spark's external shuffle service as an auxiliary service.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; queues, capacity limits, preemption between teams, and shuffle files that survive executor removal. The reason the "Nightly ETL starter" profile at the end of this article uses YARN is that everything simply works: &lt;code&gt;spark.shuffle.service.enabled=true&lt;/code&gt;, &lt;code&gt;spark.dynamicAllocation.enabled=true&lt;/code&gt;, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; a Hadoop installation to operate. Executor sizes are quantised by &lt;code&gt;yarn.scheduler.minimum-allocation-mb&lt;/code&gt;. Container start-up is slower than a pod.&lt;/li&gt;
&lt;li&gt;Deploy mode matters: &lt;code&gt;cluster&lt;/code&gt; puts the driver inside the YARN application (survives your SSH session dying); &lt;code&gt;client&lt;/code&gt; keeps it where you ran &lt;code&gt;spark-submit&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.master&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yarn&lt;/span&gt;
&lt;span class="py"&gt;spark.submit.deployMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;cluster&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/running-on-yarn.html" rel="noopener noreferrer"&gt;Running Spark on YARN&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes
&lt;/h3&gt;

&lt;p&gt;Executors are pods. You bring a container image; Spark asks the API server for pods and tears them down when done.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; elasticity, one platform for Spark and everything else, custom images with exactly the Python packages a job needs, namespaces and quotas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; ℹ️ &lt;strong&gt;there is no external shuffle service on Kubernetes.&lt;/strong&gt; When an executor pod is removed, its shuffle files go with it. That means dynamic allocation needs either &lt;em&gt;shuffle tracking&lt;/em&gt; (keep executors alive while their shuffle data is needed) or a &lt;em&gt;remote shuffle service&lt;/em&gt; (Celeborn, Uniffle), both covered in Layer 4a. Expect to spend time on image builds, service accounts and volume mounts for &lt;code&gt;spark.local.dir&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.master&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;k8s://https://k8s-api:6443&lt;/span&gt;
&lt;span class="py"&gt;spark.kubernetes.container.image&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;registry/spark-py:3.5&lt;/span&gt;
&lt;span class="py"&gt;spark.kubernetes.namespace&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;data-eng&lt;/span&gt;
&lt;span class="py"&gt;spark.kubernetes.authenticate.driver.serviceAccountName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;spark&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/running-on-kubernetes.html" rel="noopener noreferrer"&gt;Running Spark on Kubernetes&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed serverless Spark
&lt;/h3&gt;

&lt;p&gt;Dataproc Serverless, EMR Serverless and Databricks serverless compute remove the cluster entirely: you submit a job, the platform sizes and scales it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; operational simplicity, start-up time, autoscaling you did not have to configure, and the "who left the 40-node cluster running over the weekend" bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; ℹ️ a ceiling on what you may change. Executor shapes are chosen from a menu, some plugins are unavailable, and a few &lt;code&gt;spark.*&lt;/code&gt; settings are silently overridden. Read the platform limits before relying on a knob from this article.&lt;/li&gt;
&lt;li&gt;Platform-specific: it is one of the parts the playground marks as &lt;em&gt;not on open-source Spark&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://cloud.google.com/dataproc-serverless/docs" rel="noopener noreferrer"&gt;Dataproc Serverless&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/emr/latest/EMR-Serverless-UserGuide/" rel="noopener noreferrer"&gt;EMR Serverless&lt;/a&gt;, &lt;a href="https://docs.databricks.com/en/compute/serverless/index.html" rel="noopener noreferrer"&gt;Databricks serverless compute&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Then also&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;To learn, test, read plans&lt;/td&gt;
&lt;td&gt;&lt;code&gt;local[*]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One team, one cluster, no Hadoop&lt;/td&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;external shuffle service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant queues, battle-tested dynamic allocation&lt;/td&gt;
&lt;td&gt;YARN&lt;/td&gt;
&lt;td&gt;external shuffle service, push-based shuffle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud-native, custom images, elasticity&lt;/td&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;shuffle tracking &lt;em&gt;or&lt;/em&gt; Celeborn / Uniffle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nobody to operate a cluster&lt;/td&gt;
&lt;td&gt;Serverless&lt;/td&gt;
&lt;td&gt;accept the platform's limits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer 2: Code and APIs
&lt;/h2&gt;

&lt;p&gt;How the job is &lt;em&gt;written&lt;/em&gt; decides what the optimizer can see. This is the layer where a one-line change (a &lt;code&gt;@udf&lt;/code&gt; decorator) can make a job 50× slower, and where the fix is usually a different decorator.&lt;/p&gt;

&lt;h3&gt;
  
  
  DataFrame / SQL API
&lt;/h3&gt;

&lt;p&gt;Declarative plans. You describe &lt;em&gt;what&lt;/em&gt;, Catalyst decides &lt;em&gt;how&lt;/em&gt;: column pruning, predicate pushdown, join reordering, whole-stage code generation. Python, Scala, Java and SQL all compile to the same logical plan, so a PySpark DataFrame pipeline runs at JVM speed.&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;df&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;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;s3a://ridehub/trips/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&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;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;dt&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;zone_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;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;fare&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 default and the baseline. Everything else in this layer is measured against it.&lt;/p&gt;

&lt;h3&gt;
  
  
  RDD API
&lt;/h3&gt;

&lt;p&gt;The original abstraction: a distributed collection of opaque objects with &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;reduceByKey&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; full control over partitioning and per-record logic, access to things the DataFrame API hides (custom partitioners, &lt;code&gt;mapPartitions&lt;/code&gt; with external state).&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Costs:&lt;/strong&gt; RDDs skip Catalyst. No column pruning, no pushdown, no AQE, no codegen. In Python, every row is pickled from the JVM to a Python worker process and back. On a 2 TB Parquet scan that reads six columns out of forty, the RDD path reads all forty and deserializes them one object at a time.&lt;/li&gt;
&lt;li&gt;Use it when you genuinely need it, and convert back to a DataFrame as early as possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/rdd-programming-guide.html" rel="noopener noreferrer"&gt;RDD Programming Guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Python UDF ladder
&lt;/h3&gt;

&lt;p&gt;This is the single most important diagram in this layer. Every rung is "run my Python function on every row"; the difference is how the rows get to Python.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 JVM executor                           Python worker
             ┌────────────────┐                      ┌────────────────┐
 row UDF     │ row ─► pickle ─┼──── one row ────────►│ unpickle ─► f()│   ~10–100× slower
 @udf        │ row ─► pickle ─┼──── one row ────────►│ unpickle ─► f()│   than native
             └────────────────┘                      └────────────────┘

 Arrow UDF   ┌────────────────┐                      ┌────────────────┐
 @udf(       │ 10,000 rows ───┼─── Arrow batch ─────►│ f() per value  │   same code,
 useArrow)   └────────────────┘                      └────────────────┘   batched transfer

 pandas UDF  ┌────────────────┐                      ┌────────────────┐
 @pandas_udf │ 10,000 rows ───┼─── Arrow batch ─────►│ f(pd.Series)   │   vectorized:
             └────────────────┘                      └────────────────┘   numpy speed

 mapInPandas ┌────────────────┐                      ┌────────────────┐
             │ whole partition┼─── Arrow batches ───►│ f(iter[pd.DF]) │   model.predict()
             └────────────────┘                      └────────────────┘   on a whole batch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Row-at-a-time Python UDF&lt;/strong&gt; (&lt;code&gt;@F.udf&lt;/code&gt;). The rung everyone starts on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@F.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;boolean&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;is_premium&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fare&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;fare&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fare&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_premium&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="c1"&gt;# BatchEvalPython in the plan
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;⚠️ Typically 10–100× slower than the equivalent &lt;code&gt;F.col("fare") &amp;gt; 100&lt;/code&gt;. Worse, Catalyst treats it as a &lt;strong&gt;black box&lt;/strong&gt;: nothing above it in the plan can be pushed down into the scan, so a UDF in a &lt;code&gt;filter&lt;/code&gt; means the scan reads everything. Look for &lt;code&gt;BatchEvalPython&lt;/code&gt; in &lt;code&gt;explain()&lt;/code&gt;; it is the smoking gun.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Arrow-optimized Python UDF&lt;/strong&gt; (Spark 3.5+, &lt;code&gt;@F.udf(..., useArrow=True)&lt;/code&gt;). Same function, same row-by-row semantics, but rows cross the boundary in Arrow batches instead of pickle. A free 2–4× for the price of one keyword argument.&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;@F.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="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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tip_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fare&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;tip&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fare&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fare&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pandas UDF&lt;/strong&gt; (&lt;code&gt;@F.pandas_udf&lt;/code&gt;). Your function receives a &lt;code&gt;pd.Series&lt;/code&gt; (or several) per Arrow batch and returns one. This is where Python logic stops being slow, because pandas and numpy do the inner loop in C.&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;@F.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;zscore&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="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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&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;v&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="o"&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;std&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;&lt;code&gt;mapInPandas&lt;/code&gt; / &lt;code&gt;applyInPandas&lt;/code&gt;.&lt;/strong&gt; Hand an iterator of pandas DataFrames (a whole partition, or a whole group) to a function. The natural home for model scoring: load the model once per partition, call &lt;code&gt;predict&lt;/code&gt; on a batch.&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;score&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;pdf&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;pdf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FEATURES&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;pdf&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;mapInPandas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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;trip_id string, p 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;ul&gt;
&lt;li&gt;All of the Arrow-based rungs still &lt;strong&gt;block pushdown&lt;/strong&gt; for expressions above them. Put filters &lt;em&gt;before&lt;/em&gt; the UDF in the pipeline, and make sure the UDF's inputs are the only columns that survive to that point.&lt;/li&gt;
&lt;li&gt;All of them need &lt;strong&gt;memory outside the JVM heap&lt;/strong&gt; for the Python worker and the Arrow buffers. See &lt;code&gt;memoryOverhead&lt;/code&gt; in Layer 4b. The classic failure is &lt;code&gt;Container killed by YARN for exceeding memory limits&lt;/code&gt; with a heap that is barely used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/api/python/user_guide/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;Apache Arrow in PySpark&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  pandas API on Spark
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;import pyspark.pandas as ps&lt;/code&gt; gives you pandas syntax on a Spark DataFrame. Good for migrating notebooks.&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;pyspark.pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;
&lt;span class="n"&gt;psdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_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;s3a://ridehub/trips/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;psdf&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;zone_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;fare&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;ℹ️ pandas has a sequential index; Spark does not. The default index type builds one, and some operations (&lt;code&gt;iloc&lt;/code&gt;, &lt;code&gt;shift&lt;/code&gt;) collapse to a single partition to do it. Set &lt;code&gt;ps.set_option("compute.default_index_type", "distributed")&lt;/code&gt; unless you need pandas-exact ordering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/api/python/user_guide/pandas_on_spark/index.html" rel="noopener noreferrer"&gt;pandas API on Spark&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scala / Java UDF
&lt;/h3&gt;

&lt;p&gt;Runs inside the JVM, so there is no serialization tax. Still opaque to Catalyst: no pushdown through it, no codegen of the expression itself. Cheaper than any Python rung, more expensive than a built-in function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in functions only
&lt;/h3&gt;

&lt;p&gt;The top of the ladder is not a UDF at all. &lt;code&gt;pyspark.sql.functions&lt;/code&gt; has over 400 functions, and Spark 3.5 added &lt;code&gt;F.call_function&lt;/code&gt; plus higher-order functions (&lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;aggregate&lt;/code&gt;) over arrays and maps. Almost every "I need a UDF for this" has a native expression, and the native expression is visible to Catalyst.&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;df&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;fare&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;100&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;tip_ratio&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;tip&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;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;fare&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;Docs: &lt;a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" rel="noopener noreferrer"&gt;Functions&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spark Connect
&lt;/h3&gt;

&lt;p&gt;Spark 3.4+ splits the client from the driver. Your notebook holds a thin gRPC client; the driver (and the JVM, and the dependency hell) runs on a server. Clients can be upgraded independently, one bad client cannot crash the driver, and &lt;code&gt;pip install pyspark-connect&lt;/code&gt; is all a laptop needs.&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;spark.remote&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sc://spark-connect:15002&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;ℹ️ RDD APIs and &lt;code&gt;SparkContext&lt;/code&gt; calls are not available over Connect. If your code uses &lt;code&gt;sc.parallelize&lt;/code&gt;, it is not a Connect job yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Spark Connect overview&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Thrift server (JDBC / BI)
&lt;/h3&gt;

&lt;p&gt;A long-running SparkSession that speaks HiveServer2's protocol, so Tableau, Looker or &lt;code&gt;beeline&lt;/code&gt; can run SQL against your lake.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; dashboards. The session stays warm, caches survive between queries, and the file index for a table is built once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; one application shared by everyone. Without FAIR scheduler pools (Layer 5), one analyst's 30-minute query starves every dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-distributed-sql-engine.html" rel="noopener noreferrer"&gt;Distributed SQL Engine&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Streaming and its three companions
&lt;/h3&gt;

&lt;p&gt;The DataFrame API over an unbounded source. Micro-batch by default (continuous mode exists but is rarely the right choice), checkpointed, exactly-once with the right sink.&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;events&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;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;rides&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three parts turn a streaming demo into a streaming &lt;em&gt;pipeline&lt;/em&gt;. They only make sense together with Structured Streaming, which is why the playground refuses to activate them without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RocksDB state store.&lt;/strong&gt; Streaming aggregations, stream-stream joins and &lt;code&gt;dropDuplicates&lt;/code&gt; keep state. The default state store keeps it in the JVM heap, which works until the state is bigger than the heap and the executor dies in a GC storm on Friday evening. RocksDB keeps state off-heap on local disk with changelog checkpointing.&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;spark.sql.streaming.stateStore.providerClass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watermark and trigger tuning.&lt;/strong&gt; Without a watermark, a windowed aggregation keeps every window forever, because a late event could still arrive. &lt;code&gt;withWatermark("event_ts", "10 minutes")&lt;/code&gt; tells Spark when a window can be finalised and its state dropped. The trigger interval decides the latency/throughput trade; pick it on purpose.&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="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;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_ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10 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;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_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;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;zone_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;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&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 minute&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;strong&gt;&lt;code&gt;foreachBatch&lt;/code&gt; with an idempotent sink.&lt;/strong&gt; File sinks and Delta/Iceberg are exactly-once by design. A JDBC table or a &lt;code&gt;MERGE&lt;/code&gt; is not, unless you make each micro-batch idempotent, keyed by &lt;code&gt;batchId&lt;/code&gt;, so a replay after a crash does not double-count.&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;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;batch_df&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;b&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;MERGE INTO rides t USING b ON t.id = b.id
                 WHEN MATCHED THEN UPDATE SET * WHEN NOT MATCHED THEN INSERT *&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agg&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;foreachBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&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="n"&gt;chk&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html" rel="noopener noreferrer"&gt;Structured Streaming Programming Guide&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#rocksdb-state-store-implementation" rel="noopener noreferrer"&gt;RocksDB state store&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anything expressible in SQL&lt;/td&gt;
&lt;td&gt;DataFrame API with built-in functions. Always first.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python logic over a column&lt;/td&gt;
&lt;td&gt;pandas UDF; Arrow UDF if the logic is inherently scalar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model scoring&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mapInPandas&lt;/code&gt;, model loaded once per partition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrating a pandas notebook&lt;/td&gt;
&lt;td&gt;pandas API on Spark, with the distributed index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom partitioning or per-partition state in the JVM&lt;/td&gt;
&lt;td&gt;RDD, then back to DataFrame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many notebooks, one cluster&lt;/td&gt;
&lt;td&gt;Spark Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BI tools&lt;/td&gt;
&lt;td&gt;Thrift server + FAIR pools + cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka in, table out, forever&lt;/td&gt;
&lt;td&gt;Structured Streaming + RocksDB + watermark + idempotent sink&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer 3a: Execution engine
&lt;/h2&gt;

&lt;p&gt;The engine is the one part of the stack you cannot leave empty. It is what turns a physical plan into CPU instructions. For fifteen years that meant one thing: Spark's own JVM engine. Since about 2022 there have been real alternatives, and they all work the same way: a Spark plugin intercepts the physical plan and replaces operators it knows how to run with native (C++, Rust or GPU) versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Catalyst physical plan
            │
            ▼
   ┌─────────────────────┐        supported operator?
   │  engine plugin      │──────────────┬──────────────┐
   │  (Gluten / Comet /  │             yes             no
   │   Photon / RAPIDS)  │              │               │
   └─────────────────────┘              ▼               ▼
                               native columnar     JVM row engine
                               operator            (Tungsten codegen)
                                      │               ▲
                                      └── columnar→row conversion ──┘
                                          (the hidden cost of every fallback)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Catalyst + Tungsten (JVM): stock Spark
&lt;/h3&gt;

&lt;p&gt;Whole-stage code generation (operators fused into one Java method per stage), a vectorized Parquet/ORC reader, the unified memory manager, and &lt;code&gt;UnsafeRow&lt;/code&gt; binary rows that skip Java object overhead.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; everything, everywhere, with every UDF and every data source. The safest default and the baseline every native engine benchmarks against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; CPU-bound SQL on wide tables runs 2–4× slower than the native engines. GC pauses on large heaps. That is the whole pitch of the alternatives.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Gluten + Velox
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://gluten.apache.org/" rel="noopener noreferrer"&gt;Apache Gluten&lt;/a&gt; (incubating) plugs Meta's &lt;a href="https://velox-lib.io/" rel="noopener noreferrer"&gt;Velox&lt;/a&gt; C++ library under the Spark API. It also ships a columnar shuffle manager so data stays in Arrow-like columnar batches across the exchange.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; CPU-bound SQL and DataFrame ETL. Typical published numbers are 2–3× on TPC-H/DS-style workloads; the playground models it as about 2×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; ℹ️ operators and expressions Velox does not support fall back to the JVM, with a columnar-to-row conversion on each side of the fallback. A Python UDF in the middle of a Gluten plan is slower than the same UDF in a plain JVM plan. It also needs &lt;strong&gt;off-heap memory&lt;/strong&gt; configured, because Velox allocates outside the heap; forgetting &lt;code&gt;spark.memory.offHeap.size&lt;/code&gt; is the most common first-day failure.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.plugins&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.gluten.GlutenPlugin&lt;/span&gt;
&lt;span class="py"&gt;spark.shuffle.manager&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.shuffle.sort.ColumnarShuffleManager&lt;/span&gt;
&lt;span class="py"&gt;spark.memory.offHeap.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.memory.offHeap.size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;16g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Apache Comet (DataFusion)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://datafusion.apache.org/comet/" rel="noopener noreferrer"&gt;Apache DataFusion Comet&lt;/a&gt; is the Rust-based equivalent from the Arrow project: a native Parquet scan, native operators and a native columnar shuffle, for Spark 3.4 through 4.0.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the same problem as Gluten with a smaller runtime, Rust memory safety and tight Arrow integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; ℹ️ operator coverage is still growing. Check the compatibility guide for your plan, and run &lt;code&gt;explain()&lt;/code&gt;: Comet-executed operators appear with a &lt;code&gt;Comet&lt;/code&gt; prefix, so you can see exactly where it fell back.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.plugins&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.CometPlugin&lt;/span&gt;
&lt;span class="py"&gt;spark.comet.exec.shuffle.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.memory.offHeap.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.memory.offHeap.size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Photon
&lt;/h3&gt;

&lt;p&gt;Databricks' proprietary C++ engine. On Databricks it is a checkbox; off Databricks it does not exist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the same CPU-bound SQL problem, with the best integration of the four (Delta, SQL warehouses, streaming support) and no configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; platform lock-in and a premium DBU rate. ℹ️ UDFs and RDDs still fall back to the JVM, so a Photon cluster running a row-at-a-time Python UDF is paying the premium for nothing. Photon on YARN is not a configuration; the playground calls this a &lt;em&gt;platform conflict&lt;/em&gt; and tells you the job runs nowhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://docs.databricks.com/en/compute/photon.html" rel="noopener noreferrer"&gt;Photon&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  RAPIDS Accelerator (GPU)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://nvidia.github.io/spark-rapids/" rel="noopener noreferrer"&gt;NVIDIA's RAPIDS Accelerator for Apache Spark&lt;/a&gt; runs scans, joins, aggregates and sorts on GPUs, optionally with a UCX-based GPU-to-GPU shuffle.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; very wide, very numeric ETL and joins where the GPU's memory bandwidth dwarfs the CPU's. On the right workload it is the fastest thing here by a wide margin.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Costs:&lt;/strong&gt; GPU memory is small (tens of GB) and unforgiving; wide strings, UDFs and unsupported expressions spill or fall back, and the fallbacks are expensive because the data has to come back off the GPU. GPU nodes cost several times more per hour, so the speed-up has to be large to pay for itself. Benchmark your plan before committing a budget. Pair it with &lt;strong&gt;stage-level scheduling&lt;/strong&gt; (Layer 5) so only the stage that benefits runs on GPU executors.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.plugins&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;com.nvidia.spark.SQLPlugin&lt;/span&gt;
&lt;span class="py"&gt;spark.rapids.sql.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.resource.gpu.amount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;spark.task.resource.gpu.amount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0.25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing an engine
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;First choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anything with Python UDFs or RDDs&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;td&gt;native engines fall back anyway and pay a conversion each way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU-bound SQL/DataFrame ETL on open-source Spark&lt;/td&gt;
&lt;td&gt;Gluten or Comet, with off-heap sized&lt;/td&gt;
&lt;td&gt;2–3× for the cost of a plugin and some memory config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, on Databricks&lt;/td&gt;
&lt;td&gt;Photon&lt;/td&gt;
&lt;td&gt;zero configuration, best Delta integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wide numeric ETL, GPU nodes already available&lt;/td&gt;
&lt;td&gt;RAPIDS, scoped with stage-level scheduling&lt;/td&gt;
&lt;td&gt;memory bandwidth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;JVM (or Photon on Databricks)&lt;/td&gt;
&lt;td&gt;the open-source native engines have thin streaming support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A rule that holds across all of them: &lt;strong&gt;run &lt;code&gt;explain()&lt;/code&gt; and count the fallbacks&lt;/strong&gt;. A native engine that executes 95% of the plan is a win; one that executes 40% of it, with conversions at every boundary, is slower than stock Spark.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 3b: Planner, joins and AQE
&lt;/h2&gt;

&lt;p&gt;This is the layer the previous article spent most of its time in, so here is the compressed version with every knob attached. Catalyst produces a physical plan from statistics it can see at planning time; &lt;strong&gt;Adaptive Query Execution&lt;/strong&gt; re-plans at every shuffle boundary using the sizes it actually observed. Most of the parts below either feed the planner better information or tell AQE what it is allowed to change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adaptive Query Execution
&lt;/h3&gt;

&lt;p&gt;On by default since Spark 3.2. At each shuffle boundary, AQE looks at the real map output sizes and may coalesce partitions, switch join strategies or split skewed partitions. In &lt;code&gt;explain()&lt;/code&gt; you see &lt;code&gt;AdaptiveSparkPlan isFinalPlan=false&lt;/code&gt; before the first action and &lt;code&gt;isFinalPlan=true&lt;/code&gt; after.&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;spark.sql.adaptive.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three sub-features sit under it. They do nothing without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coalesce shuffle partitions.&lt;/strong&gt; The default &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; is 200, whatever the data size. AQE merges small post-shuffle partitions up to an advisory size, so a 1 GB aggregate ends up in 16 reducers instead of 200 tiny ones, and the write produces 16 files instead of 200.&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;spark.sql.adaptive.coalescePartitions.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.advisoryPartitionSizeInBytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;64m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Skew join handling.&lt;/strong&gt; If one post-shuffle partition is more than five times the median &lt;em&gt;and&lt;/em&gt; more than 256 MB, AQE splits it into several reader tasks on the big side and duplicates the matching partition on the small side. The three-hour straggler becomes twelve two-minute tasks. This is the single most valuable automatic fix in modern Spark.&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;spark.sql.adaptive.skewJoin.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.skewJoin.skewedPartitionFactor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;256m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Runtime broadcast conversion.&lt;/strong&gt; The planner chose a sort-merge join because the dimension's &lt;em&gt;file size&lt;/em&gt; looked big; after the shuffle, the dimension's &lt;em&gt;actual&lt;/em&gt; bytes are 4 MB. AQE converts to a broadcast hash join on the fly, and the local shuffle reader lets the big side read its own map outputs instead of a real exchange.&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;spark.sql.adaptive.autoBroadcastJoinThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.localShuffleReader.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution" rel="noopener noreferrer"&gt;Adaptive Query Execution&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Spark picks a join
&lt;/h3&gt;

&lt;p&gt;Before the knobs, the decision tree, because every knob below moves you to a different leaf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 is one side ≤ autoBroadcastJoinThreshold (10 MB)
                 or hinted BROADCAST?
                        │
            yes ────────┴──────── no
             │                     │
   BroadcastHashJoin       equi-join and preferSortMergeJoin=false
   · build side collected  and one side small enough per partition?
     on the DRIVER, sent             │
     to every executor      yes ─────┴───── no
   · zero Exchange for       │                │
     the big side     ShuffledHashJoin   SortMergeJoin
                      · shuffle both     · shuffle both, sort both
                      · hash the small   · spills gracefully
                        side per task    · the default for big-to-big
                                              │
                                   both sides bucketed on the key,
                                   same bucket count?
                                              │
                                     yes ─────┴───── no
                                      │                │
                              SortMergeJoin         SortMergeJoin
                              with NO Exchange      with 2 Exchanges
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Broadcast hint on small tables
&lt;/h3&gt;

&lt;p&gt;Tell the planner the dimension is small instead of letting it guess from directory size. The directory size estimate is what fooled the planner in the opening story: a 50 MB table that compressed to 12 MB on disk still looked bigger than the 10 MB threshold.&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;trips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zone_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;# or in SQL:  SELECT /*+ BROADCAST(z) */ ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; a sort-merge join with two exchanges becomes a broadcast join with zero exchanges on the fact side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; the build side is collected on the driver and sent to every executor. Hint a table that turns out to be 3 GB and you have found the fastest way to kill a driver.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;autoBroadcastJoinThreshold = 100 MB&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Raise the automatic threshold from 10 MB to 100 MB, so moderately sized dimensions broadcast without hints.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; a star schema with a handful of 20–80 MB dimensions, none of which anyone remembered to hint.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;strong&gt;Costs:&lt;/strong&gt; 100 MB of Parquet can be a gigabyte of Java objects once decompressed and hashed on the driver. Raise &lt;code&gt;spark.driver.memory&lt;/code&gt; at the same time, every time.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.autoBroadcastJoinThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;100m&lt;/span&gt;
&lt;span class="py"&gt;spark.driver.memory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧨 &lt;code&gt;autoBroadcastJoinThreshold = 2 GB&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"The executors have plenty of RAM."&lt;/em&gt; They do; the &lt;strong&gt;driver&lt;/strong&gt; does not, and the driver is where every broadcast relation is built first. Broadcast builds are capped at 8 GB and 512 million rows, and &lt;code&gt;spark.sql.broadcastTimeout&lt;/code&gt; defaults to 300 seconds. A 2 GB threshold works on the quiet day the setting was tested and dies on the first busy one with &lt;code&gt;Could not execute broadcast in 300 secs&lt;/code&gt; or a driver OOM, taking every job in the application with it.&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;spark.sql.autoBroadcastJoinThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2g     # don't&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dynamic partition pruning
&lt;/h3&gt;

&lt;p&gt;On by default since 3.0. In a star-schema join where the &lt;em&gt;dimension&lt;/em&gt; is filtered (&lt;code&gt;calendar.quarter = 'Q1'&lt;/code&gt;), DPP runs the dimension side first, collects the matching keys, and uses them to prune the &lt;em&gt;fact&lt;/em&gt; table's partitions before scanning. In &lt;code&gt;explain()&lt;/code&gt; the fact scan shows &lt;code&gt;PartitionFilters: [dynamicpruningexpression(...)]&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the "the filter is on the calendar table, so the 5 TB fact scan reads two years" problem. Requires the fact table to be partitioned by the join key.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.optimizer.dynamicPartitionPruning.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cost-based optimizer and statistics
&lt;/h3&gt;

&lt;p&gt;Catalyst's default size estimates come from file sizes. &lt;code&gt;ANALYZE TABLE&lt;/code&gt; stores row counts, distinct counts and min/max per column in the catalog; with CBO on, the planner reorders multi-way joins and estimates join output sizes properly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; wrong join order in three-way-plus joins, and dimensions that broadcast only after a filter because the planner now knows how selective the filter is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; statistics go stale, and &lt;code&gt;ANALYZE&lt;/code&gt; on a 2 TB table is itself a job. Table formats (Delta, Iceberg) carry per-file statistics that remove most of the need.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.cbo.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.cbo.joinReorder.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&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="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;ANALYZE TABLE trips COMPUTE STATISTICS FOR ALL COLUMNS&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;h3&gt;
  
  
  Prefer shuffled hash join
&lt;/h3&gt;

&lt;p&gt;By default Spark prefers sort-merge over shuffled hash because SMJ spills gracefully. Turning the preference off skips the sort when one side is much smaller.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ℹ️ The build side of a shuffled hash join must fit in memory per task. Keep it for clearly asymmetric joins where you have measured the gain.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.join.preferSortMergeJoin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key salting for hot keys
&lt;/h3&gt;

&lt;p&gt;The manual skew fix, from before AQE existed and still needed when the skew is in an aggregation rather than a join, or when the hot key is a NULL. Append a random suffix to the hot key on the big side, explode the small side into every suffix, join on both.&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;big&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;big&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;salt&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;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&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="n"&gt;small&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;small&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;salt&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;explode&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;array&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="n"&gt;i&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;i&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;16&lt;/span&gt;&lt;span class="p"&gt;)])))&lt;/span&gt;
&lt;span class="n"&gt;big&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;small&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salt&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; code complexity, the small side grows 16×, and the next hot key will not announce itself. Prefer AQE skew handling when the shape allows it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Bucketed joins
&lt;/h3&gt;

&lt;p&gt;Both tables written with &lt;code&gt;bucketBy(N, key)&lt;/code&gt; into a metastore table. Rows with the same key hash land in the same bucket number on both sides, so the join needs no Exchange at all. With Iceberg, the same idea is called a &lt;em&gt;storage-partitioned join&lt;/em&gt; (Spark 3.3+).&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;df&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;bucketBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;sortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;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;trips_b&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the recurring 5 TB-to-5 TB join on the same key, every night. The shuffle is paid once at write time instead of every read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; both sides must agree on bucket count and column, the write is a shuffle, and plain Parquet directories cannot carry bucketing metadata (hence the Hive metastore or Iceberg prerequisite).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tune &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The default 200 reducers is right for roughly 25 GB of shuffle at 128 MB each. For 1.2 TB of shuffle it means 6 GB per reducer and spills everywhere; for 200 MB it means 200 one-megabyte files. Size it to the data, or let AQE coalescing handle the small 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="py"&gt;spark.sql.shuffle.partitions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1200      # ≈ shuffle bytes / 128 MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧨 &lt;code&gt;spark.sql.shuffle.partitions = 1&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"I only want one output file."&lt;/em&gt; Now every aggregation and every join in the application runs on a single task, on a single core, with a single task's memory. Use &lt;code&gt;coalesce(1)&lt;/code&gt; on the final write if you must have one file, and leave the shuffle alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pushdown-friendly predicates
&lt;/h3&gt;

&lt;p&gt;Not a config: a habit. The planner can only prune partitions and push filters into Parquet when the predicate is a plain comparison on the partition column or a stats-bearing column.&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;# ✓ prunes partitions: PartitionFilters: [dt &amp;gt;= 2026-03-01, dt &amp;lt;= 2026-03-31]
&lt;/span&gt;&lt;span class="n"&gt;trips&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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-01&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;2026-03-31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# ✗ scans everything: the filter is on a derived expression over a different column
&lt;/span&gt;&lt;span class="n"&gt;trips&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;month&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_ts&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;explain()&lt;/code&gt; for &lt;code&gt;PartitionFilters: []&lt;/code&gt; and &lt;code&gt;PushedFilters: []&lt;/code&gt;. Empty brackets on a partitioned table are the most expensive two characters in Spark.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Lever&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200 tiny output files&lt;/td&gt;
&lt;td&gt;AQE coalesce, or &lt;code&gt;coalesce()&lt;/code&gt; before the write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One task runs for hours, the rest finish in minutes&lt;/td&gt;
&lt;td&gt;AQE skew join; salting if it is an aggregation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SortMergeJoin&lt;/code&gt; against a table you know is small&lt;/td&gt;
&lt;td&gt;broadcast hint; or CBO stats so the planner knows too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Star join scans the whole fact table&lt;/td&gt;
&lt;td&gt;DPP + partition the fact by the join key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same big join every night&lt;/td&gt;
&lt;td&gt;bucket both tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PartitionFilters: []&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rewrite the predicate on the partition column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver OOM during a join&lt;/td&gt;
&lt;td&gt;your broadcast threshold is too high&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer 4a: Shuffle
&lt;/h2&gt;

&lt;p&gt;A shuffle is the moment Spark's "every task is independent" model breaks: to group by key, every reducer needs rows from every mapper. Where the intermediate files live decides whether losing an executor costs you a task or a stage, and whether you can release executors at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        mappers write                       reducers read
   ┌──────────────┐                     ┌──────────────┐
   │ executor A   │  shuffle files      │ executor C   │
   │ task 1 ──────┼──► [p0 p1 p2 … ]    │ reducer p0 ◄─┼── fetches p0 from A and B
   └──────────────┘        │            └──────────────┘
   ┌──────────────┐        │  WHERE do these live?
   │ executor B   │        │   (a) executor's local disk  → die with the executor
   │ task 2 ──────┼──► [p0 p1 p2 … ]    (b) node's shuffle service → survive the executor
   └──────────────┘                      (c) remote shuffle cluster → survive the NODE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  External shuffle service (YARN, Standalone)
&lt;/h3&gt;

&lt;p&gt;A long-running process per node that serves shuffle files on behalf of executors that have exited.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; executors can be removed mid-job (dynamic allocation) without losing the map outputs they produced. Also survives an executor crash: the stage does not need to be recomputed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs:&lt;/strong&gt; YARN (as a NodeManager auxiliary service) or Standalone (in the worker). ℹ️ Kubernetes has no equivalent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does not solve:&lt;/strong&gt; losing the &lt;em&gt;node&lt;/em&gt;. The files are still on that node's disk.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.shuffle.service.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shuffle tracking (Kubernetes)
&lt;/h3&gt;

&lt;p&gt;The Kubernetes workaround for dynamic allocation without a shuffle service: Spark tracks which executors hold shuffle files that are still needed, and refuses to release those.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; scale-&lt;em&gt;up&lt;/em&gt; on Kubernetes, and scale-down for executors whose shuffle data has been consumed.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;strong&gt;Costs:&lt;/strong&gt; scale-down is slow and partial. An executor that produced map output for a long stage stays alive until that stage's reducers have all read it. With a 120 s timeout you still pay for idle pods.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.dynamicAllocation.shuffleTracking.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.shuffleTracking.timeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;120s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Apache Celeborn (remote shuffle)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://celeborn.apache.org/" rel="noopener noreferrer"&gt;Apache Celeborn&lt;/a&gt; runs a separate cluster of shuffle servers. Mappers &lt;em&gt;push&lt;/em&gt; their output to Celeborn workers, which merge it per reducer; executors keep nothing on local disk.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; three problems at once. Executors become &lt;strong&gt;stateless&lt;/strong&gt;, so dynamic allocation can release any of them instantly and spot nodes can vanish without triggering recomputation. Reducers do a few large sequential reads instead of thousands of small random ones. And Kubernetes gets a proper shuffle service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; another cluster to run, size and monitor, and an extra network hop for every shuffle byte. It pays for itself from the first spot interruption on.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.shuffle.manager&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.shuffle.celeborn.SparkShuffleManager&lt;/span&gt;
&lt;span class="py"&gt;spark.celeborn.master.endpoints&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;celeborn-master:9097&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Apache Uniffle (remote shuffle)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://uniffle.apache.org/" rel="noopener noreferrer"&gt;Apache Uniffle&lt;/a&gt;, from Tencent, is the other Apache remote shuffle service with the same architecture (coordinator + shuffle servers) and the same benefits. Pick one; they do not coexist in one application.&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;spark.shuffle.manager&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.shuffle.RssShuffleManager&lt;/span&gt;
&lt;span class="py"&gt;spark.rss.coordinator.quorum&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rss-coord:19999&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Push-based shuffle (Magnet)
&lt;/h3&gt;

&lt;p&gt;LinkedIn's contribution to Spark 3.2: mappers push blocks to the external shuffle service, which merges them per reducer ahead of time. Reducers read one merged file instead of N small ones.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the small-random-read problem on very large shuffles, without a separate shuffle cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs:&lt;/strong&gt; YARN with the external shuffle service. Not available on Kubernetes or Databricks.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.shuffle.push.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.shuffle.push.server.mergedShuffleFileManagerImpl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.network.shuffle.RemoteBlockPushResolver&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  zstd shuffle compression
&lt;/h3&gt;

&lt;p&gt;The shuffle codec defaults to lz4. zstd produces 20–30% smaller shuffle files and spills for slightly more CPU. On a network- or disk-bound shuffle that is a net win.&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;spark.io.compression.codec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;zstd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ⚠️ Disabling shuffle compression
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"Compression costs CPU."&lt;/em&gt; It does: about 1% of the task time with lz4. Turning it off costs 3–5× the network and disk traffic. Shuffle stages are almost never CPU-bound; leave it on.&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;spark.shuffle.compress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false            # don't&lt;/span&gt;
&lt;span class="py"&gt;spark.shuffle.spill.compress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false      # really don't&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fast local disks
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.local.dir&lt;/code&gt; is where shuffle files and spills go. On cloud VMs the default is often the boot disk: a network-attached volume with a few thousand IOPS shared by everything. Point it at local NVMe and list several disks so Spark stripes across them.&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;spark.local.dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/mnt/nvme0,/mnt/nvme1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;ℹ️ On Kubernetes and serverless platforms this is a pod spec or platform setting, not a Spark conf.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Dataproc Enhanced Flexibility Mode
&lt;/h3&gt;

&lt;p&gt;Dataproc-specific: shuffle data for jobs on preemptible secondary workers is written to the primary (on-demand) workers, so a preempted secondary loses nothing. The same idea as a remote shuffle service, built into the platform.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://cloud.google.com/dataproc/docs/concepts/configuring-clusters/flex" rel="noopener noreferrer"&gt;Dataproc Enhanced Flexibility Mode&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bigger fetch buffers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.reducer.maxSizeInFlight&lt;/code&gt; (default 48 MB) bounds how much a reducer requests at once. On high-latency networks, doubling it reduces round trips. Pair with more fetch retries for flaky networks.&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;spark.reducer.maxSizeInFlight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;96m&lt;/span&gt;
&lt;span class="py"&gt;spark.shuffle.io.maxRetries&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;For dynamic allocation&lt;/th&gt;
&lt;th&gt;For spot nodes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YARN&lt;/td&gt;
&lt;td&gt;external shuffle service (+ Magnet for huge shuffles)&lt;/td&gt;
&lt;td&gt;Celeborn / Uniffle, or EFM on Dataproc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;external shuffle service&lt;/td&gt;
&lt;td&gt;Celeborn / Uniffle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;shuffle tracking (slow scale-down) or Celeborn / Uniffle&lt;/td&gt;
&lt;td&gt;Celeborn / Uniffle + decommissioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And everywhere: zstd on, compression never off, local disks that are actually local.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 4b: Memory, caching and serialization
&lt;/h2&gt;

&lt;p&gt;Executor memory is not one number. It is a heap split by the unified memory manager, plus memory &lt;em&gt;outside&lt;/em&gt; the heap that Python workers, Arrow buffers and native engines use, plus the container limit that YARN or Kubernetes enforces. Most memory incidents are a mismatch between those three.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  container limit (what YARN / K8s kills you for exceeding)
  ┌─────────────────────────────────────────────────────────────────────┐
  │ spark.executor.memory (JVM heap)                 │ memoryOverhead   │
  │ ┌──────────────────────────────────┬───────────┐ │ (off-JVM:        │
  │ │ unified region (60% of heap-300MB)│ user +    │ │  Python workers, │
  │ │ ┌────────────────┬─────────────┐ │ reserved  │ │  Arrow buffers,  │
  │ │ │ execution      │ storage     │ │           │ │  native engine,  │
  │ │ │ (shuffle, sort,│ (cache,     │ │           │ │  thread stacks)  │
  │ │ │  join, agg)    │  broadcast) │ │           │ │                  │
  │ │ │  ◄── borrows either way ──►  │ │           │ │ + offHeap.size   │
  │ │ └────────────────┴─────────────┘ │           │ │   if enabled     │
  │ └──────────────────────────────────┴───────────┘ │                  │
  └─────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Right-sized executors (4–5 cores)
&lt;/h3&gt;

&lt;p&gt;The hardware provisioning guide's advice has not changed in a decade: 4–5 cores and 16–32 GB per executor. Enough parallelism per JVM to amortise overhead, small enough that GC pauses stay short and a lost executor loses little work. Above 5 cores per executor, HDFS and S3 client throughput stops scaling anyway.&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;spark.executor.cores&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.memory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20g&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.instances&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ⚠️ Fat executors (32 cores, 200 GB)
&lt;/h3&gt;

&lt;p&gt;One executor per node sounds efficient. In practice: multi-second GC pauses on a 200 GB heap, 32 tasks lost when one executor dies, and object-store clients saturating at a fraction of the cores. Spark Connect and native engines soften this a little; they do not remove it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory overhead for Python and native code
&lt;/h3&gt;

&lt;p&gt;Python workers, Arrow buffers and Velox/DataFusion/GPU allocations all live outside the JVM heap. &lt;code&gt;memoryOverhead&lt;/code&gt; (default 10% of executor memory, minimum 384 MB) is the only room they have. The classic failure is &lt;code&gt;Container killed by YARN for exceeding memory limits. 20.5 GB of 20 GB physical memory used&lt;/code&gt; while the heap graph shows 40% free.&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;spark.executor.memoryOverhead&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4g&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.pyspark.memory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4g        # caps each Python worker; counts toward the container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Off-heap memory
&lt;/h3&gt;

&lt;p&gt;Lets Tungsten allocate execution memory with &lt;code&gt;sun.misc.Unsafe&lt;/code&gt; outside the heap, so sort and hash buffers do not churn the garbage collector. &lt;strong&gt;Required&lt;/strong&gt; by Gluten and Comet. On a plain JVM job it is a modest GC win on large executors.&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;spark.memory.offHeap.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.memory.offHeap.size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8g             # counts toward the container limit too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;cache()&lt;/code&gt; a reused DataFrame (&lt;code&gt;MEMORY_AND_DISK&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;If an intermediate is read more than once, materialise it. &lt;code&gt;cache()&lt;/code&gt; uses &lt;code&gt;MEMORY_AND_DISK&lt;/code&gt;: blocks that do not fit in storage memory spill to local disk instead of being recomputed.&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;march&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;march&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;# materialises
&lt;/span&gt;&lt;span class="n"&gt;march&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;zone_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;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;span class="c1"&gt;# reads the cache
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; storage memory competes with execution memory; a big cache can push shuffles into spilling. Cache what is &lt;em&gt;reused&lt;/em&gt;, not what is &lt;em&gt;big&lt;/em&gt;. And remember that cached data is per-application: a nightly batch job caching its only pass gains nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚠️ &lt;code&gt;persist(MEMORY_ONLY)&lt;/code&gt; on a huge dataset
&lt;/h3&gt;

&lt;p&gt;2 TB into 160 GB of RAM. &lt;code&gt;MEMORY_ONLY&lt;/code&gt; never spills; when storage memory is short it evicts whole partitions and &lt;strong&gt;recomputes them from lineage&lt;/strong&gt; the next time they are read. The job gets slower than with no cache at all, and the Storage tab shows "27% cached" forever. Use &lt;code&gt;MEMORY_AND_DISK&lt;/code&gt;, or do not cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;MEMORY_ONLY_SER&lt;/code&gt; and Kryo
&lt;/h3&gt;

&lt;p&gt;Serialized cache blocks are 2–5× smaller than deserialized Java objects, at the cost of CPU on every read. With Kryo registered for your classes, smaller still. This matters for RDDs and Datasets of case classes; DataFrames are already stored as compact &lt;code&gt;UnsafeRow&lt;/code&gt;s, so the gain there is small.&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;spark.serializer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.serializer.KryoSerializer&lt;/span&gt;
&lt;span class="py"&gt;spark.kryo.registrationRequired&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Arrow for &lt;code&gt;toPandas()&lt;/code&gt; and &lt;code&gt;createDataFrame()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Without Arrow, &lt;code&gt;toPandas()&lt;/code&gt; pickles rows one at a time through the driver. With it, the JVM ships columnar batches that pandas ingests directly. Minutes become seconds. There is no reason to leave this off in Spark 3.x.&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;spark.sql.execution.arrow.pyspark.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.execution.arrow.maxRecordsPerBatch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;spark.driver.maxResultSize&lt;/code&gt; sized on purpose
&lt;/h3&gt;

&lt;p&gt;A guard rail. The default 1 GB limit makes a careless &lt;code&gt;collect()&lt;/code&gt; fail fast with a clear error instead of filling the driver heap and taking the application down. Raise it only as far as the driver can actually hold.&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;spark.driver.maxResultSize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧨 &lt;code&gt;collect()&lt;/code&gt; / &lt;code&gt;toPandas()&lt;/code&gt; the whole table
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"I just want it in pandas."&lt;/em&gt; Every row lands in &lt;strong&gt;driver&lt;/strong&gt; memory, after being serialized through it. &lt;code&gt;trips.toPandas()&lt;/code&gt; on 3.2 billion rows is a driver OOM with extra steps. Aggregate first, sample first, or write to storage and read the output with pandas.&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;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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="c1"&gt;# 3.2 billion rows → one JVM. Don't.
&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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;groupBy&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="nf"&gt;toPandas&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# a few thousand rows. Fine.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bigger driver (16 GB)
&lt;/h3&gt;

&lt;p&gt;The driver holds the file index (16,000 &lt;code&gt;FileStatus&lt;/code&gt; objects for &lt;em&gt;trips&lt;/em&gt;; millions for a small-files disaster), every broadcast build, every &lt;code&gt;collect()&lt;/code&gt; result and the plans themselves. 1 GB is the default; 8–16 GB is normal for a production job touching large tables.&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;spark.driver.memory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;16g&lt;/span&gt;
&lt;span class="py"&gt;spark.driver.cores&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;checkpoint()&lt;/code&gt; long lineages
&lt;/h3&gt;

&lt;p&gt;Iterative jobs (graph algorithms, 200-round feature pipelines) build lineages hundreds of stages deep. If a partition is lost, Spark replays the lineage to rebuild it; two hundred stages later you wish it had not. &lt;code&gt;checkpoint()&lt;/code&gt; writes the DataFrame to reliable storage and truncates the lineage there. Structured Streaming checkpoints for the same reason, automatically.&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="n"&gt;sparkContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCheckpointDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3a://ridehub/chk/&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="o"&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;checkpoint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  G1GC with region tuning
&lt;/h3&gt;

&lt;p&gt;G1 is the default collector on JDK 11+ and is right for almost everyone. Tune it only when the Spark UI's &lt;em&gt;GC Time&lt;/em&gt; column exceeds roughly 10% of task time; the first knob is starting concurrent collection earlier.&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;spark.executor.extraJavaOptions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Lever&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Container killed by YARN for exceeding memory limits&lt;/code&gt;, heap mostly free&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;memoryOverhead&lt;/code&gt; (Python / native / Arrow)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long GC pauses, executors "lost" with no error&lt;/td&gt;
&lt;td&gt;smaller executors, off-heap, G1 tuning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver OOM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxResultSize&lt;/code&gt; guard, no whole-table &lt;code&gt;collect()&lt;/code&gt;, bigger driver, lower broadcast threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache shows partial, job slower than uncached&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MEMORY_ONLY&lt;/code&gt; → &lt;code&gt;MEMORY_AND_DISK&lt;/code&gt;, or stop caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;toPandas()&lt;/code&gt; takes minutes on small data&lt;/td&gt;
&lt;td&gt;Arrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterative job's recovery replays everything&lt;/td&gt;
&lt;td&gt;&lt;code&gt;checkpoint()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/tuning.html#memory-management-overview" rel="noopener noreferrer"&gt;Tuning Spark: memory management&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/hardware-provisioning.html" rel="noopener noreferrer"&gt;Hardware provisioning&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/rdd-programming-guide.html#rdd-persistence" rel="noopener noreferrer"&gt;RDD persistence&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 5: Scheduling and resilience
&lt;/h2&gt;

&lt;p&gt;Layers 1–4 decide how fast the job runs when nothing goes wrong. This layer decides what happens at 03:40 when a spot node gets its two-minute notice, when one task is ten times slower than its siblings, and when S3 returns its fifth 503 of the night.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic allocation
&lt;/h3&gt;

&lt;p&gt;Add executors when tasks queue, release them when idle. The difference between paying for 50 executors all night and paying for 50 during the join and 4 during the write.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Needs&lt;/strong&gt; somewhere safe for shuffle files, or releasing an executor would destroy map output that a later stage needs. On YARN or Standalone: the external shuffle service. On Kubernetes: shuffle tracking or a remote shuffle service. On serverless: built in. ℹ️ Spark refuses to start with dynamic allocation enabled and none of these present; the playground models it as an inactive component with an error.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.dynamicAllocation.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.minExecutors&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.maxExecutors&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.executorIdleTimeout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/job-scheduling.html#dynamic-resource-allocation" rel="noopener noreferrer"&gt;Dynamic resource allocation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speculative execution
&lt;/h3&gt;

&lt;p&gt;When 90% of a stage's tasks are done and a straggler is running 3× longer than the median, launch a copy on another executor and take whichever finishes first.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; slow &lt;em&gt;nodes&lt;/em&gt; (a bad disk, a noisy neighbour). It does &lt;strong&gt;not&lt;/strong&gt; solve skew: a copy of the task that holds 30% of the keys is just as slow.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;strong&gt;Costs:&lt;/strong&gt; duplicated side effects. Two copies of a task writing to a non-idempotent sink (a JDBC insert, a &lt;code&gt;foreach&lt;/code&gt; that calls an API) means duplicate rows. Safe with file commit protocols and table formats; dangerous with everything else.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.speculation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.speculation.multiplier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;spark.speculation.quantile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0.9&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  FAIR scheduler pools
&lt;/h3&gt;

&lt;p&gt;Inside one application, jobs run FIFO by default: the first notebook cell to submit takes every slot until it finishes. FAIR mode shares slots between pools by weight, so a dashboard query in the &lt;code&gt;dashboards&lt;/code&gt; pool gets its 20% even while a 30-minute backfill runs in &lt;code&gt;batch&lt;/code&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;spark.scheduler.mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;FAIR&lt;/span&gt;
&lt;span class="py"&gt;spark.scheduler.allocation.file&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;fairscheduler.xml&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="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;setLocalProperty&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.scheduler.pool&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;dashboards&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;ul&gt;
&lt;li&gt;This is scheduling &lt;em&gt;within&lt;/em&gt; an application. Between applications, that is the cluster manager's queue system (YARN queues, Kubernetes namespaces).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/job-scheduling.html#scheduling-within-an-application" rel="noopener noreferrer"&gt;Scheduling within an application&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;spark.locality.wait = 0&lt;/code&gt; on object storage
&lt;/h3&gt;

&lt;p&gt;Spark waits up to 3 seconds per locality level (process, node, rack) for a slot near the data before scheduling a task elsewhere. On HDFS that wait finds a node-local replica and saves a network read. On S3 or GCS there are no local replicas; every byte is a network read; the wait is pure delay. Three seconds times a few locality levels times a few hundred scheduling rounds is a noticeable chunk of a short job.&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;spark.locality.wait&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0s        # object storage only; keep the default on HDFS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Stage-level scheduling
&lt;/h3&gt;

&lt;p&gt;Spark 3.1+ lets a job request a different executor profile for one stage. The feature pipeline runs on cheap 4-core executors until the model-scoring stage, which runs on 8-core, 64 GB executors with a GPU, and then the job goes back to cheap ones.&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.resource&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ResourceProfileBuilder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ExecutorResourceRequests&lt;/span&gt;
&lt;span class="n"&gt;rp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ResourceProfileBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExecutorResourceRequests&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;cores&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="nf"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64g&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpu&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withResources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mapPartitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Needs:&lt;/strong&gt; YARN or Kubernetes with dynamic allocation (the new executors have to come from somewhere). Scoped to the RDD API until recent versions; check your Spark version for DataFrame support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/configuration.html#stage-level-scheduling-overview" rel="noopener noreferrer"&gt;Stage level scheduling overview&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generous task retries
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.task.maxFailures&lt;/code&gt; (default 4) is how many times one task may fail before the stage, and the job, fails. On cloud storage and spot nodes, transient failures are normal: an S3 503 &lt;code&gt;SlowDown&lt;/code&gt;, a preempted node mid-task, a shuffle fetch from an executor that just died. Eight attempts with the default back-off costs seconds when it matters and nothing when it does not.&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;spark.task.maxFailures&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;spark.stage.maxConsecutiveAttempts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧨 &lt;code&gt;spark.task.maxFailures = 1&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"Fail fast."&lt;/em&gt; One throttled S3 request, three hours into the job, and the job is gone. This is not fail-fast; it is fail-often. If the goal is to stop retrying a genuinely broken task, the default 4 already does that in under a minute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Graceful decommissioning
&lt;/h3&gt;

&lt;p&gt;Spark 3.1+. When a node is about to be taken away (spot interruption notice, autoscaler scale-in, Kubernetes drain), Spark can stop scheduling new tasks on it and &lt;strong&gt;migrate its shuffle blocks and cached RDD blocks&lt;/strong&gt; to other executors before it dies. Without this, a preempted node means recomputing every stage that produced data on it.&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;spark.decommission.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.storage.decommission.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.storage.decommission.shuffleBlocks.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.storage.decommission.rddBlocks.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Works best when there is time (the 2-minute spot notice is enough for most shuffle outputs) and somewhere to put the blocks. With a remote shuffle service there is nothing to migrate, which is even better.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/configuration.html#:~:text=spark.decommission.enabled" rel="noopener noreferrer"&gt;Decommissioning&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Executor exclusion on failures
&lt;/h3&gt;

&lt;p&gt;Formerly "blacklisting". A node with a failing disk makes every task that lands on it fail; after a threshold, Spark stops scheduling there for a while. Cheap insurance.&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;spark.excludeOnFailure.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Spot / preemptible workers
&lt;/h3&gt;

&lt;p&gt;60–90% cheaper compute that can be reclaimed with two minutes' notice (AWS, GCP) or less (Azure). The single biggest cost lever for batch, and the single biggest source of 04:00 pages when used naked.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Costs:&lt;/strong&gt; losing a node loses its shuffle files and cached blocks. Every stage that produced data there must be recomputed. Three interruptions in a 2-hour job can double its runtime, and the job may never finish on a bad day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it safe with:&lt;/strong&gt; a remote shuffle service (nothing on the node to lose) &lt;em&gt;or&lt;/em&gt; decommissioning (move it before the node dies) &lt;em&gt;or&lt;/em&gt; EFM on Dataproc, plus generous retries, plus the driver on an on-demand node. The playground gives the combination &lt;em&gt;spot + Celeborn + dynamic allocation&lt;/em&gt; a synergy bonus for exactly this reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Event logs and the History Server
&lt;/h3&gt;

&lt;p&gt;The Spark UI disappears when the application ends. Event logging writes every event to storage, and the History Server replays them into the same UI, days later. It is the only way to debug yesterday's failure, and the only way to compare tonight's plan with last week's.&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;spark.eventLog.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.eventLog.dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;s3a://ridehub/spark-events/&lt;/span&gt;
&lt;span class="py"&gt;spark.history.fs.logDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;s3a://ridehub/spark-events/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/monitoring.html" rel="noopener noreferrer"&gt;Monitoring and instrumentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prometheus metrics sink
&lt;/h3&gt;

&lt;p&gt;Executor memory, GC time, task counts and shuffle bytes as time series, with alerts. Spark 3.0+ exposes a Prometheus servlet on the driver and executors natively.&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;spark.ui.prometheus.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="err"&gt;spark.metrics.conf.*&lt;/span&gt;&lt;span class="py"&gt;.sink.prometheusServlet.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.metrics.sink.PrometheusServlet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Levers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pay only for what the stage needs&lt;/td&gt;
&lt;td&gt;dynamic allocation + a shuffle home&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survive spot interruptions&lt;/td&gt;
&lt;td&gt;remote shuffle &lt;em&gt;or&lt;/em&gt; decommissioning, retries ≥ 8, on-demand driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One slow node should not define the job&lt;/td&gt;
&lt;td&gt;speculation (idempotent sinks only), executor exclusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboards and backfills on one session&lt;/td&gt;
&lt;td&gt;FAIR pools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU for one stage only&lt;/td&gt;
&lt;td&gt;stage-level scheduling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short jobs on S3 feel sluggish&lt;/td&gt;
&lt;td&gt;&lt;code&gt;locality.wait = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debug last night&lt;/td&gt;
&lt;td&gt;event logs + History Server; Prometheus for trends&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer 6a: File and table formats
&lt;/h2&gt;

&lt;p&gt;Everything above this line decides how fast Spark can process a byte. This layer decides &lt;strong&gt;how many bytes there are to process&lt;/strong&gt;, and whether Spark can find out which files it does not need to open before opening them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  can skip columns?   can skip row groups?   one file = many tasks?   file list from
  Parquet / ORC         yes                 yes                    yes               directory listing
  Avro                  no                  no                     yes               directory listing
  CSV / JSON            no                  no                     yes               directory listing
  gzip CSV / JSON       no                  no                     NO                directory listing
  Delta / Iceberg /     yes                 yes + per-FILE stats   yes               transaction log /
  Hudi (on Parquet)                         (skip whole files)                       manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Parquet
&lt;/h3&gt;

&lt;p&gt;Columnar, splittable, compressed per column, with a footer holding min/max statistics per row group. The default output format and the right one for anything that will be read more than once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; column pruning (read 6 columns of 40 and you read ~15% of the bytes), predicate pushdown via row-group statistics, parallel reads of one file.&lt;/li&gt;
&lt;li&gt;Spark's vectorized Parquet reader is the fastest path in the JVM engine; the native engines all have their own.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&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;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;s3a://ridehub/trips/&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;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-parquet.html" rel="noopener noreferrer"&gt;Parquet files&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ORC
&lt;/h3&gt;

&lt;p&gt;The other columnar format, from the Hive world: stripes instead of row groups, built-in indexes and optional bloom filters per column. Functionally equivalent to Parquet for Spark; pick whichever your ecosystem already speaks.&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;spark.sql.orc.impl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;native&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.orc.filterPushdown&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-orc.html" rel="noopener noreferrer"&gt;ORC files&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Avro
&lt;/h3&gt;

&lt;p&gt;Row-oriented, schema embedded, splittable. The lingua franca of Kafka and schema registries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; schema evolution and record-at-a-time producers.&lt;/li&gt;
&lt;li&gt;ℹ️ &lt;strong&gt;Costs:&lt;/strong&gt; a &lt;code&gt;SELECT one_column&lt;/code&gt; reads every column of every row. Fine as a landing format for a streaming pipeline; convert to Parquet before anything analytical touches it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-avro.html" rel="noopener noreferrer"&gt;Avro files&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CSV (uncompressed)
&lt;/h3&gt;

&lt;p&gt;Text. Every read parses every byte, types are guesses unless you provide a schema, and quoting rules are a daily surprise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Splittable (Spark can start a task at any newline), so parallelism is fine. Throughput is not: parsing dominates.&lt;/li&gt;
&lt;li&gt;ℹ️ Fine as a landing format. Convert on arrival.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚠️ gzip-compressed CSV or JSON
&lt;/h3&gt;

&lt;p&gt;gzip has no block index, so a file cannot be split: &lt;strong&gt;one file is one task on one core&lt;/strong&gt;, however large. A 50 GB &lt;code&gt;events.csv.gz&lt;/code&gt; is a single task that runs for an hour while 399 cores sit idle. Use bzip2 or seekable zstd if you must compress text, split into many ~128 MB files, or convert to Parquet on landing.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON lines
&lt;/h3&gt;

&lt;p&gt;Self-describing and verbose. The Jackson parser is the bottleneck; schema inference is a full extra pass over the data. Same advice as CSV, with more emphasis on always passing a schema.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-json.html" rel="noopener noreferrer"&gt;JSON files&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-csv.html" rel="noopener noreferrer"&gt;CSV files&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delta Lake
&lt;/h3&gt;

&lt;p&gt;Parquet files plus a transaction log (&lt;code&gt;_delta_log/&lt;/code&gt;). The log lists exactly which files make up the current version, with per-file min/max statistics.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; ACID writes (no half-written tables after a failed job), time travel (&lt;code&gt;VERSION AS OF&lt;/code&gt;), &lt;code&gt;MERGE INTO&lt;/code&gt; for upserts and GDPR deletes, data skipping from per-file statistics, &lt;code&gt;OPTIMIZE&lt;/code&gt; and &lt;code&gt;ZORDER&lt;/code&gt;, and &lt;strong&gt;no directory listing&lt;/strong&gt;: the file list comes from the log, which is why a Delta table with two million files still plans quickly.&lt;/li&gt;
&lt;li&gt;Streaming source and sink with exactly-once semantics, which makes it the natural landing table for Structured Streaming.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.extensions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.delta.sql.DeltaSparkSessionExtension&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.catalog.spark_catalog&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.sql.delta.catalog.DeltaCatalog&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="n"&gt;df&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;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;dt&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3a://ridehub/trips_delta/&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;Docs: &lt;a href="https://docs.delta.io/latest/index.html" rel="noopener noreferrer"&gt;Delta Lake&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache Iceberg
&lt;/h3&gt;

&lt;p&gt;An open table format designed for engine neutrality (Spark, Trino, Flink, Snowflake and BigQuery all read it natively).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the same ACID/time-travel/upsert problems as Delta, plus &lt;strong&gt;hidden partitioning&lt;/strong&gt; (partition by &lt;code&gt;days(pickup_ts)&lt;/code&gt; without a &lt;code&gt;dt&lt;/code&gt; column that users have to remember to filter on), &lt;strong&gt;manifest-level pruning&lt;/strong&gt; (the planner skips whole manifests of files from their statistics, without listing or opening anything), and storage-partitioned joins (3.3+).&lt;/li&gt;
&lt;li&gt;A REST catalog or Hive metastore holds the table pointer.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spark.sql.extensions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.catalog.lake&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.iceberg.spark.SparkCatalog&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.catalog.lake.type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rest&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lake.ridehub.trips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;partitionedBy&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;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pickup_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;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://iceberg.apache.org/docs/latest/spark-getting-started/" rel="noopener noreferrer"&gt;Iceberg with Spark&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache Hudi
&lt;/h3&gt;

&lt;p&gt;The upsert-first table format, from Uber. Copy-on-write tables rewrite files on update; merge-on-read tables append deltas and compact later. Incremental queries ("give me everything that changed since commit X") are a first-class feature.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; CDC-style ingestion with record-level updates and incremental downstream pulls.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&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;hudi&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;hoodie.datasource.write.recordkey.field&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;trip_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;save&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://hudi.apache.org/docs/overview" rel="noopener noreferrer"&gt;Apache Hudi&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hive metastore table (Parquet)
&lt;/h3&gt;

&lt;p&gt;Not a format, but a different &lt;em&gt;read path&lt;/em&gt;: plain Parquet files registered as a partitioned table in a metastore. Partition locations come from the catalog, not from walking S3, and bucketing metadata lives here too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; listing cost on partitioned tables, and it is the prerequisite for &lt;code&gt;bucketBy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; &lt;code&gt;MSCK REPAIR TABLE&lt;/code&gt; or &lt;code&gt;ALTER TABLE ADD PARTITION&lt;/code&gt; after every write that creates a new partition, and a metastore to run.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&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;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;dt&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;ridehub.trips&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;MSCK REPAIR TABLE ridehub.trips&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;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Landing zone from Kafka&lt;/td&gt;
&lt;td&gt;Avro (or JSON if you must), converted downstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytical tables, single engine, no updates&lt;/td&gt;
&lt;td&gt;Parquet, partitioned by date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytical tables with updates, deletes, time travel&lt;/td&gt;
&lt;td&gt;Delta (Databricks-centric) or Iceberg (multi-engine)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC ingestion with incremental consumers&lt;/td&gt;
&lt;td&gt;Hudi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Millions of files and you cannot change the writer&lt;/td&gt;
&lt;td&gt;a table format, purely for the manifest-based file list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything compressed with gzip&lt;/td&gt;
&lt;td&gt;re-compress, or split into many files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer 6b: Data layout and the read path
&lt;/h2&gt;

&lt;p&gt;Same bytes, same format, radically different job: this layer is about &lt;em&gt;where&lt;/em&gt; rows sit inside and across files so the planner can skip them, and about the handful of read-side settings that decide whether the first stage is a scan or a surprise.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;partitionBy(date)&lt;/code&gt; on write
&lt;/h3&gt;

&lt;p&gt;Hive-style directories, one per value: &lt;code&gt;dt=2026-03-01/part-0000.parquet&lt;/code&gt;. A filter on &lt;code&gt;dt&lt;/code&gt; never opens the other 334 days.&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;df&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;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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;   trips/
   ├── dt=2026-03-01/  ◄─┐
   ├── dt=2026-03-02/    ├── filter dt BETWEEN '2026-03-01' AND '2026-03-31'
   │   …                 │   opens these 31 directories (1,400 files)
   ├── dt=2026-03-31/  ◄─┘
   ├── dt=2026-04-01/      and never lists or opens the other 334 (14,600 files)
   │   …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the single biggest I/O win available, for the single most common filter (time).&lt;/li&gt;
&lt;li&gt;Pairs with pushdown-friendly predicates (Layer 3b); the playground gives the pair a synergy bonus because each is nearly useless without the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧨 &lt;code&gt;partitionBy(user_id)&lt;/code&gt; and other high-cardinality keys
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"Queries filter by user."&lt;/em&gt; 40 million users means 40 million directories with one tiny file each. Listing them takes longer than the old full scan did, the driver's file index runs out of memory, and every write creates thousands of files. Partition on low-cardinality columns only (date, region, tenant). For point lookups on high-cardinality keys, use clustering or bucketing below.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;bucketBy(key)&lt;/code&gt; on write
&lt;/h3&gt;

&lt;p&gt;Pre-hash rows into N buckets by the join or aggregation key. Later jobs that join or group by that key skip the shuffle entirely. Metastore tables only.&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;df&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;bucketBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;sortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;driver_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;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;ridehub.trips_b&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;h3&gt;
  
  
  &lt;code&gt;OPTIMIZE … ZORDER BY&lt;/code&gt; (Delta)
&lt;/h3&gt;

&lt;p&gt;Z-ordering rewrites files so that rows close in a multi-dimensional space (say, &lt;code&gt;driver_id&lt;/code&gt; × &lt;code&gt;zone_id&lt;/code&gt;) land in the same file. Per-file min/max statistics then become selective for &lt;em&gt;either&lt;/em&gt; column, and a point lookup opens a handful of files out of thousands.&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;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;OPTIMIZE ridehub.trips ZORDER BY (driver_id, zone_id)&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solves:&lt;/strong&gt; the high-cardinality lookup that partitioning cannot. &lt;strong&gt;Costs:&lt;/strong&gt; a rewrite of the table; schedule it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Liquid clustering (Databricks)
&lt;/h3&gt;

&lt;p&gt;Databricks' replacement for partitioning plus Z-ORDER: declare clustering columns once, and &lt;code&gt;OPTIMIZE&lt;/code&gt; incrementally clusters new data without rewriting old data or picking partition boundaries up front.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;trips&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;driver_id&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="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://docs.databricks.com/en/delta/clustering.html" rel="noopener noreferrer"&gt;Liquid clustering&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;sortWithinPartitions&lt;/code&gt; before write
&lt;/h3&gt;

&lt;p&gt;The cheapest clustering there is. Sorting each output partition by a column means each file (and each row group inside it) covers a narrow range of that column, so Parquet's min/max statistics finally skip data on it. The previous article's &lt;code&gt;fare &amp;gt; 300&lt;/code&gt; filter went from "every row group might match" to "3% of row groups might match" with one extra method call.&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;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sortWithinPartitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fare&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;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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Compaction to 128 MB–1 GB files
&lt;/h3&gt;

&lt;p&gt;Streaming sinks, hourly jobs and &lt;code&gt;partitionBy(user_id)&lt;/code&gt; all produce small files. Every file costs a listing entry, an S3 &lt;code&gt;GET&lt;/code&gt; for the footer, a task scheduling decision and a few milliseconds of fixed overhead. Two million 400 KB files is 800 GB of data with the overhead of 2,000,000 opens. A compaction job rewrites them into a few thousand ~256 MB files.&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;# Delta / Databricks
&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;OPTIMIZE ridehub.trips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Iceberg
&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;CALL lake.system.rewrite_data_files(table =&amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ridehub.trips&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="c1"&gt;# plain Parquet
&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;repartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;repartition&lt;/code&gt; / &lt;code&gt;coalesce&lt;/code&gt; before write
&lt;/h3&gt;

&lt;p&gt;Output file count equals the number of partitions in the final stage, which after a shuffle is &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt;: 200 files, however small the result. Decide on purpose.&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;result&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="mi"&gt;8&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;parquet&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="c1"&gt;# fewer files, no shuffle
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dt&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;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;dt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# one well-sized file per dt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;coalesce(n)&lt;/code&gt; merges partitions without a shuffle but can leave them uneven; &lt;code&gt;repartition(n, col)&lt;/code&gt; shuffles and balances. For &lt;code&gt;partitionBy&lt;/code&gt; writes, &lt;code&gt;repartition&lt;/code&gt; by the partition column avoids every task writing a sliver into every directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Delta optimized writes and auto compaction
&lt;/h3&gt;

&lt;p&gt;Databricks' automatic version of the two parts above: an adaptive shuffle before the write to produce ~128 MB files, then a compaction of small files after each commit.&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;spark.databricks.delta.optimizeWrite.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.databricks.delta.autoCompact.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Explicit schema on read
&lt;/h3&gt;

&lt;p&gt;For CSV and JSON, pass a schema. No inference pass, deterministic types, and no "the column was all nulls on Tuesday so it became a string" incident.&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;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;trip_id string, driver_id long, zone_id int, pickup_ts timestamp, fare double&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema&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;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;s3a://ridehub/raw/&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;h3&gt;
  
  
  ⚠️ &lt;code&gt;inferSchema = True&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For CSV and JSON this is a &lt;strong&gt;full extra pass over every byte&lt;/strong&gt; before the real job starts, and the inferred types can change between runs. Parquet and ORC carry their schema in the footer, so the option does nothing there.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;maxPartitionBytes = 512 MB&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.sql.files.maxPartitionBytes&lt;/code&gt; (default 128 MB) is the target size of a scan task; &lt;code&gt;openCostInBytes&lt;/code&gt; (default 4 MB) is the fixed cost charged per file when bin-packing. Raising the target halves the number of scan tasks, which helps when files are many and the work per byte is small; lowering it helps when a few huge files leave cores idle.&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;spark.sql.files.maxPartitionBytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;512m&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.files.openCostInBytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cloud-native output committer
&lt;/h3&gt;

&lt;p&gt;Spark's default commit protocol writes to a temporary directory and &lt;em&gt;renames&lt;/em&gt; on commit. On HDFS a rename is a metadata operation; on S3 it is a copy and delete of every byte, and it is not atomic. The S3A &lt;em&gt;magic&lt;/em&gt; committer (and EMR's S3-optimized committer, and GCS's equivalent) use multipart uploads that are only completed at commit time: no copy, no window of half-visible output.&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;spark.hadoop.fs.s3a.committer.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;magic&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.sources.commitProtocolClass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.internal.io.cloud.PathOutputCommitProtocol&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.parquet.output.committer.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.spark.internal.io.cloud.BindingParquetOutputCommitter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;ℹ️ Table formats solve this one level up: Delta, Iceberg and Hudi make a write visible by committing to their log, so the files underneath never need renaming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://hadoop.apache.org/docs/stable/hadoop-aws/tools/hadoop-aws/committers.html" rel="noopener noreferrer"&gt;Committing work to S3 with the S3A committers&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;mergeSchema&lt;/code&gt; on read
&lt;/h3&gt;

&lt;p&gt;When Parquet files in one directory have evolved schemas, &lt;code&gt;mergeSchema=true&lt;/code&gt; reads every footer and reconciles them. Correct, and on 16,000 files it is 16,000 footer reads on the driver before the job starts. Table formats track schema evolution in their log and avoid this.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧨 &lt;code&gt;ignoreCorruptFiles = true&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"The job keeps failing on one bad file."&lt;/em&gt; Now it silently skips &lt;strong&gt;any&lt;/strong&gt; file it cannot read, including the one that failed because S3 throttled the request and the one that was half-written by a job that died. Missing rows, no error, and nobody finds out until the finance numbers do not add up. Quarantine bad files explicitly instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  zstd Parquet compression
&lt;/h3&gt;

&lt;p&gt;Snappy is the default Parquet codec: fast, moderate ratio. zstd gives noticeably smaller files (often 20–40%) at similar read speed on modern CPUs. Less S3 storage, less network, slightly more CPU on write.&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;spark.sql.parquet.compression.codec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;zstd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Reuse one DataFrame per source
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;spark.read.parquet(path)&lt;/code&gt; builds an &lt;code&gt;InMemoryFileIndex&lt;/code&gt; by listing the directory tree. Calling it again lists again. Read once, filter many times, and the listing (which on 16,000 S3 objects is several seconds of API calls) is paid once 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="n"&gt;trips&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;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# lists once
&lt;/span&gt;&lt;span class="n"&gt;march&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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;april&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trips&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="c1"&gt;# no second listing
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Parquet column index and bloom filters
&lt;/h3&gt;

&lt;p&gt;Parquet 1.11+ writes page-level min/max (the &lt;em&gt;column index&lt;/em&gt;), so pushdown can skip pages inside a row group, not just whole row groups. Bloom filters, enabled per column at write time, answer "is this &lt;code&gt;trip_id&lt;/code&gt; definitely not in this row group?" for point lookups on high-cardinality keys where min/max statistics are useless.&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.parquet.bloom.filter.enabled&lt;/span&gt;&lt;span class="c"&gt;#trip_id=true
&lt;/span&gt;&lt;span class="err"&gt;spark.hadoop.parquet.bloom.filter.expected.ndv&lt;/span&gt;&lt;span class="c"&gt;#trip_id=50000000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Lever&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Date filter still scans the whole table&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;partitionBy(dt)&lt;/code&gt; + a plain predicate on &lt;code&gt;dt&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point lookups on a high-cardinality key&lt;/td&gt;
&lt;td&gt;Z-ORDER / liquid clustering / bloom filters, not partitioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Listing takes minutes&lt;/td&gt;
&lt;td&gt;compaction, table format, reuse the DataFrame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 tiny output files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;coalesce&lt;/code&gt; / &lt;code&gt;repartition&lt;/code&gt; before write, optimized writes, AQE coalesce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Range filter on a non-partition column reads everything&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sortWithinPartitions&lt;/code&gt; on that column at write time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job spends minutes before stage 0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;inferSchema&lt;/code&gt; on text, &lt;code&gt;mergeSchema&lt;/code&gt;, a cold listing: pass a schema, use a table format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Half-written output visible after a failure&lt;/td&gt;
&lt;td&gt;cloud committer or a table format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Combinations that are worth more than their parts
&lt;/h2&gt;

&lt;p&gt;A few of these parts only make sense together, and the playground gives them explicit synergy bonuses. They are worth listing because each is a small design pattern:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Combination&lt;/th&gt;
&lt;th&gt;Why it is more than the sum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AQE + coalesce + skew join + runtime broadcast&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Self-tuning shuffles.&lt;/strong&gt; The planner's three biggest mistakes (too many reducers, one hot reducer, wrong join strategy) all fixed after the fact.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;partitionBy(dt)&lt;/code&gt; + pushdown-friendly predicates&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Partition pruning on every read.&lt;/strong&gt; Either one alone does nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pandas UDF + Arrow + &lt;code&gt;memoryOverhead&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Production-grade PySpark.&lt;/strong&gt; Vectorized logic, fast transfer, and room for the worker to live.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Celeborn + dynamic allocation + spot&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Elastic, spot-tolerant shuffle.&lt;/strong&gt; Stateless executors can be added, removed or reclaimed at any moment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spot + graceful decommissioning&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Nodes drain instead of crash.&lt;/strong&gt; The two-minute notice becomes enough time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes + Celeborn + dynamic allocation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Stateless autoscaling on Kubernetes.&lt;/strong&gt; The missing shuffle service, supplied.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured Streaming + RocksDB + watermark + idempotent &lt;code&gt;foreachBatch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A streaming pipeline that runs for months.&lt;/strong&gt; Bounded state, off-heap, exactly-once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gluten / Comet + off-heap&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A native engine with the memory it needs.&lt;/strong&gt; Without it, you get the fallbacks and none of the speed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iceberg + bucketed joins&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Storage-partitioned joins.&lt;/strong&gt; The big-to-big join with no Exchange.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thrift server + &lt;code&gt;cache()&lt;/code&gt; + FAIR pools&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A shared BI endpoint that stays responsive.&lt;/strong&gt; Warm data, fair slots.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta + Z-ORDER + compaction&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Point lookups on a lakehouse table.&lt;/strong&gt; Few files, each covering a narrow key range.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event logs + Prometheus&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;An observable cluster.&lt;/strong&gt; Post-mortems and trends.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Six starter profiles
&lt;/h2&gt;

&lt;p&gt;The playground ships these as one-click presets. Each is a defensible starting point for its workload, not a tuned endpoint; the point is to see which layers a given problem actually touches.&lt;/p&gt;

&lt;h3&gt;
  
  
  🌙 Nightly ETL starter
&lt;/h3&gt;

&lt;p&gt;YARN · DataFrame API · built-in functions · JVM engine · AQE (coalesce, skew) · broadcast hint · pushdown-friendly predicates · external shuffle service · zstd shuffle · 4-core executors · dynamic allocation · History Server · Parquet · &lt;code&gt;partitionBy(dt)&lt;/code&gt; · &lt;code&gt;coalesce&lt;/code&gt; before write&lt;/p&gt;

&lt;p&gt;The RideHub job after Maya fixed it. Nothing exotic: every component is in open-source Spark, and the whole configuration fits in a dozen 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="py"&gt;spark.master&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yarn&lt;/span&gt;
&lt;span class="py"&gt;spark.submit.deployMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;cluster&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.coalescePartitions.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.sql.adaptive.skewJoin.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.shuffle.service.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.io.compression.codec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;zstd&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.cores&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;spark.executor.memory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20g&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spark.dynamicAllocation.maxExecutors&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50&lt;/span&gt;
&lt;span class="py"&gt;spark.eventLog.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📊 Interactive SQL endpoint
&lt;/h3&gt;

&lt;p&gt;Kubernetes · DataFrame API · Thrift server · JVM · AQE (runtime broadcast) · CBO statistics · Celeborn · &lt;code&gt;cache()&lt;/code&gt; the hot week · FAIR pools · &lt;code&gt;locality.wait = 0&lt;/code&gt; · Delta · Z-ORDER · compaction · one DataFrame per source&lt;/p&gt;

&lt;p&gt;Dashboards at nine o'clock. The levers are all about latency: a warm long-running session, fair sharing, few well-clustered files, and no three-second locality waits on object storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  🐍 PySpark ML features
&lt;/h3&gt;

&lt;p&gt;Kubernetes · DataFrame API · pandas UDF · &lt;code&gt;mapInPandas&lt;/code&gt; · JVM · AQE (coalesce) · shuffle tracking · &lt;code&gt;memoryOverhead&lt;/code&gt; · Arrow · 4-core executors · dynamic allocation · stage-level scheduling · Parquet · &lt;code&gt;partitionBy(dt)&lt;/code&gt; · pushdown-friendly predicates&lt;/p&gt;

&lt;p&gt;Python at scale without the 40× penalty. Note what is &lt;em&gt;absent&lt;/em&gt;: no row-at-a-time UDF, and no native engine (it would fall back on the UDF anyway).&lt;/p&gt;

&lt;h3&gt;
  
  
  🌊 Streaming Kafka → Delta
&lt;/h3&gt;

&lt;p&gt;Kubernetes · DataFrame API · Structured Streaming · RocksDB · watermark · idempotent &lt;code&gt;foreachBatch&lt;/code&gt; · JVM · AQE · 4-core executors · &lt;code&gt;memoryOverhead&lt;/code&gt; · generous retries · History Server · Delta · optimized writes&lt;/p&gt;

&lt;p&gt;Runs for months. Bounded state off the heap, a sink that tolerates replay, retries that tolerate Kafka hiccups, and a table format that compacts its own small files.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎟️ Cost-optimised spot batch
&lt;/h3&gt;

&lt;p&gt;Kubernetes · DataFrame API · built-in functions · JVM · AQE (coalesce, skew) · Celeborn · zstd shuffle · 4-core executors · dynamic allocation · spot workers · graceful decommissioning · generous retries · Parquet · &lt;code&gt;partitionBy(dt)&lt;/code&gt; · pushdown-friendly predicates · zstd Parquet · &lt;code&gt;coalesce&lt;/code&gt; before write&lt;/p&gt;

&lt;p&gt;The nightly ETL at 30% of the price. Every resilience lever is on because every node is disposable: shuffle lives off the executors, nodes drain when reclaimed, and eight retries absorb the churn.&lt;/p&gt;

&lt;h3&gt;
  
  
  🚀 Native engine ETL (Gluten)
&lt;/h3&gt;

&lt;p&gt;YARN · DataFrame API · built-in functions · &lt;strong&gt;Gluten + Velox&lt;/strong&gt; · AQE (coalesce) · broadcast hint · pushdown-friendly predicates · external shuffle service · &lt;strong&gt;off-heap memory&lt;/strong&gt; · 4-core executors · dynamic allocation · Parquet · &lt;code&gt;partitionBy(dt)&lt;/code&gt; · compaction&lt;/p&gt;

&lt;p&gt;The CPU-bound ETL, roughly twice as fast. Built-in functions only (no fallbacks), off-heap sized for Velox, and compacted inputs so the native scan reads big files.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cheat sheet: symptom to lever
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You see&lt;/th&gt;
&lt;th&gt;It usually means&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PartitionFilters: []&lt;/code&gt; on a partitioned table&lt;/td&gt;
&lt;td&gt;predicate is not on the partition column, or wraps it in a function&lt;/td&gt;
&lt;td&gt;rewrite the predicate; DPP for star joins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16,000 scan tasks for one month of data&lt;/td&gt;
&lt;td&gt;no pruning, or tiny files&lt;/td&gt;
&lt;td&gt;pruning above; compaction; &lt;code&gt;maxPartitionBytes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SortMergeJoin&lt;/code&gt; with two &lt;code&gt;Exchange&lt;/code&gt;s against a small table&lt;/td&gt;
&lt;td&gt;planner sized the dimension from files&lt;/td&gt;
&lt;td&gt;broadcast hint; CBO stats; AQE runtime broadcast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Could not execute broadcast in 300 secs&lt;/code&gt; / driver OOM in a join&lt;/td&gt;
&lt;td&gt;broadcast threshold too high, or a hint on a big table&lt;/td&gt;
&lt;td&gt;lower the threshold; bigger driver; let it SMJ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;199 tasks in 1 minute, 1 task in 3 hours&lt;/td&gt;
&lt;td&gt;hot key&lt;/td&gt;
&lt;td&gt;AQE skew join; salting for aggregations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BatchEvalPython&lt;/code&gt; in the plan, 40× slower&lt;/td&gt;
&lt;td&gt;row-at-a-time Python UDF&lt;/td&gt;
&lt;td&gt;built-in functions; pandas UDF; Arrow UDF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Container killed by YARN for exceeding memory limits&lt;/code&gt;, heap free&lt;/td&gt;
&lt;td&gt;off-JVM memory (Python / Arrow / native)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;memoryOverhead&lt;/code&gt;; &lt;code&gt;pyspark.memory&lt;/code&gt;; off-heap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 output files of 2 MB&lt;/td&gt;
&lt;td&gt;default shuffle partitions&lt;/td&gt;
&lt;td&gt;AQE coalesce; &lt;code&gt;coalesce()&lt;/code&gt;; optimized writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One task per &lt;code&gt;.csv.gz&lt;/code&gt;, hours long&lt;/td&gt;
&lt;td&gt;unsplittable compression&lt;/td&gt;
&lt;td&gt;re-compress; split; convert to Parquet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job spends 5 minutes before stage 0&lt;/td&gt;
&lt;td&gt;listing, &lt;code&gt;inferSchema&lt;/code&gt;, &lt;code&gt;mergeSchema&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;schema; table format; reuse DataFrame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FetchFailedException&lt;/code&gt; after a node vanished&lt;/td&gt;
&lt;td&gt;shuffle files died with the node&lt;/td&gt;
&lt;td&gt;ESS; remote shuffle; decommissioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic allocation never scales down on Kubernetes&lt;/td&gt;
&lt;td&gt;shuffle tracking holding executors&lt;/td&gt;
&lt;td&gt;Celeborn / Uniffle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboards freeze while a backfill runs&lt;/td&gt;
&lt;td&gt;FIFO within one session&lt;/td&gt;
&lt;td&gt;FAIR pools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming executor OOM after days&lt;/td&gt;
&lt;td&gt;unbounded state on heap&lt;/td&gt;
&lt;td&gt;watermark; RocksDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate rows after a retry&lt;/td&gt;
&lt;td&gt;non-idempotent sink with speculation or restarts&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;foreachBatch&lt;/code&gt; keyed by &lt;code&gt;batchId&lt;/code&gt;; table format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native engine slower than JVM&lt;/td&gt;
&lt;td&gt;fallbacks on every operator&lt;/td&gt;
&lt;td&gt;count fallbacks in &lt;code&gt;explain()&lt;/code&gt;; remove UDFs; size off-heap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job silently produced fewer rows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ignoreCorruptFiles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;turn it off; quarantine explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Play it: the Distributed Compute Playground
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reading a parts catalogue is one thing. Bolting the wrong parts together and watching the task count explode is how it sticks.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I turned this whole article into a game. 🎮&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/AnikethSD/distributed-computing-playground" rel="noopener noreferrer"&gt;Distributed Compute Playground&lt;/a&gt;&lt;/strong&gt; is a drag-and-drop sandbox where you assemble a Spark job from exactly the nine layers above. Every component in this article (all 104 of them) is a chip. Drop it in and the playground tells you, instantly, what Spark would do with the job you just built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;explain()&lt;/code&gt;-style physical plan, with &lt;code&gt;PartitionFilters&lt;/code&gt;, the join strategy and &lt;code&gt;AQEShuffleRead&lt;/code&gt; filled in from &lt;em&gt;your&lt;/em&gt; choices&lt;/li&gt;
&lt;li&gt;the stage DAG with task counts and bytes per stage&lt;/li&gt;
&lt;li&gt;how many output files, how many Spark jobs, and roughly how long it takes on your cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop in &lt;code&gt;month(pickup_ts) = 3&lt;/code&gt; and watch &lt;code&gt;PartitionFilters: []&lt;/code&gt; come back with 16,000 tasks. Swap it for a range on &lt;code&gt;dt&lt;/code&gt; and watch it fall to 1,400. Add a broadcast hint and watch the &lt;code&gt;SortMergeJoin&lt;/code&gt; turn into a &lt;code&gt;BroadcastHashJoin&lt;/code&gt;. Raise the threshold to 2 GB and watch the memory-safety score fall off a cliff.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv42dj0kvnzzmrzlvb73d.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%2Fv42dj0kvnzzmrzlvb73d.png" alt="The Job plan tab: explain() output, stage table and estimate for the nightly ETL" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's inside&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧱 &lt;strong&gt;Build it&lt;/strong&gt;: 104 real Spark components across 9 colour-coded layers. Prerequisites and conflicts are enforced: dynamic allocation without a shuffle home is an error, Photon on YARN "runs nowhere", a Gluten plan with a Python UDF gets a warning about fallbacks.&lt;/li&gt;
&lt;li&gt;🔬 &lt;strong&gt;Read the plan it would produce&lt;/strong&gt;: a live job simulator for 10 workloads, from the nightly ETL to two million tiny files, a hot-key join, a 5 TB-to-5 TB join and a Kafka stream.&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Get scored&lt;/strong&gt;: the eleven dimensions from this article, a grade from S to D, and loud warnings when you do something reckless (yes, &lt;code&gt;spark.task.maxFailures = 1&lt;/code&gt;, I'm looking at you 🙃).&lt;/li&gt;
&lt;li&gt;🕵️ &lt;strong&gt;Crack 25 missions&lt;/strong&gt;: on-call tickets written like cloud certification exam questions. Each is a ~300-word story about a company in trouble, with a &lt;strong&gt;hidden question&lt;/strong&gt; and goals that check the &lt;em&gt;plan&lt;/em&gt;, not just the score. Mission 1 is FIN-2231, the ticket from the previous article — except the company is now &lt;strong&gt;Capsule Corp&lt;/strong&gt; and the engineer is &lt;strong&gt;Sakura&lt;/strong&gt;, because every company and person in the playground borrows a name from the Naruto or Dragon Ball Z universe (no real company ever gets quoted). Then there is the three-hour straggler, the job that only fails on Fridays, the GDPR delete by Friday, and the Photon bill.&lt;/li&gt;
&lt;li&gt;🌗 &lt;strong&gt;Light and dark themes&lt;/strong&gt;, zero dependencies, &lt;strong&gt;zero network calls&lt;/strong&gt;. Nothing leaves your browser.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flcz268sygi7gae8b9dam.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%2Flcz268sygi7gae8b9dam.png" alt="The mission board: 25 on-call tickets across 8 categories" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Up and running in 10 seconds&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AnikethSD/distributed-computing-playground.git
&lt;span class="nb"&gt;cd &lt;/span&gt;distributed-computing-playground
open index.html        &lt;span class="c"&gt;# macOS; use xdg-open on Linux, or just double-click it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;npm install&lt;/code&gt;. No Docker. No cluster. Just a browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  One small favour ⭐
&lt;/h3&gt;

&lt;p&gt;If this article or the game saved you even one evening of staring at a Spark UI, please &lt;strong&gt;&lt;a href="https://github.com/AnikethSD/distributed-computing-playground" rel="noopener noreferrer"&gt;drop a ⭐ on the repo&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It takes two seconds and costs nothing. It's the single biggest signal that tells me to keep building (the &lt;code&gt;.write&lt;/code&gt; article and a second engine family are next 👀), and it helps other engineers find the project.&lt;/p&gt;

&lt;p&gt;Got a production incident that would make a brutal mission? A straggler story? A plan that fooled you? &lt;strong&gt;Issues and PRs are very welcome.&lt;/strong&gt; There's a &lt;a href="https://github.com/AnikethSD/distributed-computing-playground/blob/main/docs/authoring-missions.md" rel="noopener noreferrer"&gt;mission authoring guide&lt;/a&gt; to get you started, and if databases are more your thing, its sibling, the &lt;strong&gt;&lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;Database Playground&lt;/a&gt;&lt;/strong&gt;, is waiting too.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;The companion article&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;What Actually Happens When You Call &lt;code&gt;spark.read&lt;/code&gt;? One Line of Python, a Thousand Tasks&lt;/em&gt;: the story version, following one job through Catalyst, the file index, the scheduler and the shuffle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Official Spark docs, by layer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;L1: &lt;a href="https://spark.apache.org/docs/latest/cluster-overview.html" rel="noopener noreferrer"&gt;Cluster mode overview&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/spark-standalone.html" rel="noopener noreferrer"&gt;Standalone&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/running-on-yarn.html" rel="noopener noreferrer"&gt;YARN&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/running-on-kubernetes.html" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;L2: &lt;a href="https://spark.apache.org/docs/latest/api/python/user_guide/sql/arrow_pandas.html" rel="noopener noreferrer"&gt;Arrow and pandas UDFs&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/spark-connect-overview.html" rel="noopener noreferrer"&gt;Spark Connect&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-distributed-sql-engine.html" rel="noopener noreferrer"&gt;Thrift server&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html" rel="noopener noreferrer"&gt;Structured Streaming&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;L3: &lt;a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html" rel="noopener noreferrer"&gt;SQL performance tuning&lt;/a&gt; (AQE, join hints, CBO), &lt;a href="https://gluten.apache.org/" rel="noopener noreferrer"&gt;Apache Gluten&lt;/a&gt;, &lt;a href="https://datafusion.apache.org/comet/" rel="noopener noreferrer"&gt;Apache DataFusion Comet&lt;/a&gt;, &lt;a href="https://docs.databricks.com/en/compute/photon.html" rel="noopener noreferrer"&gt;Photon&lt;/a&gt;, &lt;a href="https://nvidia.github.io/spark-rapids/" rel="noopener noreferrer"&gt;RAPIDS Accelerator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;L4: &lt;a href="https://spark.apache.org/docs/latest/configuration.html#shuffle-behavior" rel="noopener noreferrer"&gt;Shuffle behavior configuration&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/configuration.html#push-based-shuffle-overview" rel="noopener noreferrer"&gt;Push-based shuffle&lt;/a&gt;, &lt;a href="https://celeborn.apache.org/" rel="noopener noreferrer"&gt;Apache Celeborn&lt;/a&gt;, &lt;a href="https://uniffle.apache.org/" rel="noopener noreferrer"&gt;Apache Uniffle&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/tuning.html#memory-management-overview" rel="noopener noreferrer"&gt;Memory management&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/hardware-provisioning.html" rel="noopener noreferrer"&gt;Hardware provisioning&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;L5: &lt;a href="https://spark.apache.org/docs/latest/job-scheduling.html" rel="noopener noreferrer"&gt;Job scheduling&lt;/a&gt; (dynamic allocation, FAIR pools), &lt;a href="https://spark.apache.org/docs/latest/configuration.html#stage-level-scheduling-overview" rel="noopener noreferrer"&gt;Stage-level scheduling&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/monitoring.html" rel="noopener noreferrer"&gt;Monitoring&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;L6: &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-parquet.html" rel="noopener noreferrer"&gt;Parquet&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-orc.html" rel="noopener noreferrer"&gt;ORC&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-avro.html" rel="noopener noreferrer"&gt;Avro&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-csv.html" rel="noopener noreferrer"&gt;CSV&lt;/a&gt;, &lt;a href="https://spark.apache.org/docs/latest/sql-data-sources-json.html" rel="noopener noreferrer"&gt;JSON&lt;/a&gt;, &lt;a href="https://docs.delta.io/latest/index.html" rel="noopener noreferrer"&gt;Delta Lake&lt;/a&gt;, &lt;a href="https://iceberg.apache.org/docs/latest/spark-getting-started/" rel="noopener noreferrer"&gt;Apache Iceberg&lt;/a&gt;, &lt;a href="https://hudi.apache.org/docs/overview" rel="noopener noreferrer"&gt;Apache Hudi&lt;/a&gt;, &lt;a href="https://hadoop.apache.org/docs/stable/hadoop-aws/tools/hadoop-aws/committers.html" rel="noopener noreferrer"&gt;S3A committers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Everything: &lt;a href="https://spark.apache.org/docs/latest/configuration.html" rel="noopener noreferrer"&gt;Spark configuration reference&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Longer reads&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://books.japila.pl/spark-sql-internals/" rel="noopener noreferrer"&gt;The Internals of Spark SQL&lt;/a&gt; by Jacek Laskowski, operator by operator&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Spark: The Definitive Guide&lt;/em&gt; (Chambers &amp;amp; Zaharia), Part II, for the execution model in long form&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This is the second article in a series on distributed computing, told through one dataset and one team. The first followed a job through Spark's internals; this one catalogued the parts. Next: what happens when you call &lt;code&gt;.write&lt;/code&gt;, and why your output has 200 files. Spark moves fast and platforms move faster: if a default or a feature here does not match what you see, tell me in the comments and I will fix it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spark</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>The Database Playground: Everything You Can Plug In and Tune in PostgreSQL and MySQL (Including AI Workloads)</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:50:15 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/the-database-playground-everything-you-can-plug-in-and-tune-in-postgresql-and-mysql-including-ai-18lb</link>
      <guid>https://dev.to/anikethsdeshpande/the-database-playground-everything-you-can-plug-in-and-tune-in-postgresql-and-mysql-including-ai-18lb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: PostgreSQL and MySQL are not black boxes. Both let you &lt;strong&gt;swap storage engines, index types, durability&lt;br&gt;
guarantees, where data physically lives, and even add vector search and ML&lt;/strong&gt;, often per table and sometimes&lt;br&gt;
per transaction. This article maps those options by the storage or retrieval problem each one solves,&lt;br&gt;
with links to the official docs for every fact.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Here for AI workloads?&lt;/strong&gt; Jump straight to &lt;strong&gt;Part 3: AI and ML workloads&lt;/strong&gt;&lt;br&gt;
for vector types, HNSW/IVFFlat/DiskANN tuning, hybrid search, in-database ML, and a full RAG query in SQL.&lt;/p&gt;

&lt;p&gt;🎮 &lt;strong&gt;Rather play than read?&lt;/strong&gt; I turned this entire article into a free, open-source browser game.&lt;br&gt;
Drag real PostgreSQL and MySQL components into a database stack and watch it tell you exactly what you just built.&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;👉 Play the Database Playground on GitHub&lt;/a&gt;&lt;/strong&gt;, and if it clicks, a ⭐ means the world.&lt;br&gt;
(More about it below)&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;How to read this article&lt;/li&gt;
&lt;li&gt;Part 1: PostgreSQL&lt;/li&gt;
&lt;li&gt;Part 2: MySQL (plus MariaDB and Percona)&lt;/li&gt;
&lt;li&gt;
Part 3: AI and ML workloads 🤖&lt;/li&gt;
&lt;li&gt;Cheat sheet: problem to engine&lt;/li&gt;
&lt;li&gt;
Play it: the Database Playground game 🎮&lt;/li&gt;
&lt;li&gt;Further reading&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  How to read this article
&lt;/h2&gt;

&lt;p&gt;Every database, however it's marketed, has the same layers. Each layer has something you can &lt;strong&gt;plug in&lt;/strong&gt; (swap the component) or &lt;strong&gt;tune&lt;/strong&gt; (turn a knob):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌───────────────────────────────┐
 clients →  │ Connection pooler             │  PgBouncer, thread pool
            ├───────────────────────────────┤
            │ Parser / planner / executor   │  hooks, JIT, parallelism, hash joins
            ├───────────────────────────────┤
            │ Access methods                │  ← storage engines + index types
            │  (tables and indexes)         │     heap, columnar, InnoDB, MyRocks, HNSW...
            ├───────────────────────────────┤
            │ Buffer cache                  │  shared_buffers, innodb_buffer_pool_size
            ├───────────────────────────────┤
            │ Write-ahead log / redo log    │  durability vs speed knobs
            ├───────────────────────────────┤
            │ Files, disks, object storage  │  tablespaces, SSD tuning, S3, Iceberg
            └───────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two main parts follow those layers from the top down to the disk. Part 3 then covers AI workloads across both databases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conventions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Versions&lt;/strong&gt; matter. "PG15+" means PostgreSQL 15 or later. MySQL facts use the &lt;strong&gt;8.4 LTS&lt;/strong&gt; manual unless stated otherwise. The &lt;code&gt;VECTOR&lt;/code&gt; type needs MySQL 9.x; 9.7 (April 2026) is the newest LTS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart&lt;/strong&gt; marks a setting that needs a server restart. Everything else can be changed with a reload, or per session.&lt;/li&gt;
&lt;li&gt;To see your current values, run &lt;code&gt;SELECT name, setting, unit, context FROM pg_settings;&lt;/code&gt; in PostgreSQL or &lt;code&gt;SHOW VARIABLES LIKE 'innodb%';&lt;/code&gt; in MySQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Every knob here is a trade-off, not a free speed-up.&lt;/strong&gt; Change one thing at a time, benchmark with your own&lt;br&gt;
workload, and read the linked doc before touching anything marked &lt;em&gt;unsafe&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1: PostgreSQL
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.1 How PostgreSQL plugs things in
&lt;/h3&gt;

&lt;p&gt;PostgreSQL's superpower is its &lt;strong&gt;extension system&lt;/strong&gt;. A lot of what feels like "core" features ships as extensions that use documented plug points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plug point&lt;/th&gt;
&lt;th&gt;What it lets an extension do&lt;/th&gt;
&lt;th&gt;Since&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/extend-extensions.html" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE EXTENSION&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Package types, functions, operators and index types&lt;/td&gt;
&lt;td&gt;9.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/tableam.html" rel="noopener noreferrer"&gt;Table access methods&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Replace &lt;strong&gt;how rows are stored&lt;/strong&gt; (the storage engine)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/current/indexam.html" rel="noopener noreferrer"&gt;Index access methods&lt;/a&gt; + &lt;a href="https://www.postgresql.org/docs/current/sql-create-access-method.html" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE ACCESS METHOD&lt;/code&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Add new &lt;strong&gt;index types&lt;/strong&gt; (for example, pgvector's HNSW)&lt;/td&gt;
&lt;td&gt;9.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/custom-scan.html" rel="noopener noreferrer"&gt;Custom scan providers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Take over query execution (pg_duckdb, Citus and TimescaleDB use this)&lt;/td&gt;
&lt;td&gt;9.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/ddl-foreign-data.html" rel="noopener noreferrer"&gt;Foreign data wrappers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Query &lt;strong&gt;external data&lt;/strong&gt; as if it were a table&lt;/td&gt;
&lt;td&gt;9.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/bgworker.html" rel="noopener noreferrer"&gt;Background workers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Run jobs inside the server (schedulers, partition maintenance)&lt;/td&gt;
&lt;td&gt;9.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/custom-rmgr.html" rel="noopener noreferrer"&gt;Custom WAL resource managers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Let extension storage be crash-safe and replicated&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/current/archive-modules.html" rel="noopener noreferrer"&gt;Archive modules&lt;/a&gt; (&lt;code&gt;archive_library&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Replace shell-based WAL archiving with a loadable library&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/logicaldecoding-output-plugin.html" rel="noopener noreferrer"&gt;Logical decoding output plugins&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Stream row changes out (&lt;code&gt;pgoutput&lt;/code&gt;, &lt;a href="https://github.com/eulerto/wal2json" rel="noopener noreferrer"&gt;&lt;code&gt;wal2json&lt;/code&gt;&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;9.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Extensions that hook into the server at startup must be listed in &lt;a href="https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SHARED-PRELOAD-LIBRARIES" rel="noopener noreferrer"&gt;&lt;code&gt;shared_preload_libraries&lt;/code&gt;&lt;/a&gt;, which needs a &lt;strong&gt;restart&lt;/strong&gt;. List what's installed with &lt;code&gt;\dx&lt;/code&gt;, and what's available with &lt;code&gt;SELECT * FROM pg_available_extensions;&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  1.2 Storage engines (table access methods)
&lt;/h3&gt;

&lt;p&gt;PostgreSQL has one built-in storage engine, &lt;strong&gt;&lt;code&gt;heap&lt;/code&gt;&lt;/strong&gt;. Others plug in through the &lt;a href="https://www.postgresql.org/docs/current/tableam.html" rel="noopener noreferrer"&gt;table access method API&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;heap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;-- default&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;events&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;columnar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- from an extension&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt;  &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ACCESS&lt;/span&gt; &lt;span class="k"&gt;METHOD&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;-- PG15+, rewrites the table&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;default_table_access_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'columnar'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- default for new tables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TABLE ... SET ACCESS METHOD&lt;/code&gt;&lt;/a&gt; arrived in PG15. It rewrites the whole table under an exclusive lock, so plan it like a migration. The default comes from &lt;a href="https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-DEFAULT-TABLE-ACCESS-METHOD" rel="noopener noreferrer"&gt;&lt;code&gt;default_table_access_method&lt;/code&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;Engine&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;heap&lt;/code&gt;&lt;/strong&gt; (core)&lt;/td&gt;
&lt;td&gt;Row store: 8 KB slotted pages, MVCC, TOAST for big values&lt;/td&gt;
&lt;td&gt;General OLTP. The safe default&lt;/td&gt;
&lt;td&gt;Bloat from dead row versions, which needs VACUUM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/citusdata/citus/blob/main/src/backend/columnar/README.md" rel="noopener noreferrer"&gt;Citus columnar&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;USING columnar&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Compressed column store in the &lt;code&gt;citus_columnar&lt;/code&gt; extension&lt;/td&gt;
&lt;td&gt;Append-only analytics, event history&lt;/td&gt;
&lt;td&gt;Designed as &lt;strong&gt;append-only&lt;/strong&gt;: &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; are unsupported, and several index types aren't available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/orioledb/orioledb" rel="noopener noreferrer"&gt;OrioleDB&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;USING orioledb&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Undo-log MVCC, row-level WAL, no buffer-mapping table&lt;/td&gt;
&lt;td&gt;Write-heavy OLTP without VACUUM bloat&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Beta&lt;/strong&gt;, and needs a &lt;strong&gt;patched PostgreSQL&lt;/strong&gt;. The project says it's not for production yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/duckdb/pg_duckdb" rel="noopener noreferrer"&gt;pg_duckdb&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embeds DuckDB's vectorised engine; mainly a query accelerator&lt;/td&gt;
&lt;td&gt;Analytics over heap tables and data-lake files&lt;/td&gt;
&lt;td&gt;A different execution engine, so SQL semantics can differ in edge cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/Snowflake-Labs/pg_lake" rel="noopener noreferrer"&gt;pg_lake&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;USING iceberg&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Transactional &lt;strong&gt;Apache Iceberg&lt;/strong&gt; tables on object storage&lt;/td&gt;
&lt;td&gt;Lakehouse tables writable from Postgres&lt;/td&gt;
&lt;td&gt;Needs the companion &lt;code&gt;pgduck_server&lt;/code&gt; process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://docs.tigerdata.com/" rel="noopener noreferrer"&gt;TimescaleDB&lt;/a&gt;&lt;/strong&gt; hypertables&lt;/td&gt;
&lt;td&gt;Automatic time partitioning, plus a row-to-column store ("hypercore")&lt;/td&gt;
&lt;td&gt;Time series, metrics, IoT&lt;/td&gt;
&lt;td&gt;Columnstore features are under the Timescale License, not Apache 2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 In my own tests (see my &lt;em&gt;Database Engines 101&lt;/em&gt; article's lab), the same 5 M rows took &lt;strong&gt;868 MB in heap and 127 MB in&lt;br&gt;
columnar&lt;/strong&gt;, and an analytics query read &lt;strong&gt;35x fewer pages&lt;/strong&gt;. Columnar also flatly refused &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  1.3 Index types (index access methods)
&lt;/h3&gt;

&lt;p&gt;This is where most performance is won or lost. PostgreSQL ships &lt;strong&gt;six index types&lt;/strong&gt; (&lt;a href="https://www.postgresql.org/docs/current/indexes-types.html" rel="noopener noreferrer"&gt;overview&lt;/a&gt;), and extensions add more:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;th&gt;Key knobs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;B-tree&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING btree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Equality, ranges, sorting, uniqueness&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fillfactor&lt;/code&gt;, &lt;code&gt;deduplicate_items&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Equality only, on long keys&lt;/td&gt;
&lt;td&gt;Crash-safe only since PG10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING gin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSONB, arrays, full-text, trigrams&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fastupdate&lt;/code&gt;, &lt;code&gt;gin_pending_list_limit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GiST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING gist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Geometry, ranges, nearest-neighbour&lt;/td&gt;
&lt;td&gt;Operator-class specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SP-GiST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING spgist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quadtrees, k-d trees, radix trees (IPs, phone prefixes)&lt;/td&gt;
&lt;td&gt;Operator-class specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BRIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING brin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Huge tables naturally ordered by a column (time)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pages_per_range&lt;/code&gt;, &lt;code&gt;autosummarize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;bloom&lt;/strong&gt; (contrib)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING bloom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Equality on &lt;em&gt;any combination&lt;/em&gt; of many columns&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;length&lt;/code&gt;, &lt;code&gt;col1..colN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;RUM&lt;/strong&gt; (&lt;a href="https://github.com/postgrespro/rum" rel="noopener noreferrer"&gt;ext&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING rum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full-text search &lt;em&gt;ranked&lt;/em&gt; inside the index&lt;/td&gt;
&lt;td&gt;Slower to build than GIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;HNSW / IVFFlat&lt;/strong&gt; (&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING hnsw&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vector similarity search&lt;/td&gt;
&lt;td&gt;See Part 3
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;B-tree features worth knowing&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication&lt;/strong&gt; (PG13): duplicate keys are stored once, so indexes on low-cardinality columns shrink a lot. It's on by default. Indexes carried over by &lt;code&gt;pg_upgrade&lt;/code&gt; need a &lt;code&gt;REINDEX&lt;/code&gt; to benefit. &lt;a href="https://www.postgresql.org/docs/current/btree.html#BTREE-DEDUPLICATION" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Covering indexes&lt;/strong&gt; with &lt;code&gt;INCLUDE&lt;/code&gt; (PG11) enable index-only scans without making the extra columns part of the key: &lt;code&gt;CREATE INDEX ON orders (customer_id) INCLUDE (total);&lt;/code&gt; &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip scan&lt;/strong&gt; (&lt;strong&gt;PG18&lt;/strong&gt;): a multicolumn index on &lt;code&gt;(region, created_at)&lt;/code&gt; can now serve &lt;code&gt;WHERE created_at &amp;gt; ...&lt;/code&gt; even with no condition on &lt;code&gt;region&lt;/code&gt;. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uuidv7()&lt;/code&gt;&lt;/strong&gt; (&lt;strong&gt;PG18&lt;/strong&gt;): time-ordered UUIDs land at the end of the B-tree instead of at random pages. That means fewer page splits than random &lt;code&gt;gen_random_uuid()&lt;/code&gt; values. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Index features that apply to every type&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial indexes&lt;/strong&gt; index only the rows you query: &lt;code&gt;CREATE INDEX ON orders (created_at) WHERE status = 'open';&lt;/code&gt; &lt;a href="https://www.postgresql.org/docs/current/indexes-partial.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expression indexes&lt;/strong&gt; index a computed value: &lt;code&gt;CREATE INDEX ON users (lower(email));&lt;/code&gt; &lt;a href="https://www.postgresql.org/docs/current/indexes-expressional.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;&lt;/strong&gt; builds an index without blocking writes. It can't run inside a transaction, and if it fails it leaves an &lt;code&gt;INVALID&lt;/code&gt; index you must drop. &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operator classes&lt;/strong&gt; change what an index can answer:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;text_pattern_ops&lt;/code&gt; makes &lt;code&gt;LIKE 'abc%'&lt;/code&gt; indexable under non-C collations. &lt;a href="https://www.postgresql.org/docs/current/indexes-opclass.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jsonb_path_ops&lt;/code&gt; gives a smaller, faster GIN index that supports only containment (&lt;code&gt;@&amp;gt;&lt;/code&gt;) and jsonpath queries. &lt;a href="https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GIN pending list.&lt;/strong&gt; With &lt;code&gt;fastupdate&lt;/code&gt; on (the default), new entries go to a pending list that is merged later. This makes writes cheaper, but a reader that triggers the merge pays the cost. For steady read latency, shrink &lt;code&gt;gin_pending_list_limit&lt;/code&gt; (default 4 MB) or turn &lt;code&gt;fastupdate&lt;/code&gt; off. &lt;a href="https://www.postgresql.org/docs/current/gin.html#GIN-TIPS" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BRIN&lt;/strong&gt; stores just the min and max for each block range (default &lt;code&gt;pages_per_range = 128&lt;/code&gt;), so it is kilobytes where a B-tree would be gigabytes. PG14 added &lt;code&gt;minmax_multi&lt;/code&gt; operator classes, which tolerate outliers, and &lt;code&gt;bloom&lt;/code&gt; operator classes for unordered equality lookups. &lt;a href="https://www.postgresql.org/docs/current/brin.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="n"&gt;timestamptz_minmax_multi_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages_per_range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PG18 can also build &lt;strong&gt;GIN&lt;/strong&gt; indexes in parallel. B-tree (PG11) and BRIN (PG17) builds were already parallel. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  1.4 Row and page layout knobs
&lt;/h3&gt;

&lt;p&gt;These are set per table or per column, and they change how bytes are laid out on disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fillfactor&lt;/code&gt; and HOT updates&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default 100 for tables. Setting it to 80–90 leaves free space on each page.&lt;/li&gt;
&lt;li&gt;An update can then write the new row version on the &lt;strong&gt;same page&lt;/strong&gt;, and if no indexed column changed, no index is touched. That's a &lt;em&gt;heap-only tuple&lt;/em&gt; (HOT) update.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ALTER TABLE accounts SET (fillfactor = 85);&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/storage-hot.html" rel="noopener noreferrer"&gt;HOT docs&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS" rel="noopener noreferrer"&gt;storage parameters&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TOAST (how big values are stored)&lt;/strong&gt; (&lt;a href="https://www.postgresql.org/docs/current/storage-toast.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Values larger than about 2 KB are compressed and/or moved out of line into a TOAST table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;toast_tuple_target&lt;/code&gt; (default 2032 bytes) controls when that kicks in.&lt;/li&gt;
&lt;li&gt;Per-column storage modes:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PLAIN&lt;/code&gt;: inline only, no compression.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MAIN&lt;/code&gt;: compress, and move out of line only as a last resort.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EXTERNAL&lt;/code&gt;: out of line, no compression. Fast &lt;code&gt;substring()&lt;/code&gt; on large text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EXTENDED&lt;/code&gt;: compress, then move out of line. The default for most types.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;STORAGE&lt;/span&gt; &lt;span class="k"&gt;EXTERNAL&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;Compression algorithm&lt;/strong&gt; (PG14)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose &lt;code&gt;pglz&lt;/code&gt; (the default) or &lt;code&gt;lz4&lt;/code&gt; per column, or server-wide with &lt;code&gt;default_toast_compression&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;lz4 is much faster to compress and decompress. It requires a server built with lz4 support.&lt;/li&gt;
&lt;li&gt;Changing it only affects newly written values.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-DEFAULT-TOAST-COMPRESSION" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;COMPRESSION&lt;/span&gt; &lt;span class="n"&gt;lz4&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;Virtual generated columns&lt;/strong&gt; (&lt;strong&gt;PG18&lt;/strong&gt;): computed when read, so they take no disk space. Before PG18, generated columns always took space (&lt;code&gt;STORED&lt;/code&gt;). &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  1.5 WAL and durability knobs
&lt;/h3&gt;

&lt;p&gt;The write-ahead log is what makes commits survive crashes. It's also the biggest lever for write speed. All of these are on the &lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html" rel="noopener noreferrer"&gt;WAL settings page&lt;/a&gt; unless linked otherwise.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it trades&lt;/th&gt;
&lt;th&gt;Safe?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-SYNCHRONOUS-COMMIT" rel="noopener noreferrer"&gt;&lt;code&gt;synchronous_commit&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;off&lt;/code&gt; returns before the WAL flush. A crash can lose the last few hundred ms of commits, but &lt;strong&gt;never corrupts&lt;/strong&gt; data&lt;/td&gt;
&lt;td&gt;✅ Per transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-WAL-COMPRESSION" rel="noopener noreferrer"&gt;&lt;code&gt;wal_compression&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compresses full-page images with &lt;code&gt;pglz&lt;/code&gt;, &lt;code&gt;lz4&lt;/code&gt; or &lt;code&gt;zstd&lt;/code&gt; (PG15+). Costs CPU, saves WAL volume and replication bandwidth&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-CHECKPOINT-TIMEOUT" rel="noopener noreferrer"&gt;&lt;code&gt;checkpoint_timeout&lt;/code&gt;&lt;/a&gt; / &lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE" rel="noopener noreferrer"&gt;&lt;code&gt;max_wal_size&lt;/code&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;5min&lt;/code&gt; / &lt;code&gt;1GB&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fewer checkpoints mean fewer full-page writes, at the cost of longer crash recovery&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-WAL-LEVEL" rel="noopener noreferrer"&gt;&lt;code&gt;wal_level&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;replica&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;logical&lt;/code&gt; is needed for CDC and logical replication. &lt;code&gt;minimal&lt;/code&gt; suits standalone bulk loads&lt;/td&gt;
&lt;td&gt;✅ Restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-COMMIT-DELAY" rel="noopener noreferrer"&gt;&lt;code&gt;commit_delay&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Groups commits together. Helps only at high concurrency on slow fsync&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-FULL-PAGE-WRITES" rel="noopener noreferrer"&gt;&lt;code&gt;full_page_writes&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Protects against torn pages. Turn off only if storage guarantees atomic 8 KB writes&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-FSYNC" rel="noopener noreferrer"&gt;&lt;code&gt;fsync&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;off&lt;/code&gt; risks &lt;strong&gt;unrecoverable corruption&lt;/strong&gt; on a crash&lt;/td&gt;
&lt;td&gt;❌ Throwaway loads only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Per-transaction durability&lt;/strong&gt; is the most underused trick here:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;synchronous_commit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- only this transaction&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;page_views&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;           &lt;span class="c1"&gt;-- losing this on a crash is acceptable&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&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://www.postgresql.org/docs/current/wal-async-commit.html" rel="noopener noreferrer"&gt;Asynchronous commit docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unlogged tables&lt;/strong&gt; skip WAL entirely. That makes them much faster to write, but they are &lt;strong&gt;emptied after a crash&lt;/strong&gt; and not replicated. They suit staging and cache tables. Switch between the two modes with &lt;code&gt;ALTER TABLE t SET LOGGED | UNLOGGED&lt;/code&gt;. &lt;a href="https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups, archiving and replication&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;archive_library&lt;/code&gt;&lt;/strong&gt; (PG15) archives WAL through a loadable module instead of running a shell command for every file. &lt;a href="https://www.postgresql.org/docs/current/archive-modules.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental backups&lt;/strong&gt; (PG17): set &lt;code&gt;summarize_wal = on&lt;/code&gt;, then run &lt;code&gt;pg_basebackup --incremental&lt;/code&gt; and merge with &lt;a href="https://www.postgresql.org/docs/current/app-pgcombinebackup.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_combinebackup&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quorum synchronous replication&lt;/strong&gt;: &lt;code&gt;synchronous_standby_names = 'ANY 1 (replica_a, replica_b)'&lt;/code&gt; waits for any one of the replicas, so one slow standby doesn't stall commits. &lt;a href="https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-SYNCHRONOUS-STANDBY-NAMES" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official &lt;a href="https://www.postgresql.org/docs/current/non-durability.html" rel="noopener noreferrer"&gt;Non-Durable Settings&lt;/a&gt; page lists every knob that trades safety for speed, in one place.&lt;/p&gt;




&lt;h3&gt;
  
  
  1.6 Memory and cache
&lt;/h3&gt;

&lt;p&gt;All on the &lt;a href="https://www.postgresql.org/docs/current/runtime-config-resource.html" rel="noopener noreferrer"&gt;resource consumption page&lt;/a&gt;, except where linked otherwise.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Guidance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared_buffers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128 MB&lt;/td&gt;
&lt;td&gt;Start around &lt;strong&gt;25% of RAM&lt;/strong&gt; on a dedicated server. The OS page cache holds the rest. &lt;strong&gt;Restart&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE" rel="noopener noreferrer"&gt;&lt;code&gt;effective_cache_size&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;planner hint only&lt;/strong&gt;; it allocates nothing. Set to ~50–75% of RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4 MB&lt;/td&gt;
&lt;td&gt;Applies &lt;strong&gt;per sort or hash operation, per worker&lt;/strong&gt;, so one query can use it many times over. Raise it per session for analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hash_mem_multiplier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.0 (PG15+)&lt;/td&gt;
&lt;td&gt;Lets hash joins and aggregates use &lt;code&gt;work_mem × this&lt;/code&gt; before spilling to disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64 MB&lt;/td&gt;
&lt;td&gt;Used by VACUUM, &lt;code&gt;CREATE INDEX&lt;/code&gt; and adding foreign keys. 1–2 GB speeds up index builds (it matters a lot for vector indexes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;huge_pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;try&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;on&lt;/code&gt; with preallocated Linux huge pages when shared_buffers is ≥ 8 GB. &lt;strong&gt;Restart&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Keeping hot data hot:&lt;/strong&gt; &lt;a href="https://www.postgresql.org/docs/current/pgprewarm.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_prewarm&lt;/code&gt;&lt;/a&gt; can load a table into cache on demand. Its &lt;strong&gt;autoprewarm&lt;/strong&gt; worker saves the list of cached blocks and reloads it after a restart, so you don't start with a cold cache. &lt;a href="https://www.postgresql.org/docs/current/pgbuffercache.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_buffercache&lt;/code&gt;&lt;/a&gt; shows what's in the cache right now.&lt;/p&gt;




&lt;h3&gt;
  
  
  1.7 Disks, SSDs and asynchronous I/O
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tell the planner you're on SSD.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-RANDOM-PAGE-COST" rel="noopener noreferrer"&gt;&lt;code&gt;random_page_cost&lt;/code&gt;&lt;/a&gt; defaults to &lt;strong&gt;4.0&lt;/strong&gt;, which assumes spinning disks.&lt;/li&gt;
&lt;li&gt;On NVMe or cloud SSDs, &lt;strong&gt;1.1–1.5&lt;/strong&gt; makes the planner choose index scans when it should.&lt;/li&gt;
&lt;li&gt;It can also be set &lt;strong&gt;per tablespace&lt;/strong&gt; (see 1.8).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Asynchronous I/O (PG18).&lt;/strong&gt; This is one of the biggest storage changes in years.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The new &lt;a href="https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-IO-METHOD" rel="noopener noreferrer"&gt;&lt;code&gt;io_method&lt;/code&gt;&lt;/a&gt; setting lets backends queue several reads at once for sequential scans, bitmap heap scans and VACUUM. &lt;strong&gt;Restart&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Options: &lt;code&gt;sync&lt;/code&gt; (the old behaviour), &lt;code&gt;worker&lt;/code&gt; (background I/O workers) and &lt;code&gt;io_uring&lt;/code&gt; (Linux, needs a build with liburing).&lt;/li&gt;
&lt;li&gt;The new &lt;code&gt;pg_aios&lt;/code&gt; view shows I/O in flight.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;io_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;io_uring            # or 'worker' (default)&lt;/span&gt;
&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256  # PG18 default is 16&lt;/span&gt;
&lt;span class="py"&gt;io_combine_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256kB        # PG17+: largest combined read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PG18 also raised the defaults of &lt;code&gt;effective_io_concurrency&lt;/code&gt; and &lt;code&gt;maintenance_io_concurrency&lt;/code&gt; &lt;strong&gt;to 16&lt;/strong&gt;. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data checksums are now on by default&lt;/strong&gt; (PG18 &lt;code&gt;initdb&lt;/code&gt;), so silent disk corruption is detected when pages are read. Use &lt;code&gt;--no-data-checksums&lt;/code&gt; to opt out. &lt;code&gt;pg_upgrade&lt;/code&gt; needs matching settings in the old and new clusters. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/app-pgchecksums.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_checksums&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  1.8 Hot and cold data: tablespaces and partitioning
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tablespaces map tables to disks.&lt;/strong&gt; Put the hot tables on NVMe and the archive on cheap disks, and tell the planner each disk's real cost:&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;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt; &lt;span class="k"&gt;LOCATION&lt;/span&gt; &lt;span class="s1"&gt;'/nvme/pg'&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random_page_cost&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&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;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="k"&gt;LOCATION&lt;/span&gt; &lt;span class="s1"&gt;'/hdd/pg'&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random_page_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_2019&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- locks and copies the table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;a href="https://www.postgresql.org/docs/current/sql-createtablespace.html" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE TABLESPACE&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/manage-ag-tablespaces.html" rel="noopener noreferrer"&gt;managing tablespaces&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declarative partitioning&lt;/strong&gt; makes hot and cold data a matter of metadata (&lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RANGE&lt;/strong&gt;, &lt;strong&gt;LIST&lt;/strong&gt; and &lt;strong&gt;HASH&lt;/strong&gt; partitions. &lt;strong&gt;Partition pruning&lt;/strong&gt; skips irrelevant partitions both at plan time and at run time.&lt;/li&gt;
&lt;li&gt;Each partition can live in its &lt;strong&gt;own tablespace&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;&lt;code&gt;DETACH PARTITION ... CONCURRENTLY&lt;/code&gt;&lt;/a&gt; (PG14) removes an old partition without blocking queries. You can then archive it, move it, or drop it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;RANGE&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events_2026_09&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-09-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;fast&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;events_2025&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-01-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;slow&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;Automating it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/pgpartman/pg_partman" rel="noopener noreferrer"&gt;pg_partman&lt;/a&gt;&lt;/strong&gt; creates future partitions and applies retention rules: drop old partitions, or detach them into an archive schema. It runs as a background worker or via &lt;code&gt;CALL partman.run_maintenance_proc()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.tigerdata.com/" rel="noopener noreferrer"&gt;TimescaleDB&lt;/a&gt;&lt;/strong&gt; compresses old chunks into its columnstore automatically, e.g. &lt;code&gt;CALL add_columnstore_policy('metrics', after =&amp;gt; INTERVAL '7 days');&lt;/code&gt; (&lt;a href="https://docs.tigerdata.com/api/latest/hypercore/add_columnstore_policy/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). On its managed cloud, it can also &lt;a href="https://docs.tigerdata.com/use-timescale/latest/data-tiering/" rel="noopener noreferrer"&gt;tier old chunks to S3&lt;/a&gt;. (The company is now called TigerData; the extension is still TimescaleDB.)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.9 Lakehouse, object storage and external data
&lt;/h3&gt;

&lt;p&gt;This is where PostgreSQL is changing fastest. Postgres is turning into a &lt;strong&gt;front door to the data lake&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;Tool&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;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/postgres-fdw.html" rel="noopener noreferrer"&gt;postgres_fdw&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Query another Postgres. Pushes down filters, joins, aggregates and sorts. &lt;code&gt;async_capable&lt;/code&gt; (PG14) scans remote partitions in parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/file-fdw.html" rel="noopener noreferrer"&gt;file_fdw&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read server-side CSV or text files as tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/EnterpriseDB/mysql_fdw" rel="noopener noreferrer"&gt;mysql_fdw&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read and write MySQL tables from Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/duckdb/pg_duckdb" rel="noopener noreferrer"&gt;pg_duckdb&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read and write &lt;strong&gt;Parquet, CSV, JSON, Iceberg and Delta&lt;/strong&gt; on S3, GCS, Azure and R2, and join them with regular tables. &lt;code&gt;SET duckdb.force_execution = true;&lt;/code&gt; also runs regular queries on DuckDB's engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/Snowflake-Labs/pg_lake" rel="noopener noreferrer"&gt;pg_lake&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transactional &lt;strong&gt;Iceberg&lt;/strong&gt; tables (&lt;code&gt;USING iceberg&lt;/code&gt;) with Postgres as the catalog. Query and &lt;code&gt;COPY&lt;/code&gt; Parquet, CSV and JSON on S3. Open-sourced by Snowflake from its Crunchy Data acquisition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/Mooncake-Labs/pg_mooncake" rel="noopener noreferrer"&gt;pg_mooncake&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Keeps a &lt;strong&gt;columnstore mirror&lt;/strong&gt; of a Postgres table in Iceberg format, synced via logical replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://neon.com/docs/introduction/architecture-overview" rel="noopener noreferrer"&gt;Neon&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A modified Postgres with &lt;strong&gt;separate storage and compute&lt;/strong&gt;: WAL goes to safekeepers, and pageservers keep pages on object storage. This makes instant branching and scale-to-zero possible. Databricks &lt;a href="https://www.databricks.com/company/newsroom/press-releases/databricks-agrees-acquire-neon-help-developers-deliver-ai-systems" rel="noopener noreferrer"&gt;acquired Neon&lt;/a&gt; in 2025&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 sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pg_duckdb: join a Parquet file on S3 with a local table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&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="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;FROM&lt;/span&gt; &lt;span class="n"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3://my-bucket/reviews/*.parquet'&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;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;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="s1"&gt;'customer_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Pattern: keep hot data in heap, send cold data to the lake.&lt;/strong&gt; Keep recent rows in regular partitions.&lt;br&gt;
Periodically copy old partitions to Parquet or Iceberg on object storage (pg_duckdb or pg_lake), then detach&lt;br&gt;
and drop them locally. You can still query all of it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  1.10 Data types that change how data is stored and found
&lt;/h3&gt;

&lt;p&gt;Choosing the right type changes both storage size and which indexes can help.&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;Why it matters&lt;/th&gt;
&lt;th&gt;Index with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html" rel="noopener noreferrer"&gt;&lt;code&gt;jsonb&lt;/code&gt;&lt;/a&gt; vs &lt;code&gt;json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jsonb&lt;/code&gt; is a parsed binary format that's fast to query and indexable. &lt;code&gt;json&lt;/code&gt; keeps the exact input text and is reparsed every time&lt;/td&gt;
&lt;td&gt;GIN (&lt;code&gt;jsonb_ops&lt;/code&gt; or &lt;code&gt;jsonb_path_ops&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/arrays.html" rel="noopener noreferrer"&gt;Arrays&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Many values in one column; containment and overlap queries&lt;/td&gt;
&lt;td&gt;GIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/hstore.html" rel="noopener noreferrer"&gt;&lt;code&gt;hstore&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Flat key/value pairs. Older than jsonb, still compact&lt;/td&gt;
&lt;td&gt;GIN / GiST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/current/rangetypes.html" rel="noopener noreferrer"&gt;Range and multirange types&lt;/a&gt; (multirange PG14)&lt;/td&gt;
&lt;td&gt;Time periods and numeric ranges, plus &lt;strong&gt;exclusion constraints&lt;/strong&gt; ("no overlapping bookings")&lt;/td&gt;
&lt;td&gt;GiST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://postgis.net/" rel="noopener noreferrer"&gt;PostGIS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Geometry and geography, spatial joins, distance queries&lt;/td&gt;
&lt;td&gt;GiST, SP-GiST, BRIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/pgtrgm.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Makes &lt;code&gt;LIKE '%foo%'&lt;/code&gt;, &lt;code&gt;ILIKE&lt;/code&gt;, regex and fuzzy &lt;code&gt;similarity()&lt;/code&gt; indexable&lt;/td&gt;
&lt;td&gt;GIN / GiST (&lt;code&gt;gin_trgm_ops&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/textsearch-indexes.html" rel="noopener noreferrer"&gt;&lt;code&gt;tsvector&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Built-in full-text search with stemming and ranking&lt;/td&gt;
&lt;td&gt;GIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/citext.html" rel="noopener noreferrer"&gt;&lt;code&gt;citext&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Case-insensitive text (emails, usernames)&lt;/td&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postgresql.org/docs/current/ltree.html" rel="noopener noreferrer"&gt;&lt;code&gt;ltree&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Tree paths (&lt;code&gt;europe.germany.berlin&lt;/code&gt;) for category hierarchies&lt;/td&gt;
&lt;td&gt;GiST&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 sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- "no two bookings of the same room may overlap", enforced by the database&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;btree_gist&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;bookings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;during&lt;/span&gt; &lt;span class="n"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;during&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;h3&gt;
  
  
  1.11 VACUUM and MVCC knobs
&lt;/h3&gt;

&lt;p&gt;PostgreSQL keeps old row versions for MVCC, and autovacuum cleans them up. Autovacuum that falls behind is the most common cause of table bloat. All settings are on the &lt;a href="https://www.postgresql.org/docs/current/runtime-config-vacuum.html" rel="noopener noreferrer"&gt;vacuuming settings page&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger formula&lt;/strong&gt;: a table is vacuumed when dead rows exceed &lt;code&gt;autovacuum_vacuum_threshold&lt;/code&gt; (50) + &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; (0.2) × table rows. On a 1-billion-row table, that's 200 million dead rows. Lower the scale factor &lt;strong&gt;per table&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;big_events&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autovacuum_vacuum_scale_factor&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="mi"&gt;01&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Insert-only tables&lt;/strong&gt; (PG13+) now get vacuumed too, via &lt;code&gt;autovacuum_vacuum_insert_threshold&lt;/code&gt;. That keeps the visibility map current, which index-only scans depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PG18&lt;/strong&gt; adds &lt;code&gt;autovacuum_vacuum_max_threshold&lt;/code&gt;, a cap on the trigger for huge tables. It also adds &lt;code&gt;autovacuum_worker_slots&lt;/code&gt;, so you can raise &lt;code&gt;autovacuum_max_workers&lt;/code&gt; without a restart. &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PG18 release notes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed&lt;/strong&gt;: on SSD, raise &lt;code&gt;autovacuum_vacuum_cost_limit&lt;/code&gt; (it effectively defaults to 200) so vacuum keeps up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reclaiming space&lt;/strong&gt;: &lt;a href="https://www.postgresql.org/docs/current/sql-vacuum.html" rel="noopener noreferrer"&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt;&lt;/a&gt; rewrites the table but blocks all access while it runs. &lt;a href="https://github.com/reorg/pg_repack" rel="noopener noreferrer"&gt;&lt;code&gt;pg_repack&lt;/code&gt;&lt;/a&gt; does the same online, with only brief locks.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.12 Query execution and connections
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Parallel query&lt;/strong&gt; (&lt;a href="https://www.postgresql.org/docs/current/runtime-config-resource.html#RUNTIME-CONFIG-RESOURCE-ASYNC-BEHAVIOR" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max_parallel_workers_per_gather&lt;/code&gt; (default 2) controls how many CPU cores one query can use.&lt;/li&gt;
&lt;li&gt;Raise it for analytics. Keep it low for high-concurrency OLTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;JIT compilation&lt;/strong&gt; (&lt;a href="https://www.postgresql.org/docs/current/jit-decision.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On by default, and kicks in above &lt;code&gt;jit_above_cost&lt;/code&gt; (100000).&lt;/li&gt;
&lt;li&gt;Compiling can take longer than a medium-sized query itself, so many OLTP systems set &lt;code&gt;jit = off&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Statistics&lt;/strong&gt; (&lt;a href="https://www.postgresql.org/docs/current/sql-createstatistics.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For badly estimated skewed columns, raise statistics per column: &lt;code&gt;ALTER TABLE t ALTER COLUMN c SET STATISTICS 1000;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CREATE STATISTICS&lt;/code&gt;&lt;/strong&gt; teaches the planner about &lt;strong&gt;correlated columns&lt;/strong&gt; such as city and zip code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Connection poolers.&lt;/strong&gt; Every PostgreSQL connection is a separate process, so thousands of clients need a pooler in front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.pgbouncer.org/config.html" rel="noopener noreferrer"&gt;PgBouncer&lt;/a&gt;&lt;/strong&gt;: &lt;code&gt;pool_mode = transaction&lt;/code&gt; is the usual choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/postgresml/pgcat" rel="noopener noreferrer"&gt;PgCat&lt;/a&gt;&lt;/strong&gt;: pooling plus load balancing and sharding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/supabase/supavisor" rel="noopener noreferrer"&gt;Supavisor&lt;/a&gt;&lt;/strong&gt;: cloud-native and multi-tenant.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.13 Observability plug-ins
&lt;/h3&gt;

&lt;p&gt;You can't tune what you can't see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/pgstatstatements.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: the top queries by total time. The first extension to install.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/auto-explain.html" rel="noopener noreferrer"&gt;&lt;code&gt;auto_explain&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: logs the execution plan of any query slower than N ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_stat_io&lt;/code&gt;&lt;/strong&gt; (PG16): I/O broken down by backend type and context. &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;track_io_timing = on&lt;/code&gt;&lt;/strong&gt;: adds I/O time to &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; and the stats views.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.14 PostgreSQL starter profiles
&lt;/h3&gt;

&lt;p&gt;These are starting points for a &lt;strong&gt;64 GB RAM, 16 vCPU, NVMe&lt;/strong&gt; server on PG18. Always benchmark. &lt;a href="https://pgtune.leopard.in.ua/" rel="noopener noreferrer"&gt;PGTune&lt;/a&gt; and the &lt;a href="https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server" rel="noopener noreferrer"&gt;official tuning wiki&lt;/a&gt; are good sanity checks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (a) OLTP on NVMe
&lt;/span&gt;&lt;span class="py"&gt;shared_buffers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16GB&lt;/span&gt;
&lt;span class="py"&gt;effective_cache_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;48GB&lt;/span&gt;
&lt;span class="py"&gt;work_mem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16MB&lt;/span&gt;
&lt;span class="py"&gt;maintenance_work_mem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2GB&lt;/span&gt;
&lt;span class="py"&gt;huge_pages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;on&lt;/span&gt;
&lt;span class="py"&gt;wal_compression&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;lz4&lt;/span&gt;
&lt;span class="py"&gt;checkpoint_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;15min&lt;/span&gt;
&lt;span class="py"&gt;max_wal_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16GB&lt;/span&gt;
&lt;span class="py"&gt;random_page_cost&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1.1&lt;/span&gt;
&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256&lt;/span&gt;
&lt;span class="py"&gt;io_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;io_uring              # PG18 + liburing build; else 'worker'&lt;/span&gt;
&lt;span class="py"&gt;autovacuum_vacuum_cost_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2000&lt;/span&gt;
&lt;span class="py"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.05&lt;/span&gt;
&lt;span class="py"&gt;jit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="py"&gt;shared_preload_libraries&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'pg_stat_statements,auto_explain,pg_prewarm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (b) Bulk load / ETL (trades some durability, never corrupts)
&lt;/span&gt;&lt;span class="py"&gt;synchronous_commit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="py"&gt;wal_level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;minimal               # no replicas / PITR on this box&lt;/span&gt;
&lt;span class="py"&gt;max_wal_senders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;archive_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="py"&gt;max_wal_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;64GB&lt;/span&gt;
&lt;span class="py"&gt;checkpoint_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;30min&lt;/span&gt;
&lt;span class="py"&gt;maintenance_work_mem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4GB&lt;/span&gt;
&lt;span class="py"&gt;max_parallel_maintenance_workers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="c"&gt;# + UNLOGGED staging tables + COPY, then ALTER TABLE ... SET LOGGED
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (c) Analytics / reporting
&lt;/span&gt;&lt;span class="py"&gt;work_mem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256MB                  # per operation, per worker!&lt;/span&gt;
&lt;span class="py"&gt;hash_mem_multiplier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4.0&lt;/span&gt;
&lt;span class="py"&gt;max_parallel_workers_per_gather&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;max_parallel_workers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16&lt;/span&gt;
&lt;span class="py"&gt;max_worker_processes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16&lt;/span&gt;
&lt;span class="py"&gt;random_page_cost&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1.1&lt;/span&gt;
&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256&lt;/span&gt;
&lt;span class="py"&gt;default_statistics_target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;500&lt;/span&gt;
&lt;span class="py"&gt;enable_partitionwise_join&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;on&lt;/span&gt;
&lt;span class="py"&gt;enable_partitionwise_aggregate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 2: MySQL (plus MariaDB and Percona)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 How MySQL plugs things in
&lt;/h3&gt;

&lt;p&gt;MySQL made "pluggable storage engines" famous. The server layer (parser, optimizer, replication) talks to storage engines through a handler API. On top of that sits a general &lt;strong&gt;plugin&lt;/strong&gt; system, plus the newer &lt;strong&gt;component&lt;/strong&gt; system:&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;SHOW&lt;/span&gt; &lt;span class="n"&gt;ENGINES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                          &lt;span class="c1"&gt;-- storage engines&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;PLUGINS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                          &lt;span class="c1"&gt;-- everything plugged in&lt;/span&gt;
&lt;span class="n"&gt;INSTALL&lt;/span&gt; &lt;span class="n"&gt;PLUGIN&lt;/span&gt; &lt;span class="n"&gt;clone&lt;/span&gt; &lt;span class="n"&gt;SONAME&lt;/span&gt; &lt;span class="s1"&gt;'mysql_clone.so'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- classic plugin&lt;/span&gt;
&lt;span class="n"&gt;INSTALL&lt;/span&gt; &lt;span class="n"&gt;COMPONENT&lt;/span&gt; &lt;span class="s1"&gt;'file://component_validate_password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- newer component model&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/plugin-loading.html" rel="noopener noreferrer"&gt;Plugin loading&lt;/a&gt; · &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/plugin-types.html" rel="noopener noreferrer"&gt;plugin types&lt;/a&gt; · &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/component-loading.html" rel="noopener noreferrer"&gt;components&lt;/a&gt;)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The big difference from PostgreSQL:&lt;/strong&gt; in MySQL a storage engine is the &lt;strong&gt;whole package&lt;/strong&gt;. It brings its own&lt;br&gt;
index format, locking, crash recovery and caching. PostgreSQL splits table engines and index types into separate&lt;br&gt;
layers that you can mix freely.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2.2 Storage engines
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MyISAM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- full table copy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default comes from &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_default_storage_engine" rel="noopener noreferrer"&gt;&lt;code&gt;default_storage_engine&lt;/code&gt;&lt;/a&gt;. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_disabled_storage_engines" rel="noopener noreferrer"&gt;&lt;code&gt;disabled_storage_engines&lt;/code&gt;&lt;/a&gt; blocks the ones you never want used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engines in stock MySQL&lt;/strong&gt; (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/storage-engines.html" rel="noopener noreferrer"&gt;overview&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;Engine&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-storage-engine.html" rel="noopener noreferrer"&gt;InnoDB&lt;/a&gt;&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;ACID, row locks, MVCC, foreign keys, rows clustered by primary key&lt;/td&gt;
&lt;td&gt;Almost everything&lt;/td&gt;
&lt;td&gt;Primary key choice matters (see 2.3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/myisam-storage-engine.html" rel="noopener noreferrer"&gt;MyISAM&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No transactions, table-level locks&lt;/td&gt;
&lt;td&gt;Legacy read-only data&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not crash-safe&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/memory-storage-engine.html" rel="noopener noreferrer"&gt;MEMORY&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RAM only, hash indexes by default&lt;/td&gt;
&lt;td&gt;Temporary lookup tables&lt;/td&gt;
&lt;td&gt;Lost on restart. Size capped by &lt;code&gt;max_heap_table_size&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/archive-storage-engine.html" rel="noopener noreferrer"&gt;ARCHIVE&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;zlib-compressed rows. &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;SELECT&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Cold audit and log data&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt;; almost no indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/csv-storage-engine.html" rel="noopener noreferrer"&gt;CSV&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Plain CSV files on disk&lt;/td&gt;
&lt;td&gt;Swapping files with other tools&lt;/td&gt;
&lt;td&gt;No indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/blackhole-storage-engine.html" rel="noopener noreferrer"&gt;BLACKHOLE&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Throws data away but &lt;strong&gt;still writes it to the binlog&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Replication relays and filters&lt;/td&gt;
&lt;td&gt;Stores nothing, by design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/federated-storage-engine.html" rel="noopener noreferrer"&gt;FEDERATED&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A proxy to a table on another MySQL server&lt;/td&gt;
&lt;td&gt;Occasional remote reads&lt;/td&gt;
&lt;td&gt;Disabled by default; slow joins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/mysql-cluster.html" rel="noopener noreferrer"&gt;NDB&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Distributed, shared-nothing, in-memory&lt;/td&gt;
&lt;td&gt;Telecom-style high availability&lt;/td&gt;
&lt;td&gt;Ships as the separate NDB Cluster product&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Extra engines in MariaDB and Percona Server&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;Engine&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://docs.percona.com/percona-server/8.4/myrocks-index.html" rel="noopener noreferrer"&gt;MyRocks&lt;/a&gt;&lt;/strong&gt; (RocksDB LSM)&lt;/td&gt;
&lt;td&gt;Percona, &lt;a href="https://mariadb.com/kb/en/myrocks/" rel="noopener noreferrer"&gt;MariaDB&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Write-heavy workloads and data much bigger than RAM. &lt;strong&gt;Much better compression&lt;/strong&gt; and less flash wear than InnoDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/mariadb-columnstore/" rel="noopener noreferrer"&gt;ColumnStore&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MariaDB&lt;/td&gt;
&lt;td&gt;Columnar analytics (OLAP), with optional object storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/s3-storage-engine/" rel="noopener noreferrer"&gt;S3&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MariaDB 10.5+&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Read-only archive tables stored on S3&lt;/strong&gt;. Move a table there with &lt;code&gt;ALTER TABLE old_orders ENGINE=S3;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/spider/" rel="noopener noreferrer"&gt;Spider&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MariaDB&lt;/td&gt;
&lt;td&gt;Shards one logical table across many servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/connect/" rel="noopener noreferrer"&gt;CONNECT&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MariaDB&lt;/td&gt;
&lt;td&gt;Treats CSV, JSON, XML, ODBC and remote tables as MariaDB tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/aria/" rel="noopener noreferrer"&gt;Aria&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MariaDB&lt;/td&gt;
&lt;td&gt;Crash-safe replacement for MyISAM; used for internal temp tables&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;MyRocks trade-offs:&lt;/strong&gt; no foreign keys, no FULLTEXT or SPATIAL indexes, and it requires &lt;code&gt;binlog_format=ROW&lt;/code&gt;.&lt;br&gt;
&lt;a href="https://docs.percona.com/percona-server/8.4/myrocks-limitations.html" rel="noopener noreferrer"&gt;Limitations&lt;/a&gt;.&lt;br&gt;
TokuDB, the other write-optimised engine, has been removed from both&lt;br&gt;
&lt;a href="https://docs.percona.com/percona-server/8.0/release-notes/Percona-Server-8.0.28-19.html" rel="noopener noreferrer"&gt;Percona&lt;/a&gt; and&lt;br&gt;
MariaDB (removed in 10.6).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2.3 InnoDB layout: clustered keys, page size and row formats
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The clustered primary key&lt;/strong&gt; (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;InnoDB stores &lt;strong&gt;rows inside the primary-key B-tree&lt;/strong&gt;, and every secondary index stores the primary key as its row pointer. So:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep primary keys short.&lt;/strong&gt; A 36-byte UUID string gets copied into every secondary index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep inserts sequential.&lt;/strong&gt; Random UUIDs split pages all over the tree. &lt;code&gt;UUID_TO_BIN(UUID(), 1)&lt;/code&gt; reorders the time bits so new values sort roughly in insert order.&lt;/li&gt;
&lt;li&gt;With no primary key, InnoDB invents a hidden 6-byte one. &lt;code&gt;sql_generate_invisible_primary_key&lt;/code&gt; (8.0.30) makes it visible and explicit.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Page size&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_page_size" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_page_size&lt;/code&gt;&lt;/a&gt;: 4 KB to 64 KB, default 16 KB.&lt;/li&gt;
&lt;li&gt;It can only be set &lt;strong&gt;when the data directory is initialised&lt;/strong&gt;. Smaller pages suit point lookups on SSD; larger pages suit scans.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Row formats&lt;/strong&gt; (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-row-format.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DYNAMIC&lt;/code&gt; is the default. It stores long &lt;code&gt;BLOB&lt;/code&gt;, &lt;code&gt;TEXT&lt;/code&gt; and &lt;code&gt;VARCHAR&lt;/code&gt; values &lt;strong&gt;fully off-page&lt;/strong&gt;, keeping only a 20-byte pointer in the row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COMPRESSED&lt;/code&gt; adds zlib compression (see 2.8).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Instant DDL&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT;&lt;/code&gt; changes only metadata. Since 8.0.29 it works for adding a column at any position, and for dropping columns.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;Online DDL docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.4 Indexes
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B-tree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;Everything; the clustered primary key plus secondary indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING HASH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;MEMORY and NDB only&lt;/strong&gt;. InnoDB silently uses a B-tree instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Adaptive hash index&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;automatic&lt;/td&gt;
&lt;td&gt;InnoDB builds an in-memory hash over hot pages. &lt;strong&gt;Off by default in 8.4&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/fulltext-search.html" rel="noopener noreferrer"&gt;FULLTEXT&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FULLTEXT(body)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Natural-language search. Add &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/fulltext-search-ngram.html" rel="noopener noreferrer"&gt;&lt;code&gt;WITH PARSER ngram&lt;/code&gt;&lt;/a&gt; for Chinese, Japanese and Korean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/spatial-types.html" rel="noopener noreferrer"&gt;SPATIAL&lt;/a&gt;&lt;/strong&gt; (R-tree)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SPATIAL INDEX(g)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Geo queries. The column needs &lt;code&gt;NOT NULL&lt;/code&gt; and an &lt;code&gt;SRID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/create-index.html#create-index-functional-key-parts" rel="noopener noreferrer"&gt;Functional&lt;/a&gt;&lt;/strong&gt; (8.0.13)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INDEX ((LOWER(email)))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Indexing expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/create-index.html#create-index-multi-valued" rel="noopener noreferrer"&gt;Multi-valued&lt;/a&gt;&lt;/strong&gt; (8.0.17)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INDEX ((CAST(doc-&amp;gt;'$.tags' AS CHAR(32) ARRAY)))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrays inside JSON (&lt;code&gt;MEMBER OF&lt;/code&gt;, &lt;code&gt;JSON_OVERLAPS&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/descending-indexes.html" rel="noopener noreferrer"&gt;Descending&lt;/a&gt;&lt;/strong&gt; (8.0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INDEX (a ASC, b DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixed-direction &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/invisible-indexes.html" rel="noopener noreferrer"&gt;Invisible&lt;/a&gt;&lt;/strong&gt; (8.0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALTER TABLE t ALTER INDEX i INVISIBLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Test dropping an index safely.&lt;/strong&gt; It's still maintained, but the optimizer ignores it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Tools the optimizer uses instead of, or alongside, indexes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/analyze-table.html" rel="noopener noreferrer"&gt;Histograms&lt;/a&gt;&lt;/strong&gt; (8.0): &lt;code&gt;ANALYZE TABLE t UPDATE HISTOGRAM ON col WITH 64 BUCKETS;&lt;/code&gt; gives the optimizer data distributions &lt;strong&gt;without the write cost of an index&lt;/strong&gt;. 8.4 adds &lt;code&gt;AUTO UPDATE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/hash-joins.html" rel="noopener noreferrer"&gt;Hash join&lt;/a&gt;&lt;/strong&gt; (8.0.18): replaced block nested-loop for joins without usable indexes. Memory is bounded by &lt;code&gt;join_buffer_size&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.5 Durability: redo log, doublewrite and binlog
&lt;/h3&gt;

&lt;p&gt;Variables are on the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html" rel="noopener noreferrer"&gt;InnoDB parameters&lt;/a&gt; and &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html" rel="noopener noreferrer"&gt;binary log options&lt;/a&gt; pages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it trades&lt;/th&gt;
&lt;th&gt;Safe?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2&lt;/code&gt; writes at commit but flushes about once per second, so an &lt;strong&gt;OS crash&lt;/strong&gt; can lose ~1 s. &lt;code&gt;0&lt;/code&gt; means a &lt;strong&gt;mysqld crash&lt;/strong&gt; can lose ~1 s&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_sync_binlog" rel="noopener noreferrer"&gt;&lt;code&gt;sync_binlog&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt; leaves binlog flushing to the OS&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_redo_log_capacity&lt;/code&gt;&lt;/a&gt; (8.0.30)&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;Bigger means fewer forced flushes but longer recovery. &lt;strong&gt;Resizable at runtime&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_doublewrite&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Protects against torn pages. &lt;code&gt;DETECT_ONLY&lt;/code&gt; (8.0.30) detects them but can't repair them&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/binary-log-transaction-compression.html" rel="noopener noreferrer"&gt;&lt;code&gt;binlog_transaction_compression&lt;/code&gt;&lt;/a&gt; (8.0.20)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;zstd-compresses the binlog, and it &lt;strong&gt;stays compressed on replicas&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_binlog_row_image" rel="noopener noreferrer"&gt;&lt;code&gt;binlog_row_image&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MINIMAL&lt;/code&gt; makes the binlog much smaller, &lt;strong&gt;but breaks CDC tools&lt;/strong&gt; like Debezium&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_binlog_group_commit_sync_delay" rel="noopener noreferrer"&gt;&lt;code&gt;binlog_group_commit_sync_delay&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt; µs&lt;/td&gt;
&lt;td&gt;Waits so more commits share one fsync&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The nuclear option for bulk loads:&lt;/strong&gt; &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html#innodb-disable-redo-logging" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER INSTANCE DISABLE INNODB REDO_LOG&lt;/code&gt;&lt;/a&gt; (8.0.21) turns off redo logging for the whole instance.&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;ALTER&lt;/span&gt; &lt;span class="n"&gt;INSTANCE&lt;/span&gt; &lt;span class="n"&gt;DISABLE&lt;/span&gt; &lt;span class="n"&gt;INNODB&lt;/span&gt; &lt;span class="n"&gt;REDO_LOG&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- ONLY on a fresh instance you can rebuild&lt;/span&gt;
&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="k"&gt;DATA&lt;/span&gt; &lt;span class="n"&gt;INFILE&lt;/span&gt; &lt;span class="s1"&gt;'/data/orders.csv'&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;FIELDS&lt;/span&gt; &lt;span class="n"&gt;TERMINATED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;INSTANCE&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="n"&gt;INNODB&lt;/span&gt; &lt;span class="n"&gt;REDO_LOG&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;❌ A crash while redo logging is disabled can leave the &lt;strong&gt;entire instance&lt;/strong&gt; unrecoverable. It's meant only for&lt;br&gt;
loading data into a new instance.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2.6 Memory and the buffer pool
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool-resize.html" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_buffer_pool_size&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: the single most important knob. The default is 128 MB; aim for about &lt;strong&gt;70–75% of RAM&lt;/strong&gt; on a dedicated server. It can be &lt;strong&gt;resized online&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-dedicated-server.html" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_dedicated_server&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: sizes the buffer pool and redo log from the machine's RAM and CPU count. It's a good default on dedicated hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffer pool warm-up&lt;/strong&gt;: &lt;code&gt;innodb_buffer_pool_dump_at_shutdown&lt;/code&gt; and &lt;code&gt;innodb_buffer_pool_load_at_startup&lt;/code&gt; (both on by default) save the hottest pages at shutdown and reload them at startup. That's MySQL's equivalent of &lt;code&gt;pg_prewarm&lt;/code&gt;. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-preload-buffer-pool.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;MySQL 8.4 changed many InnoDB defaults.&lt;/strong&gt; If you upgraded from 8.0 and performance shifted, this is why. (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html" rel="noopener noreferrer"&gt;What's new in 8.4&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;Variable&lt;/th&gt;
&lt;th&gt;8.0 default&lt;/th&gt;
&lt;th&gt;8.4 default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;innodb_adaptive_hash_index&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OFF&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;innodb_change_buffering&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;innodb_flush_method&lt;/code&gt; (Linux)&lt;/td&gt;
&lt;td&gt;fsync&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;O_DIRECT&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;innodb_io_capacity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;innodb_log_buffer_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;innodb_numa_interleave&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OFF&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ON&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  2.7 Disks and SSDs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;innodb_flush_method = O_DIRECT&lt;/code&gt;&lt;/strong&gt; (the 8.4 default on Linux) bypasses the OS page cache, so data isn't cached twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;innodb_io_capacity&lt;/code&gt; / &lt;code&gt;innodb_io_capacity_max&lt;/code&gt;&lt;/strong&gt; tell InnoDB how many IOPS it may use for background flushing. Raise them on fast NVMe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;innodb_flush_neighbors = 0&lt;/code&gt;&lt;/strong&gt; (the default since 8.0) stops InnoDB flushing neighbouring pages together. That trick only helps spinning disks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;innodb_read_io_threads&lt;/code&gt; / &lt;code&gt;innodb_write_io_threads&lt;/code&gt;&lt;/strong&gt;: background I/O threads. &lt;strong&gt;Restart&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_tmpdir" rel="noopener noreferrer"&gt;&lt;code&gt;innodb_tmpdir&lt;/code&gt;&lt;/a&gt; puts the temporary sort files from online &lt;code&gt;ALTER TABLE&lt;/code&gt; on a bigger or faster disk.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.8 Compression
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-compression.html" rel="noopener noreferrer"&gt;Table compression&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;zlib. The buffer pool holds compressed &lt;em&gt;and&lt;/em&gt; uncompressed copies, which costs CPU and RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-page-compression.html" rel="noopener noreferrer"&gt;Transparent page compression&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COMPRESSION='lz4'&lt;/code&gt; or &lt;code&gt;'zlib'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Compresses each page, then &lt;strong&gt;punches holes&lt;/strong&gt; in the file. Needs a filesystem with hole-punching (ext4, XFS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ARCHIVE engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENGINE=ARCHIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Heavy compression, but insert and select only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MyRocks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rocksdb_default_cf_options="compression=kLZ4Compression;bottommost_compression=kZSTD"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compression per LSM level. Usually the best ratio of the lot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Binlog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;binlog_transaction_compression=ON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Smaller binlogs and less replication bandwidth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  2.9 Hot and cold data placement
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Put a table on a different disk&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;innodb_file_per_table&lt;/code&gt; (the default), each table is its own &lt;code&gt;.ibd&lt;/code&gt; file, and &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-create-table-external.html" rel="noopener noreferrer"&gt;&lt;code&gt;DATA DIRECTORY&lt;/code&gt;&lt;/a&gt; can place it on another disk.&lt;/li&gt;
&lt;li&gt;The directory must be listed in &lt;code&gt;innodb_directories&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;logs_archive&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;DATA&lt;/span&gt; &lt;span class="n"&gt;DIRECTORY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/mnt/hdd/mysql'&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;General tablespaces&lt;/strong&gt; group several tables into one file on a chosen disk (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/general-tablespaces.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;ts_cold&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;DATAFILE&lt;/span&gt; &lt;span class="s1"&gt;'/mnt/hdd/ts_cold.ibd'&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;logs_2019&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;ts_cold&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;Partitioning&lt;/strong&gt; (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/partitioning.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Types: &lt;code&gt;RANGE&lt;/code&gt;, &lt;code&gt;LIST&lt;/code&gt;, &lt;code&gt;HASH&lt;/code&gt; and &lt;code&gt;KEY&lt;/code&gt;, plus the &lt;code&gt;COLUMNS&lt;/code&gt; variants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition pruning&lt;/strong&gt; is visible in the &lt;code&gt;partitions&lt;/code&gt; column of &lt;code&gt;EXPLAIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In 8.0+, &lt;strong&gt;only InnoDB and NDB support partitioning&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The classic archiving pattern&lt;/strong&gt; uses &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/partitioning-management-exchange.html" rel="noopener noreferrer"&gt;&lt;code&gt;EXCHANGE PARTITION&lt;/code&gt;&lt;/a&gt;, which swaps a partition with a table instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_2019&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_2019&lt;/span&gt; &lt;span class="n"&gt;REMOVE&lt;/span&gt; &lt;span class="n"&gt;PARTITIONING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;EXCHANGE&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="n"&gt;p2019&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_2019&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- instant swap&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="n"&gt;p2019&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_2019&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ARCHIVE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;-- or ENGINE=S3 on MariaDB → cold storage on S3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2.10 Lakehouse and analytics bridges
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/heatwave/en/" rel="noopener noreferrer"&gt;MySQL HeatWave&lt;/a&gt;&lt;/strong&gt;: Oracle's in-memory, columnar, scale-out accelerator that sits next to InnoDB.

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HeatWave Lakehouse&lt;/strong&gt; queries CSV, Parquet, Avro and JSON files in object storage and joins them with InnoDB tables.&lt;/li&gt;
&lt;li&gt;It's a &lt;strong&gt;managed cloud service&lt;/strong&gt; (Oracle Cloud, AWS and others), &lt;strong&gt;not part of MySQL Community&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/mariadb-columnstore/" rel="noopener noreferrer"&gt;MariaDB ColumnStore&lt;/a&gt;&lt;/strong&gt;: open-source columnar analytics inside MariaDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mariadb.com/kb/en/s3-storage-engine/" rel="noopener noreferrer"&gt;MariaDB S3 engine&lt;/a&gt;&lt;/strong&gt;: cold, read-only tables on object storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDC to the lake&lt;/strong&gt;: stream the binlog through &lt;strong&gt;&lt;a href="https://debezium.io/documentation/reference/stable/connectors/mysql.html" rel="noopener noreferrer"&gt;Debezium&lt;/a&gt;&lt;/strong&gt; and Kafka into Iceberg, Delta or a warehouse. This needs &lt;code&gt;binlog_format=ROW&lt;/code&gt; and &lt;code&gt;binlog_row_image=FULL&lt;/code&gt;, and GTIDs are recommended.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.11 Data types that change storage and retrieval
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/json.html" rel="noopener noreferrer"&gt;JSON&lt;/a&gt;&lt;/strong&gt; is stored in an optimised binary format.

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial in-place updates&lt;/strong&gt; (8.0) apply to &lt;code&gt;JSON_SET&lt;/code&gt;, &lt;code&gt;JSON_REPLACE&lt;/code&gt; and &lt;code&gt;JSON_REMOVE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pair them with &lt;code&gt;binlog_row_value_options=PARTIAL_JSON&lt;/code&gt; so only the diff is written to the binlog.&lt;/li&gt;
&lt;li&gt;Index JSON with &lt;strong&gt;generated columns&lt;/strong&gt;, functional indexes or multi-valued indexes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/spatial-types.html" rel="noopener noreferrer"&gt;Spatial types&lt;/a&gt;&lt;/strong&gt; support SRIDs, with geographic calculations since 8.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/enum.html" rel="noopener noreferrer"&gt;ENUM&lt;/a&gt;&lt;/strong&gt; stores a string as a 1–2 byte integer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BLOB/TEXT&lt;/strong&gt; columns are stored off-page and need prefix indexes. They're one more reason to avoid &lt;code&gt;SELECT *&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/9.7/en/vector.html" rel="noopener noreferrer"&gt;VECTOR&lt;/a&gt;&lt;/strong&gt; (9.0+) is covered in Part 3.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.12 Plugins worth knowing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin / component&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;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/clone-plugin.html" rel="noopener noreferrer"&gt;Clone plugin&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLONE INSTANCE FROM ...&lt;/code&gt; makes a full physical copy for provisioning replicas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thread pool&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Handles thousands of connections efficiently. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/thread-pool.html" rel="noopener noreferrer"&gt;Enterprise-only in MySQL&lt;/a&gt;; free in &lt;a href="https://docs.percona.com/percona-server/8.4/threadpool.html" rel="noopener noreferrer"&gt;Percona&lt;/a&gt; and &lt;a href="https://mariadb.com/kb/en/thread-pool-in-mariadb/" rel="noopener noreferrer"&gt;MariaDB&lt;/a&gt; (&lt;code&gt;thread_handling=pool-of-threads&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/group-replication.html" rel="noopener noreferrer"&gt;Group Replication&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-node, consensus-based high availability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/rewriter-query-rewrite-plugin.html" rel="noopener noreferrer"&gt;Query Rewrite&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rewrites bad queries server-side without changing application code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/keyring.html" rel="noopener noreferrer"&gt;Keyring components&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Encryption at rest (&lt;code&gt;ENCRYPTION='Y'&lt;/code&gt;). The old keyring &lt;em&gt;plugins&lt;/em&gt; are gone in 8.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/validate-password.html" rel="noopener noreferrer"&gt;validate_password&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Password policy, now implemented as a component&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Online schema changes without downtime:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/github/gh-ost" rel="noopener noreferrer"&gt;gh-ost&lt;/a&gt;&lt;/strong&gt;: triggerless; reads the binlog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.percona.com/percona-toolkit/pt-online-schema-change.html" rel="noopener noreferrer"&gt;pt-online-schema-change&lt;/a&gt;&lt;/strong&gt;: uses triggers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/major/MySQLTuner-perl" rel="noopener noreferrer"&gt;MySQLTuner&lt;/a&gt;&lt;/strong&gt; gives quick configuration recommendations.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2.13 MySQL starter profiles
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (a) OLTP on NVMe (64 GB RAM, 16 vCPU)
&lt;/span&gt;&lt;span class="nn"&gt;[mysqld]&lt;/span&gt;
&lt;span class="py"&gt;innodb_buffer_pool_size&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;48G&lt;/span&gt;
&lt;span class="py"&gt;innodb_redo_log_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8G&lt;/span&gt;
&lt;span class="py"&gt;innodb_flush_log_at_trx_commit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;sync_binlog&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;innodb_flush_method&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;O_DIRECT&lt;/span&gt;
&lt;span class="py"&gt;innodb_io_capacity&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10000&lt;/span&gt;
&lt;span class="py"&gt;innodb_io_capacity_max&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;20000&lt;/span&gt;
&lt;span class="py"&gt;innodb_flush_neighbors&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;innodb_buffer_pool_dump_at_shutdown&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ON&lt;/span&gt;
&lt;span class="py"&gt;innodb_buffer_pool_load_at_startup&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ON&lt;/span&gt;
&lt;span class="py"&gt;binlog_format&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ROW&lt;/span&gt;
&lt;span class="py"&gt;binlog_transaction_compression&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ON&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (b) Bulk load into a disposable instance (revert afterwards!)
&lt;/span&gt;&lt;span class="nn"&gt;[mysqld]&lt;/span&gt;
&lt;span class="err"&gt;disable-log-bin&lt;/span&gt;
&lt;span class="py"&gt;innodb_flush_log_at_trx_commit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;sync_binlog&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;innodb_doublewrite&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;OFF     # restart; unsafe on crash&lt;/span&gt;
&lt;span class="py"&gt;innodb_redo_log_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16G&lt;/span&gt;
&lt;span class="py"&gt;innodb_log_buffer_size&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256M&lt;/span&gt;
&lt;span class="c"&gt;# + ALTER INSTANCE DISABLE INNODB REDO_LOG for the load itself
# + load in primary-key order, add secondary indexes afterwards
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# (c) Write-heavy with MyRocks (Percona Server)
&lt;/span&gt;&lt;span class="nn"&gt;[mysqld]&lt;/span&gt;
&lt;span class="py"&gt;plugin-load-add&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;rocksdb=ha_rocksdb.so&lt;/span&gt;
&lt;span class="py"&gt;default-storage-engine&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ROCKSDB&lt;/span&gt;
&lt;span class="py"&gt;binlog_format&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ROW&lt;/span&gt;
&lt;span class="py"&gt;transaction_isolation&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;READ-COMMITTED&lt;/span&gt;
&lt;span class="py"&gt;rocksdb_block_cache_size&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;32G&lt;/span&gt;
&lt;span class="py"&gt;innodb_buffer_pool_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1G&lt;/span&gt;
&lt;span class="py"&gt;rocksdb_max_background_jobs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;rocksdb_default_cf_options&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"compression=kLZ4Compression;bottommost_compression=kZSTD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 3: AI and ML workloads
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;🤖🤖🤖 &lt;strong&gt;This is the section most people don't know exists.&lt;/strong&gt; You don't necessarily need a separate vector&lt;br&gt;
database. Both PostgreSQL and MySQL-family databases can store embeddings, run approximate nearest-neighbour&lt;br&gt;
search, combine it with keyword search, and even call models, &lt;strong&gt;inside the database&lt;/strong&gt;, next to the rows you&lt;br&gt;
already have. Here's what to plug in and which knobs matter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3.1 The five AI workload shapes
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;What the database must do&lt;/th&gt;
&lt;th&gt;Key components&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Store embeddings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hold 384–3,072-dim float vectors per row, cheaply&lt;/td&gt;
&lt;td&gt;Vector types, half precision, TOAST/off-page storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Similarity search (ANN)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Top-k closest vectors" in milliseconds&lt;/td&gt;
&lt;td&gt;HNSW, IVFFlat, DiskANN, ScaNN indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Filtered search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Closest vectors &lt;em&gt;where tenant = 42&lt;/em&gt;"&lt;/td&gt;
&lt;td&gt;Partial indexes, partitions, iterative scans, label filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hybrid search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Combine semantic and keyword relevance&lt;/td&gt;
&lt;td&gt;Full-text/BM25 plus rank fusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;In-database ML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generate embeddings, predict, train&lt;/td&gt;
&lt;td&gt;Cloud ML extensions, PL/Python, HeatWave AutoML&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  3.2 PostgreSQL + pgvector: the core
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt;&lt;/strong&gt; is the standard. It's available on practically every managed Postgres service, and at the time of writing the latest release is &lt;strong&gt;0.8.6&lt;/strong&gt; (&lt;a href="https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&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;items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&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;items&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[...]'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- cosine distance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Types, and how many dimensions each type can index&lt;/strong&gt; (&lt;a href="https://github.com/pgvector/pgvector#indexing" rel="noopener noreferrer"&gt;README&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;Type&lt;/th&gt;
&lt;th&gt;Bytes per dim&lt;/th&gt;
&lt;th&gt;Max dims in an HNSW/IVFFlat index&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4 (float32)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;halfvec&lt;/code&gt; (0.7.0)&lt;/td&gt;
&lt;td&gt;2 (float16)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Halves storage and index size&lt;/strong&gt;, usually with little recall loss. Needed to index 3,072-dim models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1/8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Binary quantization for very fast first-pass candidates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sparsevec&lt;/code&gt; (0.7.0)&lt;/td&gt;
&lt;td&gt;per non-zero&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,000 non-zero&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sparse models (SPLADE, BM25-style vectors)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Distance operators&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; L2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;#&amp;gt;&lt;/code&gt; negative inner product&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; cosine&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;+&amp;gt;&lt;/code&gt; L1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;~&amp;gt;&lt;/code&gt; Hamming&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;%&amp;gt;&lt;/code&gt; Jaccard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operator in your query must match the index's operator class (&lt;code&gt;vector_cosine_ops&lt;/code&gt; for &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;, for example), or the index won't be used.&lt;/p&gt;

&lt;h4&gt;
  
  
  HNSW vs IVFFlat: the two index types, and their knobs
&lt;/h4&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;strong&gt;HNSW&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;IVFFlat&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How&lt;/td&gt;
&lt;td&gt;Multi-layer proximity graph&lt;/td&gt;
&lt;td&gt;k-means clusters ("lists")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Slower, more memory. Can be built on an empty table&lt;/td&gt;
&lt;td&gt;Faster. &lt;strong&gt;Build after loading data&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed/recall&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;td&gt;Good if tuned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build knobs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;m&lt;/code&gt; (default &lt;strong&gt;16&lt;/strong&gt;), &lt;code&gt;ef_construction&lt;/code&gt; (default &lt;strong&gt;64&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lists&lt;/code&gt; (start with rows/1000 up to 1 M rows, then √rows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query knob&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hnsw.ef_search&lt;/code&gt; (default &lt;strong&gt;40&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ivfflat.probes&lt;/code&gt; (default &lt;strong&gt;1&lt;/strong&gt;; start at √lists)&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 sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;-- higher = better recall, slower&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Build-time knobs (these matter enormously)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt;&lt;/strong&gt;: HNSW builds are &lt;em&gt;much&lt;/em&gt; faster when the graph fits in memory. If you see &lt;code&gt;NOTICE: hnsw graph no longer fits into maintenance_work_mem&lt;/code&gt;, the build has fallen back to a slow path. Raise it, but not so far that the server runs out of memory. &lt;a href="https://github.com/pgvector/pgvector#index-build-time" rel="noopener noreferrer"&gt;README&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt;&lt;/strong&gt;: parallel index builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load with &lt;code&gt;COPY&lt;/code&gt;, then create the index.&lt;/strong&gt; Watch progress in &lt;code&gt;pg_stat_progress_create_index&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;maintenance_work_mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'8GB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_parallel_maintenance_workers&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="c1"&gt;-- plus the leader process&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Keep pgvector patched.&lt;/strong&gt; Version 0.8.2 fixed a buffer overflow in parallel HNSW builds, and 0.8.3 fixed&lt;br&gt;
possible index corruption during HNSW vacuuming (&lt;a href="https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;).&lt;br&gt;
Check yours with &lt;code&gt;SELECT extversion FROM pg_extension WHERE extname = 'vector';&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Filtered search: the #1 production gotcha
&lt;/h4&gt;

&lt;p&gt;Approximate indexes find the top-k &lt;em&gt;first&lt;/em&gt; and apply your &lt;code&gt;WHERE&lt;/code&gt; &lt;em&gt;afterwards&lt;/em&gt;. With a selective filter, you can get back &lt;strong&gt;fewer rows than your &lt;code&gt;LIMIT&lt;/code&gt;&lt;/strong&gt;. You have four options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Iterative index scans&lt;/strong&gt; (0.8.0+): keep scanning the index until enough rows pass the filter.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- or strict_order; default off&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_scan_tuples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- safety cap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Partial indexes&lt;/strong&gt; per hot filter value: &lt;code&gt;CREATE INDEX ... USING hnsw (embedding vector_l2_ops) WHERE (category_id = 123);&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition&lt;/strong&gt; by tenant, so each tenant gets its own small index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pgvectorscale label filtering&lt;/strong&gt; (below), which filters &lt;em&gt;inside&lt;/em&gt; the graph search.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(&lt;a href="https://github.com/pgvector/pgvector#filtering" rel="noopener noreferrer"&gt;README: filtering&lt;/a&gt; · &lt;a href="https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md" rel="noopener noreferrer"&gt;iterative scans in 0.8.0&lt;/a&gt;)&lt;/p&gt;

&lt;h4&gt;
  
  
  Shrinking vectors: half precision and binary quantization plus re-ranking
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- index at half precision: half the index size&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;halfvec_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- binary quantization: tiny index; re-rank the candidates at full precision&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;binary_quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;bit_hamming_ops&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="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;items&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;binary_quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;~&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;binary_quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&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;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;                                   &lt;span class="c1"&gt;-- over-fetch cheap candidates&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;                    &lt;span class="c1"&gt;-- re-rank with full precision&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3.3 More PostgreSQL vector engines
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Why you'd pick it&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;
&lt;strong&gt;&lt;a href="https://github.com/timescale/pgvectorscale" rel="noopener noreferrer"&gt;pgvectorscale&lt;/a&gt;&lt;/strong&gt; (TigerData)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USING diskann&lt;/code&gt; (StreamingDiskANN)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Disk-friendly&lt;/strong&gt; graph index with Statistical Binary Quantization. Filters on &lt;code&gt;smallint[]&lt;/code&gt; labels &lt;em&gt;inside&lt;/em&gt; the search&lt;/td&gt;
&lt;td&gt;Active; uses pgvector's types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/tensorchord/VectorChord" rel="noopener noreferrer"&gt;VectorChord&lt;/a&gt;&lt;/strong&gt; (TensorChord)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING vchordrq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IVF plus &lt;strong&gt;RaBitQ&lt;/strong&gt; quantization and re-ranking; fast builds at large scale&lt;/td&gt;
&lt;td&gt;Active; successor to the deprecated &lt;a href="https://github.com/tensorchord/pgvecto.rs" rel="noopener noreferrer"&gt;pgvecto.rs&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;AlloyDB ScaNN&lt;/strong&gt; (&lt;a href="https://cloud.google.com/alloydb/docs/ai" rel="noopener noreferrer"&gt;Google&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING scann&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google's ScaNN algorithm inside AlloyDB&lt;/td&gt;
&lt;td&gt;Cloud-only (AlloyDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;pg_diskann&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/azure/postgresql/flexible-server/generative-ai-azure-overview" rel="noopener noreferrer"&gt;Azure&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USING diskann&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Microsoft's DiskANN on Azure Postgres&lt;/td&gt;
&lt;td&gt;Cloud-only (Azure)&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 sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pgvectorscale: filter by labels inside the ANN search&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vectorscale&lt;/span&gt; &lt;span class="k"&gt;CASCADE&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;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;diskann&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;labels&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;documents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&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;3&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="nb"&gt;smallint&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3.4 Hybrid search: semantic plus keyword
&lt;/h3&gt;

&lt;p&gt;Pure vector search misses exact matches such as product codes, names and error messages. Hybrid search runs both kinds of search and fuses the rankings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Built-in full-text search&lt;/strong&gt;: a &lt;code&gt;tsvector&lt;/code&gt; column with a GIN index, queried with &lt;code&gt;websearch_to_tsquery&lt;/code&gt; and ranked with &lt;code&gt;ts_rank_cd&lt;/code&gt;. &lt;a href="https://www.postgresql.org/docs/current/textsearch.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/pgtrgm.html" rel="noopener noreferrer"&gt;pg_trgm&lt;/a&gt;&lt;/strong&gt;: typo-tolerant fuzzy matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/paradedb/paradedb" rel="noopener noreferrer"&gt;ParadeDB pg_search&lt;/a&gt;&lt;/strong&gt;: true &lt;strong&gt;BM25&lt;/strong&gt; ranking (built on Tantivy) through a &lt;code&gt;bm25&lt;/code&gt; index. Licensed AGPL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt;: &lt;code&gt;score = Σ 1 / (60 + rank)&lt;/code&gt; across the result lists. pgvector's authors publish a &lt;a href="https://github.com/pgvector/pgvector-python/blob/master/examples/hybrid_search/rrf.py" rel="noopener noreferrer"&gt;reference example&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3.5 End-to-end RAG retrieval in one SQL query (PostgreSQL)
&lt;/h3&gt;

&lt;p&gt;This combines half-precision storage, an HNSW index, a tenant filter kept full by iterative scans, keyword search and RRF:&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;EXTENSION&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;vector&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;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;document_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content_tsv&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;          &lt;span class="c1"&gt;-- 2 bytes per dimension&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- after bulk-loading with COPY:&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;maintenance_work_mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'8GB'&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;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;halfvec_cosine_ops&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;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content_tsv&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;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- $1 = query embedding, $2 = tenant id, $3 = user question&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&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;semantic&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&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;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&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;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&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;distance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;semantic_ranked&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;RANK&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&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;rank&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;keyword&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="k"&gt;c&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;RANK&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&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;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_tsv&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&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;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&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;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rrf_score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;semantic_ranked&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&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;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rrf_score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- these 8 chunks go into the LLM prompt&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3.6 In-database ML and model calls (PostgreSQL)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it does&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;&lt;strong&gt;Google &lt;a href="https://cloud.google.com/alloydb/docs/ai" rel="noopener noreferrer"&gt;&lt;code&gt;google_ml_integration&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;embedding('text-embedding-005', text)&lt;/code&gt; and &lt;code&gt;ml_predict_row()&lt;/code&gt; from SQL&lt;/td&gt;
&lt;td&gt;AlloyDB and Cloud SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Azure &lt;a href="https://learn.microsoft.com/azure/postgresql/flexible-server/generative-ai-azure-overview" rel="noopener noreferrer"&gt;&lt;code&gt;azure_ai&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;azure_openai.create_embeddings()&lt;/code&gt;, Azure ML and language services&lt;/td&gt;
&lt;td&gt;Azure Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/postgresql-ml.html" rel="noopener noreferrer"&gt;&lt;code&gt;aws_ml&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Calls SageMaker, Comprehend and Bedrock from SQL&lt;/td&gt;
&lt;td&gt;Aurora PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/plpython.html" rel="noopener noreferrer"&gt;PL/Python&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;plpython3u&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Run scikit-learn and similar libraries &lt;em&gt;inside&lt;/em&gt; the server&lt;/td&gt;
&lt;td&gt;Self-managed only. It's &lt;strong&gt;untrusted&lt;/strong&gt;, so superuser only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/duckdb/pg_duckdb" rel="noopener noreferrer"&gt;pg_duckdb&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast feature engineering and aggregations over tables and Parquet&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://age.apache.org/" rel="noopener noreferrer"&gt;Apache AGE&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graph queries (openCypher) inside Postgres, useful for &lt;strong&gt;GraphRAG&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Check a project's status before adopting it.&lt;/strong&gt; Timescale's &lt;strong&gt;&lt;a href="https://github.com/timescale/pgai" rel="noopener noreferrer"&gt;pgai&lt;/a&gt;&lt;/strong&gt; (automatic&lt;br&gt;
embedding "vectorizers" and LLM calls from SQL) is now an &lt;strong&gt;archived&lt;/strong&gt; repository.&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/postgresml/postgresml" rel="noopener noreferrer"&gt;PostgresML&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href="https://madlib.apache.org/" rel="noopener noreferrer"&gt;Apache MADlib&lt;/a&gt;&lt;/strong&gt; were&lt;br&gt;
once the go-to options for in-database ML. Check their recent activity before depending on them. For&lt;br&gt;
auto-embedding today, a common pattern is a trigger that writes to a queue, plus an external worker that calls&lt;br&gt;
the embedding API. Don't call an LLM synchronously inside a trigger.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3.7 MySQL-family AI features
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;MySQL 9.x: &lt;code&gt;VECTOR&lt;/code&gt; type&lt;/strong&gt; (added in 9.0; up to 16,383 dimensions) (&lt;a href="https://dev.mysql.com/doc/refman/9.7/en/vector.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="k"&gt;VALUES&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;STRING_TO_VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0.1, 0.2, ...]'&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;VECTOR_DIM&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="n"&gt;VECTOR_TO_STRING&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Know what Community MySQL does and doesn't give you.&lt;/strong&gt; It has the &lt;code&gt;VECTOR&lt;/code&gt; &lt;strong&gt;type&lt;/strong&gt; and its conversion&lt;br&gt;
functions. According to the &lt;a href="https://dev.mysql.com/doc/refman/9.7/en/vector-functions.html" rel="noopener noreferrer"&gt;vector functions docs&lt;/a&gt;,&lt;br&gt;
the &lt;code&gt;DISTANCE()&lt;/code&gt; function is available only in Oracle's HeatWave service (and the Enterprise "MySQL AI"&lt;br&gt;
offering), and &lt;strong&gt;Community MySQL has no vector index&lt;/strong&gt;. For open-source vector search in the MySQL family, look&lt;br&gt;
at &lt;strong&gt;MariaDB&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;MySQL HeatWave (managed service)&lt;/strong&gt; (&lt;a href="https://dev.mysql.com/doc/heatwave/en/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GenAI&lt;/strong&gt;: &lt;code&gt;sys.ML_GENERATE&lt;/code&gt; (LLM calls), &lt;code&gt;sys.ML_EMBED_ROW&lt;/code&gt; (embeddings), &lt;code&gt;sys.ML_RAG&lt;/code&gt; (retrieval-augmented generation over a built-in vector store).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AutoML&lt;/strong&gt;: &lt;code&gt;sys.ML_TRAIN&lt;/code&gt;, &lt;code&gt;sys.ML_PREDICT_ROW&lt;/code&gt; and &lt;code&gt;sys.ML_EXPLAIN&lt;/code&gt;. You can train and serve models on your tables with SQL alone.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&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;ML_TRAIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shop.churn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'churned'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JSON_OBJECT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'task'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'classification'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CALL&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;ML_MODEL_LOAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ML_PREDICT_ROW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON_OBJECT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenure'&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="s1"&gt;'plan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'basic'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&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;MariaDB Vector (open source, 11.7+; 11.8 is the first LTS with it)&lt;/strong&gt; (&lt;a href="https://mariadb.com/kb/en/vector-overview/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type &lt;code&gt;VECTOR(N)&lt;/code&gt;, with a &lt;code&gt;VECTOR INDEX&lt;/code&gt; based on a modified HNSW ("mHNSW") that works with InnoDB.&lt;/li&gt;
&lt;li&gt;Distance functions: &lt;code&gt;VEC_DISTANCE_EUCLIDEAN&lt;/code&gt; and &lt;code&gt;VEC_DISTANCE_COSINE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Knobs: &lt;code&gt;mhnsw_default_m&lt;/code&gt; (graph connectivity), &lt;code&gt;mhnsw_ef_search&lt;/code&gt; (recall vs speed), &lt;code&gt;mhnsw_default_distance&lt;/code&gt;, and &lt;code&gt;mhnsw_max_cache_size&lt;/code&gt; (memory for the index).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;        &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;VECTOR&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="n"&gt;DISTANCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cosine&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Tuning InnoDB buffer pool'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VEC_FromText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0.12,0.45,0.33,0.91]'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Vector search in MariaDB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;VEC_FromText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0.10,0.40,0.35,0.88]'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;mhnsw_ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- higher = better recall&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;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;VEC_DISTANCE_COSINE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VEC_FromText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0.11,0.42,0.34,0.90]'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distance function in &lt;code&gt;ORDER BY&lt;/code&gt; must match the index's &lt;code&gt;DISTANCE&lt;/code&gt; setting, or the index isn't used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed MySQL with AI features&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud SQL for MySQL&lt;/strong&gt;: vector columns, ANN vector indexes and &lt;code&gt;approx_distance()&lt;/code&gt; searches. &lt;a href="https://cloud.google.com/sql/docs/mysql/work-with-vectors" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Aurora MySQL&lt;/strong&gt;: calls SageMaker, Comprehend (for example &lt;code&gt;aws_comprehend_detect_sentiment()&lt;/code&gt;) and Bedrock from SQL. &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/mysql-ml.html" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PlanetScale&lt;/strong&gt;: MySQL-compatible vector columns and indexes. &lt;a href="https://planetscale.com/docs/vectors" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/mindsdb/mindsdb" rel="noopener noreferrer"&gt;MindsDB&lt;/a&gt;&lt;/strong&gt;: an external SQL layer that connects to both MySQL and Postgres to add models and knowledge bases.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3.8 AI workload tuning checklist
&lt;/h3&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;Do this&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;
&lt;strong&gt;Size &lt;code&gt;maintenance_work_mem&lt;/code&gt; so the HNSW graph fits&lt;/strong&gt;, and use parallel builds&lt;/td&gt;
&lt;td&gt;Builds that spill are many times slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Keep the vector index in RAM&lt;/strong&gt; (shared_buffers plus OS cache, or &lt;code&gt;mhnsw_max_cache_size&lt;/code&gt; in MariaDB)&lt;/td&gt;
&lt;td&gt;Graph search is random access; disk reads kill latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Use &lt;code&gt;halfvec&lt;/code&gt;&lt;/strong&gt; (or quantization) for storage and indexes&lt;/td&gt;
&lt;td&gt;Half the memory, and 3,072-dim models become indexable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Over-fetch, then re-rank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cheap approximate candidates, then exact or cross-encoder precision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Plan for filters&lt;/strong&gt;: partial indexes, partitions, iterative scans or label filtering&lt;/td&gt;
&lt;td&gt;Otherwise filtered queries return too few rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Tune recall per query&lt;/strong&gt; with &lt;code&gt;SET LOCAL hnsw.ef_search&lt;/code&gt; / &lt;code&gt;ivfflat.probes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Trade recall for latency where it matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Measure recall&lt;/strong&gt; against exact search (&lt;code&gt;SET enable_indexscan = off&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Otherwise you're tuning blind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Load with &lt;code&gt;COPY&lt;/code&gt;, build indexes afterwards&lt;/strong&gt;; generate embeddings asynchronously&lt;/td&gt;
&lt;td&gt;Faster loads, and no LLM calls inside transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Keep embeddings in a separate &lt;code&gt;chunks&lt;/code&gt; table&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The main table stays small, and re-embedding with a new model doesn't rewrite business rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Add hybrid search&lt;/strong&gt; (FTS or BM25 plus RRF)&lt;/td&gt;
&lt;td&gt;Catches exact terms that embeddings miss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Cheat sheet: problem to engine
&lt;/h2&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;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL / MariaDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;General OLTP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;heap&lt;/code&gt; + B-tree&lt;/td&gt;
&lt;td&gt;InnoDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write-heavy, larger than RAM&lt;/td&gt;
&lt;td&gt;Tune WAL and checkpoints; OrioleDB (beta)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MyRocks&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append-only analytics&lt;/td&gt;
&lt;td&gt;Citus &lt;code&gt;columnar&lt;/code&gt;, TimescaleDB columnstore, pg_duckdb&lt;/td&gt;
&lt;td&gt;MariaDB ColumnStore, HeatWave (cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faster writes, some data loss acceptable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SET LOCAL synchronous_commit = off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;innodb_flush_log_at_trx_commit = 2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging or scratch tables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UNLOGGED&lt;/code&gt; tables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sql_log_bin=0&lt;/code&gt;, MEMORY/TempTable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-off bulk load&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wal_level = minimal&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, build indexes afterwards&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALTER INSTANCE DISABLE INNODB REDO_LOG&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot data on NVMe, cold on HDD&lt;/td&gt;
&lt;td&gt;Tablespaces + partitions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DATA DIRECTORY&lt;/code&gt;, general tablespaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold data on S3&lt;/td&gt;
&lt;td&gt;pg_lake / pg_duckdb (Parquet, Iceberg)&lt;/td&gt;
&lt;td&gt;MariaDB &lt;strong&gt;S3 engine&lt;/strong&gt;, HeatWave Lakehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolling archive&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DETACH PARTITION CONCURRENTLY&lt;/code&gt; + pg_partman&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXCHANGE PARTITION&lt;/code&gt; → ARCHIVE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Huge time-ordered table&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;BRIN&lt;/strong&gt; index&lt;/td&gt;
&lt;td&gt;Partition by range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON documents&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jsonb&lt;/code&gt; + GIN (&lt;code&gt;jsonb_path_ops&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;JSON + multi-valued / functional indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fuzzy text search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FULLTEXT (+ ngram parser)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo&lt;/td&gt;
&lt;td&gt;PostGIS + GiST&lt;/td&gt;
&lt;td&gt;SPATIAL (R-tree) + SRID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector search&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pgvector&lt;/strong&gt; HNSW / pgvectorscale / VectorChord&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;MariaDB Vector&lt;/strong&gt;, HeatWave, Cloud SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid search&lt;/td&gt;
&lt;td&gt;FTS or pg_search + RRF&lt;/td&gt;
&lt;td&gt;FULLTEXT + vector + RRF in SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-DB ML&lt;/td&gt;
&lt;td&gt;Cloud ML extensions, PL/Python&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;HeatWave AutoML&lt;/strong&gt;, Aurora ML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote data&lt;/td&gt;
&lt;td&gt;FDWs (postgres_fdw, mysql_fdw)&lt;/td&gt;
&lt;td&gt;FEDERATED, MariaDB CONNECT/Spider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thousands of connections&lt;/td&gt;
&lt;td&gt;PgBouncer&lt;/td&gt;
&lt;td&gt;Thread pool (Percona/MariaDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Play it: the Database Playground game
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reading about knobs is one thing. Breaking a database on purpose is how it actually sticks.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I turned this whole article into a game. 🎮&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;Database Playground&lt;/a&gt;&lt;/strong&gt; is a drag-and-drop sandbox where you assemble a database engine layer by layer, from the connection pooler down to the disk. It then tells you, instantly, what you've built: what it's great at, what it's terrible at, and what kind of data belongs 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6upr937l1ige50xhsvf.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%2Fm6upr937l1ige50xhsvf.png" alt="Building a hybrid-search RAG engine in the Database Playground" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's inside&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧱 &lt;strong&gt;Build it&lt;/strong&gt;: drag &lt;strong&gt;100+ real components&lt;/strong&gt; (56 PostgreSQL, 47 MySQL / MariaDB / Percona) into colour-coded layers: table engines, index types, WAL and redo, memory, storage, vector types.&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;See it analysed live&lt;/strong&gt;: capability scores, best-fit workloads, sample data that suits your build, the generated &lt;code&gt;postgresql.conf&lt;/code&gt; / &lt;code&gt;my.cnf&lt;/code&gt; and SQL, and loud warnings when you do something reckless (yes, &lt;code&gt;fsync = off&lt;/code&gt;, I'm looking at you 🙃).&lt;/li&gt;
&lt;li&gt;🕵️ &lt;strong&gt;Crack 24 missions&lt;/strong&gt;: story-driven case studies written like cloud certification exam questions. Each is a ~300-word story about a company in trouble, with a &lt;strong&gt;hidden question&lt;/strong&gt;. Work out the real problem, build the fix, and earn up to ⭐⭐⭐.&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;Go deep on AI&lt;/strong&gt;: a RAG bot that can't find error code &lt;code&gt;E-4012&lt;/code&gt;, &lt;strong&gt;500 million embeddings on a 256 GB box&lt;/strong&gt;, clinical RAG on MariaDB, and the classic "ask for 10 results, get 2" filtered vector search trap.&lt;/li&gt;
&lt;li&gt;🌗 &lt;strong&gt;Light and dark themes&lt;/strong&gt;, zero dependencies, &lt;strong&gt;zero network calls&lt;/strong&gt;. Your data never leaves your browser.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwj85acagtodzciat05f5.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%2Fwj85acagtodzciat05f5.png" alt="The mission board: 24 case studies across 8 families" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Up and running in 10 seconds&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AnikethSD/database-playground.git
&lt;span class="nb"&gt;cd &lt;/span&gt;database-playground
open index.html        &lt;span class="c"&gt;# macOS; use xdg-open on Linux, or just double-click it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;npm install&lt;/code&gt;. No Docker. No sign-up. Just a browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  One small favour ⭐
&lt;/h3&gt;

&lt;p&gt;If this article or the game saved you even one evening of doc-diving, please &lt;strong&gt;&lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;drop a ⭐ on the repo&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It takes two seconds and costs nothing. It's also the single biggest signal that tells me to keep building (more AI missions are next 👀), and it helps other developers discover the project.&lt;/p&gt;

&lt;p&gt;Got a production war story that would make a brutal mission? Spotted a score that feels wrong? &lt;strong&gt;Issues and PRs are very welcome.&lt;/strong&gt; There's a &lt;a href="https://github.com/AnikethSD/database-playground/blob/main/docs/authoring-missions.md" rel="noopener noreferrer"&gt;mission authoring guide&lt;/a&gt; to get you started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;⭐ Star Database Playground on GitHub →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config.html" rel="noopener noreferrer"&gt;Server configuration (all settings)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PostgreSQL 18 release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/tableam.html" rel="noopener noreferrer"&gt;Table access method interface&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/indexes-types.html" rel="noopener noreferrer"&gt;Index types&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server" rel="noopener noreferrer"&gt;Tuning Your PostgreSQL Server (wiki)&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/populate.html" rel="noopener noreferrer"&gt;Populating a database&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;MySQL / MariaDB / Percona&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/" rel="noopener noreferrer"&gt;MySQL 8.4 reference manual&lt;/a&gt; · &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html" rel="noopener noreferrer"&gt;InnoDB parameters&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/storage-engines.html" rel="noopener noreferrer"&gt;Alternative storage engines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mariadb.com/kb/en/storage-engines/" rel="noopener noreferrer"&gt;MariaDB storage engines&lt;/a&gt; · &lt;a href="https://docs.percona.com/percona-server/8.4/myrocks-index.html" rel="noopener noreferrer"&gt;Percona MyRocks&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AI / vectors&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector README&lt;/a&gt; · &lt;a href="https://github.com/timescale/pgvectorscale" rel="noopener noreferrer"&gt;pgvectorscale&lt;/a&gt; · &lt;a href="https://docs.vectorchord.ai/" rel="noopener noreferrer"&gt;VectorChord docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.mysql.com/doc/refman/9.7/en/vector.html" rel="noopener noreferrer"&gt;MySQL VECTOR type&lt;/a&gt; · &lt;a href="https://dev.mysql.com/doc/heatwave/en/" rel="noopener noreferrer"&gt;HeatWave docs&lt;/a&gt; · &lt;a href="https://mariadb.com/kb/en/vector-overview/" rel="noopener noreferrer"&gt;MariaDB Vector&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Found a knob I missed, or a fact that's out of date? This ecosystem moves fast. Let me know in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And if you scrolled straight to the bottom: go &lt;a href="https://github.com/AnikethSD/database-playground" rel="noopener noreferrer"&gt;break some databases in the Playground&lt;/a&gt; 🎮, and leave a ⭐ on your way out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>mysql</category>
      <category>database</category>
    </item>
    <item>
      <title>Database Engines 101: How They Work, the Main Types, and How to Swap Them in PostgreSQL</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Mon, 28 Sep 2026 12:02:03 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/database-engines-101-how-they-work-the-main-types-and-how-to-swap-them-in-postgresql-506j</link>
      <guid>https://dev.to/anikethsdeshpande/database-engines-101-how-they-work-the-main-types-and-how-to-swap-them-in-postgresql-506j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;This article has a hands-on lab.&lt;/strong&gt; You'll run PostgreSQL in Docker, store the same 5 million rows in&lt;br&gt;
two different storage engines, crash the database on purpose, and measure what changes.&lt;br&gt;
&lt;strong&gt;If you already know the theory, skip straight to the lab.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: A database engine is the part of a database that turns "save this row" into bytes on disk and back again.&lt;br&gt;
There's no single best engine. &lt;strong&gt;Row stores&lt;/strong&gt; suit transactions, &lt;strong&gt;LSM trees&lt;/strong&gt; suit heavy writes,&lt;br&gt;
&lt;strong&gt;column stores&lt;/strong&gt; suit analytics and &lt;strong&gt;in-memory&lt;/strong&gt; engines suit speed.&lt;br&gt;
PostgreSQL lets you choose &lt;strong&gt;per table&lt;/strong&gt; with &lt;code&gt;CREATE TABLE ... USING &amp;lt;engine&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Part 1: How an engine works&lt;/li&gt;
&lt;li&gt;Part 2: Types of engines&lt;/li&gt;
&lt;li&gt;Part 3: The lab&lt;/li&gt;
&lt;li&gt;Cheat sheet: pick an engine by use case&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 1: How an engine works
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;INSERT INTO users VALUES (1, 'alice', 1000)&lt;/code&gt;, two very different pieces of software are involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   SQL text
      │
      ▼
 ┌──────────────────────────┐
 │  Query layer             │   parse → plan → execute
 │  "what do you want?"     │
 └────────────┬─────────────┘
              │  "store this row", "give me row X"
              ▼
 ┌──────────────────────────┐
 │  Storage engine          │   pages, rows, logs, versions
 │  "how do the bytes live?"│
 └────────────┬─────────────┘
              ▼
          disk / S3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This article is about the bottom box. The easiest way to see why it looks the way it does is to build the obvious version and watch it fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The naive engine: one row per line in a text file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users.csv&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;w&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;f&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;i&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;200_000&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;write&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;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,user_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;06&lt;/span&gt;&lt;span class="n"&gt;d&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;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100000&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my machine it failed three ways almost immediately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read cost depends on which row you want.&lt;/strong&gt; Finding row N means counting N newlines. Row 0 took 0.25 ms and row 199,999 took 17.6 ms, &lt;strong&gt;70x slower&lt;/strong&gt;, on a file under 5 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A row that grows overwrites its neighbour.&lt;/strong&gt; The only safe fix is to rewrite everything after it. I changed 40 bytes and had to write 4.6 MB, a &lt;strong&gt;121,667x write amplification&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crash creates a row that never existed.&lt;/strong&gt; Cut power halfway through replacing &lt;code&gt;42,alice,1000&lt;/code&gt; with &lt;code&gt;42,bob,2000&lt;/code&gt; and you get &lt;code&gt;42,bobce,1000&lt;/code&gt;. It parses fine, so nothing warns you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Real engines solve these with four ideas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 1: Pages
&lt;/h3&gt;

&lt;p&gt;Split every file into &lt;strong&gt;fixed-size blocks called pages&lt;/strong&gt;. PostgreSQL uses &lt;strong&gt;8 KB&lt;/strong&gt;. Finding block 5,000 becomes plain arithmetic (&lt;code&gt;5000 × 8192&lt;/code&gt;), so every read costs the same. The page becomes the unit of disk I/O, caching and crash recovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 2: Slotted pages
&lt;/h3&gt;

&lt;p&gt;Inside each page, a small &lt;strong&gt;slot array&lt;/strong&gt; at the front points to rows stored at the back. The two regions grow toward each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;byte 0     ┌────────────────────────────────┐
           │ Page header (24 bytes)         │
           ├────────────────────────────────┤
           │ Slot 1 │ Slot 2 │ Slot 3 │ ... │  ← slots grow DOWN ↓
           ├────────────────────────────────┤
           │          free space            │
           ├────────────────────────────────┤
           │ ... │ Row 3 │ Row 2 │ Row 1    │  ← rows grow UP ↑
byte 8192  └────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A row's address is &lt;code&gt;(page, slot)&lt;/code&gt;, not a byte offset. So the engine can move rows around inside a page to reclaim space, and every index entry pointing at &lt;code&gt;(5, 3)&lt;/code&gt; still works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 3: Checksums and the write-ahead log
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Checksums&lt;/strong&gt; &lt;em&gt;detect&lt;/em&gt; damage: each page stores a checksum of its own bytes, so a half-written page fails verification instead of returning &lt;code&gt;42,bobce,1000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;write-ahead log (WAL)&lt;/strong&gt; &lt;em&gt;repairs&lt;/em&gt; damage. Before touching a page, the engine appends a description of the change to a sequential log and flushes it to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. append "page 5, slot 3: balance 1000 → 2000" to the WAL  → fsync
2. change page 5 in memory
3. write page 5 to disk ... eventually
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the database crashes before step 3, recovery replays the log. You'll watch this happen in the lab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 4: MVCC
&lt;/h3&gt;

&lt;p&gt;Instead of overwriting a row, write a &lt;strong&gt;new version&lt;/strong&gt; and stamp each version with the transaction that created it (&lt;code&gt;xmin&lt;/code&gt;) and the one that replaced it (&lt;code&gt;xmax&lt;/code&gt;). Each transaction reads from a snapshot, so a long report keeps seeing the old balance while an update commits alongside it. &lt;strong&gt;Readers never block writers.&lt;/strong&gt; The cost is that old versions pile up and must be cleaned out, which is what PostgreSQL's &lt;code&gt;VACUUM&lt;/code&gt; does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Types of engines
&lt;/h2&gt;

&lt;p&gt;Those four ideas describe PostgreSQL's default engine. Other engines make different trade-offs, depending on what they're optimised for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Row-oriented store (B-tree + pages)
&lt;/h3&gt;

&lt;p&gt;Whole rows are kept together in pages and updated in place, with B-tree indexes to find them. The classic design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Great at:&lt;/strong&gt; transactions, point lookups, frequent updates (OLTP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak at:&lt;/strong&gt; scanning a few columns across billions of rows, because it reads whole rows anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples:&lt;/strong&gt; PostgreSQL &lt;code&gt;heap&lt;/code&gt;, MySQL InnoDB, SQLite, Oracle, SQL Server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Log-structured merge tree (LSM)
&lt;/h3&gt;

&lt;p&gt;Never update in place. Writes go to an in-memory buffer (plus a log for safety). When the buffer fills, it's flushed to disk as an immutable sorted file, and background &lt;strong&gt;compaction&lt;/strong&gt; merges those files over time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Great at:&lt;/strong&gt; very heavy write and ingest rates, since every disk write is sequential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak at:&lt;/strong&gt; reads may check several files (Bloom filters help), and compaction uses background CPU and I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples:&lt;/strong&gt; RocksDB, LevelDB, Cassandra, ScyllaDB, MyRocks, CockroachDB's Pebble.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Column-oriented store
&lt;/h3&gt;

&lt;p&gt;Store each &lt;strong&gt;column&lt;/strong&gt; separately and compress it. A query that needs 2 of 12 columns reads only those 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Row store (one page)             Column store
┌──────────────────────────┐     id:      1   2   3   4  ...
│ 1 │ IN │ 12.50 │ chrome  │     country: IN  US  IN  DE ...  ← compresses
│ 2 │ US │  8.00 │ safari  │     amount:  12.5 8.0 3.2 ...      very well
│ 3 │ IN │  3.20 │ chrome  │     browser: ...
└──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Great at:&lt;/strong&gt; analytics such as aggregates, dashboards and scans over huge tables (OLAP). Much smaller on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak at:&lt;/strong&gt; updating or deleting single rows, and fetching whole rows one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples:&lt;/strong&gt; ClickHouse, DuckDB, Snowflake, BigQuery, Parquet files, Citus/Hydra columnar for PostgreSQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  In-memory store
&lt;/h3&gt;

&lt;p&gt;Keep everything in RAM. Durability comes from snapshots and append-only logs, or you give it up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Great at:&lt;/strong&gt; microsecond latency for caches, sessions, leaderboards and queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak at:&lt;/strong&gt; RAM is expensive, and durability is weaker unless you configure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples:&lt;/strong&gt; Redis, Memcached, MySQL &lt;code&gt;MEMORY&lt;/code&gt;, VoltDB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pluggable engines
&lt;/h3&gt;

&lt;p&gt;Some databases let you choose the engine &lt;strong&gt;per table&lt;/strong&gt;. MySQL made this famous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&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="k"&gt;cache&lt;/span&gt;  &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL has had the same thing since version 12, through the &lt;strong&gt;Table Access Method&lt;/strong&gt; API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;heap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;-- the default row store&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;events&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;columnar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- added by an extension&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It goes further than tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you can swap&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Built in&lt;/th&gt;
&lt;th&gt;Added by extensions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Table engine&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATE TABLE ... USING x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;heap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;columnar&lt;/code&gt; (Citus, Hydra), &lt;code&gt;orioledb&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index engine&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX ... USING x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;btree&lt;/code&gt;, &lt;code&gt;hash&lt;/code&gt;, &lt;code&gt;gin&lt;/code&gt;, &lt;code&gt;gist&lt;/code&gt;, &lt;code&gt;spgist&lt;/code&gt;, &lt;code&gt;brin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hnsw&lt;/code&gt;, &lt;code&gt;ivfflat&lt;/code&gt; (pgvector)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durability&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATE UNLOGGED TABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;skip the WAL entirely&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now let's plug some in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The lab
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You need:&lt;/strong&gt; Docker, and about 15 minutes. The table load takes about a minute.&lt;/p&gt;

&lt;p&gt;We'll use the &lt;code&gt;citusdata/citus&lt;/code&gt; image: stock &lt;strong&gt;PostgreSQL 17&lt;/strong&gt; with the &lt;code&gt;columnar&lt;/code&gt; table engine already installed. We're only using its columnar engine, not its distributed features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 0: Start PostgreSQL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg-engines &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pw citusdata/citus:13.0
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; pg-engines psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;psql&lt;/code&gt;, turn on timing:&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="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;timing&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1: See which engines are installed
&lt;/h3&gt;

&lt;p&gt;PostgreSQL lists every engine in the &lt;code&gt;pg_am&lt;/code&gt; catalog table ("am" = access method):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;amname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="n"&gt;amtype&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'t'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'table'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_am&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amname&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;  engine  | kind
----------+-------
 columnar | table     ← added by the extension
 heap     | table     ← PostgreSQL's default
 brin     | index
 btree    | index
 gin      | index
 gist     | index
 hash     | index
 spgist   | index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Same table, two engines
&lt;/h3&gt;

&lt;p&gt;A realistic 12-column web-events table. The only difference between the two is the &lt;code&gt;USING&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="nb"&gt;text&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;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;device&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_url&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;referrer&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;smallint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&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;USING&lt;/span&gt; &lt;span class="n"&gt;heap&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;events_col&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;events_row&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;columnar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load 5 million rows of fake events into both:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7919&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&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="s1"&gt;'IN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'US'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'BR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'JP'&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="k"&gt;g&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;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&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="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'mobile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'desktop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'tablet'&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="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&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="s1"&gt;'chrome'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'firefox'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'safari'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'edge'&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="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
       &lt;span class="s1"&gt;'/products/'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'/details?ref=campaign_'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="s1"&gt;'https://www.example-'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.com/search?q=item'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;500&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="k"&gt;g&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;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;900&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&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;5000000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;events_col&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;events_row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;events_col&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now compare their size on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"table"&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;amname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&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;size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_am&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relam&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'events_%'&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;   table    |  engine  |  size
------------+----------+--------
 events_col | columnar | 127 MB
 events_row | heap     | 868 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data, &lt;strong&gt;6.8x smaller&lt;/strong&gt;. Storing each column together means similar values sit next to each other, and they compress well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Run an analytics query
&lt;/h3&gt;

&lt;p&gt;Average order amount per country. &lt;code&gt;BUFFERS&lt;/code&gt; shows how many 8 KB pages each engine had to read:&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;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COSTS&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TIMING&lt;/span&gt; &lt;span class="k"&gt;OFF&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;country&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;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events_row&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;country&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COSTS&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TIMING&lt;/span&gt; &lt;span class="k"&gt;OFF&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;country&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;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events_col&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;country&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trimmed output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- heap
 -&amp;gt;  Parallel Seq Scan on events_row
       Buffers: shared read=111093          ← 868 MB of pages
 Execution Time: 852 ms

-- columnar
 -&amp;gt;  Custom Scan (ColumnarScan) on events_col
       Columnar Projected Columns: country, amount
       Buffers: shared hit=3164 read=16     ← 25 MB of pages
 Execution Time: 1409 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columnar engine read &lt;strong&gt;35x fewer pages&lt;/strong&gt;. &lt;code&gt;Projected Columns: country, amount&lt;/code&gt; shows why: it skipped the other 10 columns entirely.&lt;/p&gt;

&lt;p&gt;But look at the times. &lt;strong&gt;On my laptop, heap was still faster.&lt;/strong&gt; The whole dataset fits in RAM, so reading pages is cheap. PostgreSQL also split the heap scan across 3 CPU cores, while this columnar engine scans on one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 That's the real lesson: &lt;strong&gt;an engine is a bet on your bottleneck.&lt;/strong&gt; Columnar wins when I/O is the limit, for&lt;br&gt;
example tables bigger than RAM, network-attached cloud disks, or storage you pay for by the byte scanned.&lt;br&gt;
When everything is already in memory, the difference shrinks, and a well-parallelised row store can win.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Update one row
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;events_col&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&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;UPDATE 1
ERROR:  UPDATE and CTID scans not supported for ColumnarScan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columnar engine refuses. Compressed column chunks can't be cheaply edited in place, so this engine is &lt;strong&gt;append-only&lt;/strong&gt;. It suits event logs and history tables, not a &lt;code&gt;users&lt;/code&gt; table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Turn durability down with &lt;code&gt;UNLOGGED&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The WAL from Part 1 has a cost. PostgreSQL lets you skip it per table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;          &lt;span class="n"&gt;clicks_safe&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;events_row&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;UNLOGGED&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;clicks_fast&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;events_row&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;pg_current_wal_lsn&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;before&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;gset&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;clicks_safe&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;events_row&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000000&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;pg_current_wal_lsn&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;middle&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;gset&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;clicks_fast&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;events_row&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000000&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;pg_current_wal_lsn&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;after&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;gset&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="s1"&gt;'middle'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'before'&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;wal_written_safe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="s1"&gt;'after'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'middle'&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;wal_written_fast&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; wal_written_safe | wal_written_fast
------------------+------------------
 199 MB           | 0 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unlogged insert wrote &lt;strong&gt;zero bytes of WAL&lt;/strong&gt; and ran about 1.5x faster (1.5 s vs 2.3 s). Both tables hold 1,000,000 rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Crash it on purpose
&lt;/h3&gt;

&lt;p&gt;Quit &lt;code&gt;psql&lt;/code&gt; (&lt;code&gt;\q&lt;/code&gt;), then kill PostgreSQL the hard way. &lt;code&gt;SIGKILL&lt;/code&gt; gives it no chance to clean up, like pulling the power cord:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; KILL pg-engines
docker start pg-engines
docker logs pg-engines 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"not properly shut down|redo"&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;LOG:  database system was not properly shut down; automatic recovery in progress
LOG:  redo starts at 0/57A9DD88
LOG:  redo done at 0/57A9DE58
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the WAL replay from Part 1. Now reconnect and count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&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;FROM&lt;/span&gt; &lt;span class="n"&gt;clicks_safe&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;safe_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;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;FROM&lt;/span&gt; &lt;span class="n"&gt;clicks_fast&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;fast_rows&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; safe_rows | fast_rows
-----------+-----------
   1000000 |         0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The unlogged table is empty.&lt;/strong&gt; With no WAL to replay, PostgreSQL can't know whether its pages are consistent, so after a crash it truncates them. It's a good fit for staging tables and caches you can rebuild. It's a bad fit for anything you can't afford to lose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Swap index engines
&lt;/h3&gt;

&lt;p&gt;Indexes are pluggable too. Put a B-tree and a BRIN index on the same timestamp column:&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;events_ts_btree&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;btree&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;events_ts_brin&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events_row&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&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;size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'events_ts_%'&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      |  size
-----------------+--------
 events_ts_btree | 107 MB
 events_ts_brin  | 40 kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BRIN index is &lt;strong&gt;about 2,700x smaller&lt;/strong&gt;. A B-tree stores one entry per row. BRIN ("Block Range INdex") stores only the min and max timestamp for each group of 128 pages. Since events arrive in time order, those ranges barely overlap, so the index can still skip most of the table.&lt;/p&gt;

&lt;p&gt;Both indexes answer "give me Feb 1st" correctly (86,400 rows). BRIN is &lt;em&gt;lossy&lt;/em&gt;: it returns whole page ranges, and PostgreSQL rechecks the rows, discarding 11,028 extras. If your data &lt;em&gt;isn't&lt;/em&gt; physically ordered by that column, BRIN becomes useless and you're back to the B-tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Change a table's engine later
&lt;/h3&gt;

&lt;p&gt;You don't have to choose the engine forever. Since PostgreSQL 15, one command moves an existing table to a different engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'clicks_safe'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;-- 174 MB&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;clicks_safe&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ACCESS&lt;/span&gt; &lt;span class="k"&gt;METHOD&lt;/span&gt; &lt;span class="n"&gt;columnar&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'clicks_safe'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;-- 25 MB&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;clicks_safe&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ACCESS&lt;/span&gt; &lt;span class="k"&gt;METHOD&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;-- and back again&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the "plug and play" part. The table name, the columns and your queries stay the same. Only the engine underneath changes. The catch is that it &lt;strong&gt;rewrites the whole table and locks it&lt;/strong&gt; while doing so, so plan it like a migration.&lt;/p&gt;

&lt;p&gt;You can also change the default for new tables:&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;SET&lt;/span&gt; &lt;span class="n"&gt;default_table_access_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;columnar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Clean up
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pg-engines
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cheat sheet: pick an engine by use case
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Pick&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;Users, orders, payments&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;heap&lt;/code&gt; + &lt;code&gt;btree&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Frequent updates, point lookups, full durability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event logs, metrics history, audit trails&lt;/td&gt;
&lt;td&gt;&lt;code&gt;columnar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Append-only, scanned by a few columns, ~7x smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETL staging, rebuildable caches&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UNLOGGED&lt;/code&gt; heap&lt;/td&gt;
&lt;td&gt;No WAL, faster writes. Emptied after a crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Huge time-ordered tables, range filters&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brin&lt;/code&gt; index&lt;/td&gt;
&lt;td&gt;Kilobytes instead of 100+ MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search, JSONB, arrays&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gin&lt;/code&gt; index&lt;/td&gt;
&lt;td&gt;Indexes the values &lt;em&gt;inside&lt;/em&gt; a column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maps and geometry (PostGIS)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gist&lt;/code&gt; index&lt;/td&gt;
&lt;td&gt;Handles overlaps and nearest-neighbour queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI embeddings, similarity search&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hnsw&lt;/code&gt; (pgvector)&lt;/td&gt;
&lt;td&gt;Approximate nearest-neighbour search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Massive write-heavy key-value data&lt;/td&gt;
&lt;td&gt;LSM engine (RocksDB, Cassandra)&lt;/td&gt;
&lt;td&gt;Sequential writes and compaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-millisecond cache or session store&lt;/td&gt;
&lt;td&gt;In-memory (Redis)&lt;/td&gt;
&lt;td&gt;RAM speed; durability is optional&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Want to go deeper?
&lt;/h2&gt;

&lt;p&gt;I'm learning this by building one: &lt;strong&gt;&lt;a href="https://github.com/AnikethDG/LakePG" rel="noopener noreferrer"&gt;LakePG&lt;/a&gt;&lt;/strong&gt; is a from-scratch Python storage engine that writes &lt;strong&gt;byte-for-byte PostgreSQL-compatible pages&lt;/strong&gt;, with slotted pages, checksums, tuples and MVCC. Its tests compare the pages it writes against a real PostgreSQL 17 server. It's a learning reimplementation of ideas from PostgreSQL, Neon and Databricks Lakebase, not an original design and not production software.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/storage-page-layout.html" rel="noopener noreferrer"&gt;PostgreSQL: Database Page Layout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/tableam.html" rel="noopener noreferrer"&gt;PostgreSQL: Table Access Method Interface&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/brin.html" rel="noopener noreferrer"&gt;PostgreSQL: BRIN Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/wal-intro.html" rel="noopener noreferrer"&gt;PostgreSQL: Write-Ahead Logging&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.citusdata.com/en/stable/admin_guide/table_management.html#columnar-storage" rel="noopener noreferrer"&gt;Citus columnar storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/storage-engines.html" rel="noopener noreferrer"&gt;MySQL: Alternative Storage Engines&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Your Python venv Is (Mostly) a Symlink — Here's Why That Matters</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Mon, 28 Sep 2026 05:17:36 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/your-python-venv-is-mostly-a-symlink-heres-why-that-matters-33cm</link>
      <guid>https://dev.to/anikethsdeshpande/your-python-venv-is-mostly-a-symlink-heres-why-that-matters-33cm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — On Linux and macOS, &lt;code&gt;python3 -m venv .venv&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; create a copy of Python.&lt;br&gt;
The &lt;code&gt;python&lt;/code&gt; inside your venv is a &lt;strong&gt;symlink&lt;/strong&gt; that points back to the system interpreter.&lt;br&gt;
The standard library isn't copied either. Only &lt;code&gt;site-packages&lt;/code&gt; (your installed packages) really belongs to the venv.&lt;br&gt;
So if the system Python gets upgraded, moved or deleted, your venv can break outright,&lt;br&gt;
or keep running against a different Python without telling you.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The claim&lt;/li&gt;
&lt;li&gt;Part 1 — See it with your own eyes (GCP VM / EC2 lab)&lt;/li&gt;
&lt;li&gt;Part 2 — What does this mean? Pros, cons, and real-world impact&lt;/li&gt;
&lt;li&gt;Part 3 — Building a venv that doesn't betray you&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The claim
&lt;/h2&gt;

&lt;p&gt;Most of us picture a virtual environment 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;   What we THINK a venv is
   ───────────────────────

   .venv/
   ├── 🐍 a full private copy of Python
   ├── 📚 a full private copy of the standard library
   └── 📦 my packages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what you actually get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   What a venv ACTUALLY is (Linux/macOS default)
   ─────────────────────────────────────────────

   .venv/
   ├── bin/
   │   ├── python   ──symlink──►  python3
   │   ├── python3  ──symlink──►  /usr/bin/python3   ◄── the SYSTEM interpreter
   │   ├── pip                    (small script, absolute shebang)
   │   └── activate               (shell script)
   ├── lib/python3.12/site-packages/   ◄── the ONLY truly private part
   └── pyvenv.cfg                      ◄── "home = /usr/bin"  (a pointer back)

   Standard library (os, json, asyncio, ssl, ...)?  → still read from /usr/lib/python3.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A venv is really &lt;strong&gt;an isolated &lt;code&gt;site-packages&lt;/code&gt; folder, plus a pointer back to an interpreter that lives somewhere else.&lt;/strong&gt;&lt;br&gt;
I'll prove it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 1 — See it with your own eyes
&lt;/h2&gt;

&lt;p&gt;You can run this on any Linux box. A throwaway cloud VM is ideal because it's clean and you can wreck it without worrying.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 0: Get a VM
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Option A — Google Cloud (GCE)
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances create venv-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1-a &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ubuntu-2404-lts-amd64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ubuntu-os-cloud

gcloud compute ssh venv-lab &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Option B — AWS (EC2)
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 run-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-id&lt;/span&gt; resolve:ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-type&lt;/span&gt; t3.micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-name&lt;/span&gt; &amp;lt;your-key-pair&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-group-ids&lt;/span&gt; &amp;lt;sg-allowing-ssh&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag-specifications&lt;/span&gt; &lt;span class="s1"&gt;'ResourceType=instance,Tags=[{Key=Name,Value=venv-lab}]'&lt;/span&gt;

ssh &lt;span class="nt"&gt;-i&lt;/span&gt; &amp;lt;your-key&amp;gt;.pem ubuntu@&amp;lt;public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once you're in, install the venv module (Ubuntu ships it as a separate package):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; python3-venv
python3 &lt;span class="nt"&gt;--version&lt;/span&gt;          &lt;span class="c"&gt;# Python 3.12.x on Ubuntu 24.04&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 The output below comes from Ubuntu 24.04 / Python 3.12. Other distros and versions show the same pattern, with different version numbers.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Experiment 1: Look inside &lt;code&gt;bin/&lt;/code&gt;
&lt;/h3&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; ~
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv demo
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; demo/bin/ | &lt;span class="nb"&gt;grep &lt;/span&gt;python
&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;lrwxrwxrwx 1 ubuntu ubuntu   7 ... python -&amp;gt; python3
lrwxrwxrwx 1 ubuntu ubuntu  16 ... python3 -&amp;gt; /usr/bin/python3
lrwxrwxrwx 1 ubuntu ubuntu   7 ... python3.12 -&amp;gt; python3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;l&lt;/code&gt; at the start of each permission string and the &lt;code&gt;-&amp;gt;&lt;/code&gt; arrows mean &lt;strong&gt;these are symlinks&lt;/strong&gt;. None of them is a real binary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 2: Follow the chain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; demo/bin/python
&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/bin/python3
&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;/usr/bin/python3.12
/usr/bin/python3.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They end at the &lt;strong&gt;same file&lt;/strong&gt;. You can also check that the venv entry is just a link and not a hard copy:&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;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%i  %s bytes  %n'&lt;/span&gt; /usr/bin/python3.12 demo/bin/python3
&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;16123164  6939856 bytes  /usr/bin/python3.12   ← the real binary (~7 MB)
18122480       16 bytes  demo/bin/python3      ← a 16-byte symlink
&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;   Symlink resolution chain
   ────────────────────────

   demo/bin/python
        │  (symlink)
        ▼
   demo/bin/python3
        │  (symlink)
        ▼
   /usr/bin/python3
        │  (symlink, owned by the distro)
        ▼
   /usr/bin/python3.12   ◄── the one and only real interpreter binary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Experiment 3: Read &lt;code&gt;pyvenv.cfg&lt;/code&gt;, the venv's "birth certificate"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;demo/pyvenv.cfg
&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;home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/ubuntu/demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;home = /usr/bin&lt;/code&gt; is the key line. At startup, Python sees this file and uses it to find its &lt;strong&gt;real home&lt;/strong&gt; (and the standard library that comes with it).&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 4: Ask Python where things come from
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;demo/bin/python - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import sys, os, json
print("sys.executable :", sys.executable)
print("realpath       :", os.path.realpath(sys.executable))
print("sys.prefix     :", sys.prefix)        # the venv
print("sys.base_prefix:", sys.base_prefix)   # the REAL Python install
print("json stdlib    :", json.__file__)
&lt;/span&gt;&lt;span class="no"&gt;EOF
&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;sys.executable : /home/ubuntu/demo/bin/python
realpath       : /usr/bin/python3.12
sys.prefix     : /home/ubuntu/demo
sys.base_prefix: /usr
json stdlib    : /usr/lib/python3.12/json/__init__.py   ← NOT inside the venv!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two prefixes. &lt;code&gt;sys.prefix&lt;/code&gt; is your venv, where packages get installed. &lt;code&gt;sys.base_prefix&lt;/code&gt; is the system install, where the interpreter and the stdlib come from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 5: Check the size
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; demo
&lt;span class="nb"&gt;ls &lt;/span&gt;demo/lib/python3.12/site-packages
&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;12M   demo
pip  pip-24.0.dist-info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost all of those 12 MB are &lt;strong&gt;pip&lt;/strong&gt;. The interpreter and the stdlib (tens of MB) aren't there, because they're borrowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 6: Move the venv and watch it break
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; demo/bin/pip
&lt;span class="nb"&gt;mv &lt;/span&gt;demo demo-moved
demo-moved/bin/pip &lt;span class="nt"&gt;--version&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;#!/home/ubuntu/demo/bin/python3
bash: demo-moved/bin/pip: /home/ubuntu/demo/bin/python3: bad interpreter: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every console script (&lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;pytest&lt;/code&gt;, &lt;code&gt;black&lt;/code&gt;, and so on) has the &lt;strong&gt;absolute path&lt;/strong&gt; of the venv baked into its shebang line. Venvs aren't relocatable.&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;mv &lt;/span&gt;demo-moved demo   &lt;span class="c"&gt;# put it back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Experiment 7: Delete the base Python (the scary one)
&lt;/h3&gt;

&lt;p&gt;We don't want to wreck the VM's real &lt;code&gt;/usr/bin/python3&lt;/code&gt;, since &lt;code&gt;apt&lt;/code&gt; and cloud-init depend on it. So we'll build a &lt;strong&gt;fake "system Python"&lt;/strong&gt; in &lt;code&gt;/opt&lt;/code&gt;, create venvs from it, then "uninstall" 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="c"&gt;# 1. Build a standalone copy of the interpreter + stdlib at /opt/fakepy&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/fakepy/bin /opt/fakepy/lib
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /usr/bin/python3.12 /opt/fakepy/bin/
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /usr/lib/python3.12 /opt/fakepy/lib/
/opt/fakepy/bin/python3.12 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys; print(sys.prefix)"&lt;/span&gt;   &lt;span class="c"&gt;# → /opt/fakepy&lt;/span&gt;

&lt;span class="c"&gt;# 2. Create two venvs from it: default (symlinks) and --copies&lt;/span&gt;
/opt/fakepy/bin/python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv &lt;span class="nt"&gt;--without-pip&lt;/span&gt; ~/linked
/opt/fakepy/bin/python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv &lt;span class="nt"&gt;--without-pip&lt;/span&gt; &lt;span class="nt"&gt;--copies&lt;/span&gt; ~/copied
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ~/linked/bin/python3.12     &lt;span class="c"&gt;# → /opt/fakepy/bin/python3.12&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ~/copied/bin/python3.12     &lt;span class="c"&gt;# → a real 7 MB file&lt;/span&gt;

&lt;span class="c"&gt;# 3. Simulate "the base Python was upgraded / uninstalled"&lt;/span&gt;
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /opt/fakepy /opt/fakepy.gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/linked/bin/python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'print("hello")'&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;bash: /home/ubuntu/linked/bin/python: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;💥 The symlink is &lt;strong&gt;dangling&lt;/strong&gt;, so the venv is dead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/copied/bin/python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import sys, os; print(sys.base_prefix, os.__file__)'&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;/usr /usr/lib/python3.12/os.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;😱 The &lt;code&gt;--copies&lt;/code&gt; venv &lt;em&gt;did&lt;/em&gt; start, but &lt;code&gt;pyvenv.cfg&lt;/code&gt; pointed to a &lt;code&gt;home&lt;/code&gt; that no longer exists. Python fell back to its compiled-in prefix (&lt;code&gt;/usr&lt;/code&gt;) and &lt;strong&gt;silently switched to a different standard library&lt;/strong&gt;. Here the two happened to be identical, so nothing went wrong. On a real machine they could be different patch or minor versions, and you'd get strange &lt;code&gt;ImportError&lt;/code&gt;s or subtle bugs that nobody can explain.&lt;/p&gt;

&lt;p&gt;Clean up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /opt/fakepy.gone /opt/fakepy   &lt;span class="c"&gt;# or: sudo rm -rf /opt/fakepy.gone&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/linked ~/copied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you're finished with the lab, delete the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances delete venv-lab &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1-a   &lt;span class="c"&gt;# GCP&lt;/span&gt;
aws ec2 terminate-instances &lt;span class="nt"&gt;--instance-ids&lt;/span&gt; &amp;lt;instance-id&amp;gt;          &lt;span class="c"&gt;# AWS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 2 — What does this mean?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How a venv actually boots
&lt;/h3&gt;

&lt;p&gt;This comes from &lt;a href="https://peps.python.org/pep-0405/" rel="noopener noreferrer"&gt;PEP 405&lt;/a&gt;, the spec that defines &lt;code&gt;venv&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;  $ .venv/bin/python app.py
            │
            ▼
  ┌──────────────────────────────────────┐
  │ Kernel follows symlinks              │
  │ .venv/bin/python → /usr/bin/python3.12│  ← executes the SYSTEM binary
  └──────────────────────────────────────┘
            │  (but argv[0] / sys.executable still says .venv/bin/python)
            ▼
  ┌──────────────────────────────────────┐
  │ Python looks for pyvenv.cfg next to  │
  │ (or one level above) sys.executable  │
  └──────────────────────────────────────┘
            │ found!
            ▼
  ┌──────────────────────────────────────┐        ┌──────────────────────────────┐
  │ sys.prefix      = .venv              │ ─────► │ .venv/lib/python3.12/        │
  │                                      │        │        site-packages/  📦     │
  │ sys.base_prefix = derived from       │        └──────────────────────────────┘
  │                   "home = /usr/bin"  │        ┌──────────────────────────────┐
  │                                      │ ─────► │ /usr/lib/python3.12/  📚      │
  └──────────────────────────────────────┘        │ (os, json, ssl, asyncio ...) │
                                                  └──────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a venv is three things:&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;Where it lives&lt;/th&gt;
&lt;th&gt;Owned by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interpreter binary&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/usr/bin/python3.12&lt;/code&gt; (symlinked)&lt;/td&gt;
&lt;td&gt;🧑‍💼 The OS / package manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard library&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/usr/lib/python3.12/&lt;/code&gt; (referenced)&lt;/td&gt;
&lt;td&gt;🧑‍💼 The OS / package manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party packages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.venv/lib/python3.12/site-packages/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;🙋 &lt;strong&gt;You&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Being precise:&lt;/strong&gt; "the venv is a symlink" is a handy shorthand, but the venv &lt;em&gt;directory&lt;/em&gt; is real, and so is &lt;code&gt;site-packages&lt;/code&gt;.&lt;br&gt;
What's symlinked is the &lt;strong&gt;interpreter&lt;/strong&gt;. The &lt;strong&gt;stdlib&lt;/strong&gt; is borrowed through &lt;code&gt;pyvenv.cfg&lt;/code&gt; and never linked or copied.&lt;br&gt;
On Windows, venvs use a small copied &lt;code&gt;python.exe&lt;/code&gt; launcher instead of a symlink, but it still reads &lt;code&gt;pyvenv.cfg&lt;/code&gt; and depends on the base install in the same way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why did Python's designers do it this way?
&lt;/h3&gt;

&lt;p&gt;On purpose. Venvs are meant to be &lt;strong&gt;cheap, fast and disposable&lt;/strong&gt;, not self-contained deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ Pros
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pro&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tiny&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~12 MB (mostly pip) instead of ~40–100 MB per env. Having 30 projects doesn't mean 30 copies of CPython.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fast to create&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Creating a few symlinks takes milliseconds. Great for CI, tox and nox matrices.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security patches for free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;When &lt;code&gt;apt upgrade&lt;/code&gt; ships a patched &lt;code&gt;python3.12&lt;/code&gt; (say, an &lt;code&gt;ssl&lt;/code&gt; or &lt;code&gt;zipfile&lt;/code&gt; CVE fix), every venv picks it up with no rebuild.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One interpreter to trust&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only one binary sits on disk to audit, scan and patch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Package isolation still works&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two projects can pin different versions of &lt;code&gt;requests&lt;/code&gt; without conflict, which is the reason venvs exist.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ❌ Cons
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Con&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fragile to base changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Base Python removed or upgraded → &lt;code&gt;No such file or directory&lt;/code&gt; / &lt;code&gt;bad interpreter&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Silent minor-version drift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Venv made with &lt;code&gt;python3 -m venv&lt;/code&gt; links to &lt;code&gt;/usr/bin/python3&lt;/code&gt;, &lt;em&gt;not&lt;/em&gt; &lt;code&gt;python3.12&lt;/code&gt;. If the distro repoints &lt;code&gt;python3&lt;/code&gt; to 3.13, your venv starts running &lt;strong&gt;3.13&lt;/strong&gt; and looks for &lt;code&gt;lib/python3.13/site-packages&lt;/code&gt;, which doesn't exist. Result: &lt;code&gt;ModuleNotFoundError&lt;/code&gt; for everything you installed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Not relocatable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Absolute paths live in shebangs, &lt;code&gt;pyvenv.cfg&lt;/code&gt; and &lt;code&gt;activate&lt;/code&gt;. You can't &lt;code&gt;mv&lt;/code&gt;, &lt;code&gt;cp -r&lt;/code&gt;, rsync to another path, or bake it in one Docker stage and copy it to another path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Not portable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You can't copy &lt;code&gt;.venv&lt;/code&gt; to another machine unless the exact same Python exists at the exact same path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compiled wheels are tied to the base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;C extensions (&lt;code&gt;numpy&lt;/code&gt;, &lt;code&gt;psycopg&lt;/code&gt;, &lt;code&gt;cryptography&lt;/code&gt;) were built against a specific ABI (&lt;code&gt;cp312&lt;/code&gt;). Swapping the base breaks them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;--copies&lt;/code&gt; is only half a fix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It copies the binary but still borrows the stdlib, and falls back &lt;em&gt;silently&lt;/em&gt; if &lt;code&gt;home&lt;/code&gt; disappears (see Experiment 7).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How it bites in real life
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                   ┌──────────────────────────────┐
                   │   Your venv  (.venv/)        │
                   │   python3 → /usr/bin/python3 │
                   └───────────────┬──────────────┘
                                   │ depends on
                                   ▼
   ┌───────────────────────────────────────────────────────────────┐
   │            Base interpreter you DON'T control                 │
   └───────────────────────────────────────────────────────────────┘
      ▲               ▲                 ▲                   ▲
      │               │                 │                   │
 do-release-upgrade   brew upgrade   pyenv uninstall    Docker: copy venv
 22.04 → 24.04        python@3.12    3.11.4             into distroless /
 (3.10 → 3.12)        + brew cleanup                    alpine image
      │               │                 │                   │
      ▼               ▼                 ▼                   ▼
 ModuleNotFound   dangling link     dangling link      "no such file"
 (drift)          → venv dead       → venv dead        at container start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some scenarios you may have hit already:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ubuntu release upgrade.&lt;/strong&gt; 22.04 ships Python 3.10 and 24.04 ships 3.12. After &lt;code&gt;do-release-upgrade&lt;/code&gt;, venvs built with &lt;code&gt;python3 -m venv&lt;/code&gt; start running 3.12 and all your packages are gone. Venvs built with &lt;code&gt;python3.10 -m venv&lt;/code&gt; are dead links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS + Homebrew.&lt;/strong&gt; &lt;code&gt;brew upgrade&lt;/code&gt; installs a new patch version, and &lt;code&gt;brew cleanup&lt;/code&gt; deletes the old Cellar directory your venv pointed to. This is the classic "my venv broke overnight".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs / systemd services on a VM.&lt;/strong&gt; You set &lt;code&gt;ExecStart=/opt/app/.venv/bin/python ...&lt;/code&gt; in a unit file. Months later, unattended upgrades or an OS image refresh change the base Python, and the service dies at 3 a.m.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker multi-stage builds.&lt;/strong&gt; You build &lt;code&gt;/opt/venv&lt;/code&gt; in &lt;code&gt;python:3.12&lt;/code&gt; and copy it into &lt;code&gt;gcr.io/distroless/base&lt;/code&gt;. &lt;code&gt;/usr/local/bin/python3&lt;/code&gt; doesn't exist there, so the venv's symlink points nowhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renaming a project folder.&lt;/strong&gt; &lt;code&gt;mv my-app my-app-v2&lt;/code&gt; breaks &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;pytest&lt;/code&gt; and every other console script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI caching.&lt;/strong&gt; You cache &lt;code&gt;.venv&lt;/code&gt; between runs. The runner image updates its Python, the cache restores a venv linked to a Python that's gone, and the build fails in confusing ways.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 3 — Building a venv that doesn't betray you
&lt;/h2&gt;

&lt;p&gt;The root problem isn't the symlink itself. It's that &lt;strong&gt;the venv depends on an interpreter you don't control.&lt;/strong&gt; So the fix is to &lt;strong&gt;control the base interpreter&lt;/strong&gt;, and to treat venvs as disposable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Which venv strategy do I need?
                    ──────────────────────────────

                  ┌──────────────────────────────┐
                  │ Is this for local dev / CI ? │
                  └───────┬───────────────┬──────┘
                       yes│               │no (deploying / shipping)
                          ▼               ▼
         ┌──────────────────────┐   ┌───────────────────────────────┐
         │ Rule 1 + Rule 2      │   │ Must it run where Python may   │
         │ pinned base + lock   │   │ be missing/different?          │
         │ file, recreate often │   └──────┬─────────────────┬──────┘
         └──────────────────────┘       yes│                 │no
                                           ▼                 ▼
                        ┌──────────────────────────┐  ┌─────────────────────┐
                        │ Rule 4: ship the         │  │ Rule 3: Docker with │
                        │ interpreter too          │  │ venv at the SAME    │
                        │ (standalone Python /     │  │ path, SAME base img │
                        │  container / PyInstaller)│  └─────────────────────┘
                        └──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rule 1 — Never build venvs on the distro's &lt;code&gt;python3&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The system Python exists &lt;strong&gt;for the OS&lt;/strong&gt; (apt, cloud-init, gcloud/aws tooling), and the OS will upgrade it whenever it needs to. Install your own interpreter at a &lt;strong&gt;pinned, versioned path&lt;/strong&gt; that only changes when you change it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Option A: &lt;code&gt;uv&lt;/code&gt; (fast and simple)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-LsSf&lt;/span&gt; https://astral.sh/uv/install.sh | sh
uv python &lt;span class="nb"&gt;install &lt;/span&gt;3.12            &lt;span class="c"&gt;# downloads a standalone CPython build&lt;/span&gt;
uv venv &lt;span class="nt"&gt;--python&lt;/span&gt; 3.12 .venv       &lt;span class="c"&gt;# venv linked to *uv's* Python, not /usr/bin&lt;/span&gt;
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Option B: &lt;code&gt;pyenv&lt;/code&gt; (compiles from source)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pyenv.run | bash      &lt;span class="c"&gt;# then follow the shell setup it prints&lt;/span&gt;
pyenv &lt;span class="nb"&gt;install &lt;/span&gt;3.12.7
~/.pyenv/versions/3.12.7/bin/python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the venv is &lt;strong&gt;still a symlink&lt;/strong&gt;, but now it points at an interpreter that belongs to you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   BEFORE (fragile)                         AFTER (stable)
   ────────────────                         ──────────────
   .venv/bin/python3                        .venv/bin/python3
        │                                        │
        ▼                                        ▼
   /usr/bin/python3  ◄── apt owns this      ~/.pyenv/versions/3.12.7/bin/python3.12
        │                (changes whenever       ◄── YOU own this
        ▼                 the OS wants)              (changes only when you
   /usr/bin/python3.12                                pyenv uninstall it)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Option C: No extra tools? At least pin the minor version
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv      &lt;span class="c"&gt;# ✅ links to python3.12 — no silent 3.12→3.13 drift&lt;/span&gt;
python3    &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv      &lt;span class="c"&gt;# ❌ links to python3   — drifts when the distro repoints it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check 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;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; .venv/bin/ | &lt;span class="nb"&gt;grep &lt;/span&gt;python
&lt;span class="c"&gt;# python3.12 -&amp;gt; /usr/bin/python3.12   ✅ pinned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rule 2 — Treat the venv as a cache, not a pet
&lt;/h3&gt;

&lt;p&gt;Assume any venv can die, and make rebuilding it a one-liner:&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;# Lock exact versions&lt;/span&gt;
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.lock          &lt;span class="c"&gt;# or: uv pip compile / poetry lock / pip-tools&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild from scratch any time&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .venv
python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never commit &lt;code&gt;.venv&lt;/code&gt;&lt;/strong&gt; to git (add it to &lt;code&gt;.gitignore&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;mv&lt;/code&gt; a venv&lt;/strong&gt;. Recreate it at the new path instead.&lt;/li&gt;
&lt;li&gt;If you upgraded the base Python in place (same minor version), refresh the links with:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv &lt;span class="nt"&gt;--upgrade&lt;/span&gt; .venv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rule 3 — In Docker, keep the venv's world identical across stages
&lt;/h3&gt;

&lt;p&gt;Multi-stage builds work fine &lt;strong&gt;as long as the final image has the same interpreter at the same path&lt;/strong&gt;:&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="c"&gt;# ---------- build stage ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv /opt/venv
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="/opt/venv/bin:$PATH"&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.lock .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.lock

&lt;span class="c"&gt;# ---------- runtime stage ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim                 # ✅ SAME base → /usr/local/bin/python3.12 exists&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /opt/venv /opt/venv # ✅ SAME path → shebangs still valid&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="/opt/venv/bin:$PATH"&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /app&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "main.py"]&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;   build stage (python:3.12-slim)          runtime stage (python:3.12-slim)
   ┌──────────────────────────────┐        ┌──────────────────────────────┐
   │ /usr/local/bin/python3.12 ◄─┐│        │ /usr/local/bin/python3.12 ◄─┐│
   │ /opt/venv/bin/python ───────┘│ COPY ► │ /opt/venv/bin/python ───────┘│
   │ /opt/venv/lib/.../site-pkgs  │        │ /opt/venv/lib/.../site-pkgs  │
   └──────────────────────────────┘        └──────────────────────────────┘
                 ✅ link target exists on both sides
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;❌ Don't copy &lt;code&gt;/opt/venv&lt;/code&gt; into &lt;code&gt;alpine&lt;/code&gt; (musl vs glibc), &lt;code&gt;distroless&lt;/code&gt; (no Python at that path), or &lt;code&gt;python:3.13-slim&lt;/code&gt; (wrong minor version).&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 4 — If Python might be missing at the destination, ship the interpreter
&lt;/h3&gt;

&lt;p&gt;When the target machine may not have a matching Python at all (air-gapped servers, customer machines, minimal images), a venv is the wrong tool. Ship the interpreter along with it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Interpreter, stdlib and venv frozen together. The most common answer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/astral-sh/python-build-standalone" rel="noopener noreferrer"&gt;python-build-standalone&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A relocatable CPython tarball. Unpack it in &lt;code&gt;/opt/myapp/python&lt;/code&gt;, build the venv from &lt;em&gt;that&lt;/em&gt; Python, and ship the whole &lt;code&gt;/opt/myapp&lt;/code&gt; directory.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;conda-pack&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Packs a full conda env (interpreter included) into a tarball you can relocate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PyInstaller / Nuitka / shiv&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bundles your app and interpreter into a single executable or zipapp.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What about &lt;code&gt;--copies&lt;/code&gt;?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv &lt;span class="nt"&gt;--copies&lt;/span&gt; .venv
&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;   --symlinks (default on Linux/macOS)      --copies
   ───────────────────────────────────      ─────────────────────────────────
   bin/python3.12 → /usr/bin/python3.12     bin/python3.12   (real 7 MB copy)
   stdlib: borrowed from /usr/lib           stdlib: STILL borrowed from /usr/lib
   base removed → hard fail 💥              base removed → silent fallback 😶
   base patched → auto-upgrade ✅           base patched → stale binary +
                                                           new stdlib ⚠️ mismatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--copies&lt;/code&gt; is useful when your filesystem &lt;strong&gt;can't do symlinks&lt;/strong&gt; (some network mounts, Vagrant/VirtualBox shared folders, certain Windows setups). It is &lt;strong&gt;not&lt;/strong&gt; a way to make a venv self-contained, because the stdlib still lives in the base install. In some ways it's worse: a symlinked venv fails loudly, while a copied one can keep running with a mismatched stdlib.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus: a venv health check
&lt;/h3&gt;

&lt;p&gt;Drop this in your dotfiles or CI to spot broken or drifted venvs before they cause trouble:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# venv-doctor.sh — usage: ./venv-doctor.sh path/to/.venv [more venvs...]&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;v &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&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;do
  &lt;/span&gt;&lt;span class="nv"&gt;py&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;/bin/python"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="s2"&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;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"💀 &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;: interpreter link is dangling → &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import sys, os, re
venv = sys.argv[1]
cfg = open(os.path.join(venv, "pyvenv.cfg")).read()
home = re.search(r"^home&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*(.+)&lt;/span&gt;&lt;span class="nv"&gt;$"&lt;/span&gt;&lt;span class="sh"&gt;, cfg, re.M).group(1).strip()
want = re.search(r"^version(?:_info)?&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*(&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sh"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sh"&gt;+)", cfg, re.M).group(1)
have = f"{sys.version_info.major}.{sys.version_info.minor}"
problems = []
if want != have:
    problems.append(f"version drift: created with {want}, now running {have}")
if not os.path.isdir(home):
    problems.append(f"home '{home}' no longer exists (stdlib fallback: {sys.base_prefix})")
print(("⚠️  " if problems else "✅ ") + venv + (": " + "; ".join(problems) if problems else f": OK (Python {have} → {os.path.realpath(sys.executable)})"))
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="k"&gt;done&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;$ ./venv-doctor.sh ~/proj-a/.venv ~/proj-b/.venv ~/old-thing/.venv
✅ /home/ubuntu/proj-a/.venv: OK (Python 3.12 → /home/ubuntu/.pyenv/versions/3.12.7/bin/python3.12)
⚠️  /home/ubuntu/proj-b/.venv: version drift: created with 3.10, now running 3.12
💀 /home/ubuntu/old-thing/.venv: interpreter link is dangling → /opt/fakepy/bin/python3.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is my venv's Python a symlink?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls -l .venv/bin/python*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does it really run?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;readlink -f .venv/bin/python&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where does it think home is?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cat .venv/pyvenv.cfg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Venv vs base prefix&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.venv/bin/python -c 'import sys;print(sys.prefix, sys.base_prefix)'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where does the stdlib come from?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.venv/bin/python -c 'import os;print(os.__file__)'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create a pinned venv&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python3.12 -m venv .venv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create a venv on a Python you own&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;uv venv --python 3.12&lt;/code&gt; / &lt;code&gt;~/.pyenv/versions/3.12.7/bin/python -m venv .venv&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh links after base patch upgrade&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python3.12 -m venv --upgrade .venv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild from scratch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rm -rf .venv &amp;amp;&amp;amp; python3.12 -m venv .venv &amp;amp;&amp;amp; .venv/bin/pip install -r requirements.lock&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   A venv is a lightweight overlay:

        ┌───────────────────────────────┐
        │  YOUR packages (site-packages)│  ◄── isolated, yours
        ├───────────────────────────────┤
        │  stdlib                       │  ◄── borrowed
        │  interpreter binary           │  ◄── symlinked
        └───────────────────────────────┘
                 base Python install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The symlink design makes venvs &lt;strong&gt;cheap, fast and auto-patched&lt;/strong&gt;. That was a deliberate choice.&lt;/li&gt;
&lt;li&gt;It also means a venv is &lt;strong&gt;only as stable as the interpreter it points to&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;So: &lt;strong&gt;own your base interpreter&lt;/strong&gt; (uv / pyenv / a pinned &lt;code&gt;python3.X&lt;/code&gt;), &lt;strong&gt;lock your dependencies&lt;/strong&gt;, &lt;strong&gt;treat venvs as disposable&lt;/strong&gt;, and when you need portability, &lt;strong&gt;ship the interpreter&lt;/strong&gt;, not just the venv.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time a venv "randomly" breaks after an upgrade, run &lt;code&gt;ls -l .venv/bin/python&lt;/code&gt; first. There's a good chance the link is pointing at a Python that no longer exists.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Drop a ❤️ or tell me in the comments about your worst "my venv broke overnight" story.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>linux</category>
      <category>devops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Predicate Pushdown - Understanding Practically With An Example</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Wed, 17 Apr 2024 19:42:28 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/predicate-pushdown-understanding-practically-with-an-example-4b51</link>
      <guid>https://dev.to/anikethsdeshpande/predicate-pushdown-understanding-practically-with-an-example-4b51</guid>
      <description>&lt;p&gt;What is predicate pushdown?&lt;/p&gt;

&lt;p&gt;The immediate theoretical answer that we get on searching is&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Predicate pushdown is a query optimisation technique used in database technologies&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Okay, I got to know that it is an optimisation technique. But I still did not understand...&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How is the optimisation happening? 🤨&lt;/li&gt;
&lt;li&gt;What is predicate? 🤔&lt;/li&gt;
&lt;li&gt;What exactly is the meaning of pushed down here? 🤷‍♂️&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm sure since you are also reading this article, even you have these questions in mind!&lt;/p&gt;

&lt;p&gt;Now, lets explore this interesting topic practically in &lt;strong&gt;&lt;em&gt;PySpark&lt;/em&gt;&lt;/strong&gt; using &lt;code&gt;explain()&lt;/code&gt;&lt;br&gt;
(Similar phenomenon could be observed in relational databases as well)&lt;/p&gt;

&lt;p&gt;1] Reading a csv file containing employee information.&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;emp&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;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;csv&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;header&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data/employee.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;emp&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;&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.amazonaws.com%2Fuploads%2Farticles%2Fppxfeaejoac8noucyd86.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.amazonaws.com%2Fuploads%2Farticles%2Fppxfeaejoac8noucyd86.png" alt="df.show" width="700" height="476"&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;emp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formatted&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;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.amazonaws.com%2Fuploads%2Farticles%2Fxg2ushs3wglcaltw18wk.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.amazonaws.com%2Fuploads%2Farticles%2Fxg2ushs3wglcaltw18wk.png" alt="pyspark explain read df" width="800" height="148"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here, step (1) is related to read csv file&lt;/p&gt;

&lt;p&gt;2] Lets do a group by and get number of employees on each department&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;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emp&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;deptID&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="n"&gt;df&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;&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.amazonaws.com%2Fuploads%2Farticles%2Fxqawodk4tq9qwuy14eu5.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.amazonaws.com%2Fuploads%2Farticles%2Fxqawodk4tq9qwuy14eu5.png" alt="emp group by" width="284" 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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formatted&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;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.amazonaws.com%2Fuploads%2Farticles%2Fjehpma6xwkaybwow9kby.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.amazonaws.com%2Fuploads%2Farticles%2Fjehpma6xwkaybwow9kby.png" alt="df explain" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here, step (2) is related to &lt;code&gt;group by&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;3] Lets filter data for only dept number 10&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;dept_10&lt;/span&gt; &lt;span class="o"&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;filter&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;deptID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dept_10&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;&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.amazonaws.com%2Fuploads%2Farticles%2Fz0a88h1wfr5ccnexkdaz.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.amazonaws.com%2Fuploads%2Farticles%2Fz0a88h1wfr5ccnexkdaz.png" alt="Image description" width="348" height="184"&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;dept_10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formatted&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;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.amazonaws.com%2Fuploads%2Farticles%2F5ma0nok0jxtp0zuky4x0.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.amazonaws.com%2Fuploads%2Farticles%2F5ma0nok0jxtp0zuky4x0.png" alt="Image description" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here, we can see that step (2) is filtering and step (3) is grouping. &lt;/p&gt;

&lt;p&gt;Now here is the catch, &lt;br&gt;
Ideally if we go by the sequence of operations, grouping should be done first and then filtering. &lt;/p&gt;

&lt;p&gt;However, the optimiser does filtering first and then grouping, because grouping is an expensive operation and it is optimal to filter first and then group data.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiywjb9ic2nu0l5ue64m7.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.amazonaws.com%2Fuploads%2Farticles%2Fiywjb9ic2nu0l5ue64m7.png" alt="Image description" width="544" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the physical plan we see that filtering (&lt;code&gt;predicate&lt;/code&gt;) is pushed down with respect to grouping. That is why it is called &lt;code&gt;push down&lt;/code&gt; ! &lt;/p&gt;

</description>
      <category>spark</category>
      <category>optimisation</category>
      <category>sql</category>
      <category>interview</category>
    </item>
    <item>
      <title>Write Through</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Fri, 22 Mar 2024 13:06:39 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/write-through-81n</link>
      <guid>https://dev.to/anikethsdeshpande/write-through-81n</guid>
      <description>&lt;p&gt;Write through cache is a simple to implement caching mechanism.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Here the newly arrived data is written into cache and as well as persisted into the disk or a database. Atomicity is maintained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two ways to implement it:&lt;br&gt;
1] The application writes data to cache and database simultaneously&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz9ls68gioad30eo5d0v.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.amazonaws.com%2Fuploads%2Farticles%2Fmz9ls68gioad30eo5d0v.png" alt="Write through cache" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2] The application writes data to cache and then the cache writes the data into the database.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy577t78bpgorm1dqudns.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.amazonaws.com%2Fuploads%2Farticles%2Fy577t78bpgorm1dqudns.png" alt="Write through cache 2" width="800" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h5&gt;
  
  
  Advantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;Simple to implement.&lt;/li&gt;
&lt;li&gt;Faster response times.&lt;/li&gt;
&lt;li&gt;Data integrity because of atomic nature of write operation. &lt;/li&gt;
&lt;li&gt;Lower latency for subsequent reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h5&gt;
  
  
  Disadvantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cache pollution: Since every time the data is filled into cache, it can get filled with less frequently read data and more cache eviction which could introduce some latency.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Not suitable for write intensive scenarios as the write operations are slower compared to other methods because data needs to be written in cache as well as persistent storage everytime.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cache</category>
      <category>redis</category>
      <category>systemdesign</category>
      <category>writepolicy</category>
    </item>
    <item>
      <title>Database Caching Strategies</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Fri, 22 Mar 2024 12:43:08 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/cache-write-policies-419b</link>
      <guid>https://dev.to/anikethsdeshpande/cache-write-policies-419b</guid>
      <description>&lt;p&gt;We often face high latencies while fetching data from Database and are unable to meet SLA. Caching is one of the solutions to implement after DB query and table optimisations. &lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Cache&lt;/strong&gt; is used to store the data so that it can be delivered faster to the client as compared to persistent systems like database or disk.&lt;/p&gt;

&lt;p&gt;Popular caching tools are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://redis.io/" rel="noopener noreferrer"&gt;Redis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://memcached.org/" rel="noopener noreferrer"&gt;Memcached&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are multiple ways in which data can be written into and read from the cache. Let us explore the most prominently used methods or policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache Aside
&lt;/h2&gt;

&lt;p&gt;Cache Aside or Lazy Loading is one of the cache write policies or strategies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In cache aside method, the application is responsible for storing data into cache.&lt;/li&gt;
&lt;li&gt;When the client sends request to the application, it looks for data in the cache.&lt;/li&gt;
&lt;li&gt;If the data is found in the cache it is called as &lt;em&gt;cache-hit&lt;/em&gt;. The data is fetched from cache and returned to client.&lt;/li&gt;
&lt;li&gt;However, if the data is not found in the cache, also called as &lt;em&gt;cache-miss&lt;/em&gt;, the application queries for data in the database, writes the data into cache and sends the response to the client.&lt;/li&gt;
&lt;li&gt;Since we store data in cache only when it is necessary, this strategy is also called as Lazy-Loading.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flh6koyxc7792iu568usl.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.amazonaws.com%2Fuploads%2Farticles%2Flh6koyxc7792iu568usl.png" alt="Cache Aside - Write Strategy" width="538" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Advantages:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Cost effective&lt;/em&gt; because, only the frequently accessed data is stored into cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Disadvantages:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The response time can be slow when there is cache miss, because, it involves many i/o operations to fetch data from DB and store it in cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;In case the system can tolerate an initial delay and the same data is to be fetched repeatedly, then this mechanism works best.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Write through
&lt;/h2&gt;

&lt;p&gt;Write through cache is a simple to implement caching mechanism.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Here the newly arrived data is written into cache and as well as persisted into the disk or a database. Atomicity is maintained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two ways to implement it:&lt;br&gt;
1] The application writes data to cache and database simultaneously&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz9ls68gioad30eo5d0v.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.amazonaws.com%2Fuploads%2Farticles%2Fmz9ls68gioad30eo5d0v.png" alt="Write through cache" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2] The application writes data to cache and then the cache writes the data into the database.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy577t78bpgorm1dqudns.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.amazonaws.com%2Fuploads%2Farticles%2Fy577t78bpgorm1dqudns.png" alt="Write through cache 2" width="800" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h5&gt;
  
  
  Advantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;Faster response times.&lt;/li&gt;
&lt;li&gt;Data integrity because of atomic nature of write operation. &lt;/li&gt;
&lt;li&gt;Lower latency for subsequent reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h5&gt;
  
  
  Disadvantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cache pollution: Since every time the data is filled into cache, it can get filled with less frequently read data and more cache eviction which could introduce some latency.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Not suitable for write intensive scenarios as the write operations are slower compared to other methods because data needs to be written in cache as well as persistent storage everytime.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;In case the system cannot tolerate an initial delay and the writes are infrequent, then this mechanism works best. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Also if the number of records to be cached is also fixed, then this approach can provide the best of the results.&lt;/p&gt;

&lt;blockquote&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thank you :)&lt;/p&gt;

</description>
      <category>gratitude</category>
    </item>
    <item>
      <title>Cache Aside</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Fri, 22 Mar 2024 12:41:36 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/cache-aside-1ioa</link>
      <guid>https://dev.to/anikethsdeshpande/cache-aside-1ioa</guid>
      <description>&lt;p&gt;Cache Aside or Lazy Loading is one of the cache write policies or strategies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In cache aside method, the application is responsible for storing data into cache.&lt;/li&gt;
&lt;li&gt;When the client sends request to the application, it looks for data in the cache.&lt;/li&gt;
&lt;li&gt;If the data is found in the cache it is called as &lt;em&gt;cache-hit&lt;/em&gt;. The data is fetched from cache and returned to client.&lt;/li&gt;
&lt;li&gt;However, if the data is not found in the cache, also called as &lt;em&gt;cache-miss&lt;/em&gt;, the application queries for data in the database, writes the data into cache and sends the response to the client.&lt;/li&gt;
&lt;li&gt;Since we store data in cache only when it is necessary, this strategy is also called as Lazy-Loading.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flh6koyxc7792iu568usl.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.amazonaws.com%2Fuploads%2Farticles%2Flh6koyxc7792iu568usl.png" alt="Cache Aside - Write Strategy" width="538" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h5&gt;
  
  
  Advantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;The implementation is simple.&lt;/li&gt;
&lt;/ul&gt;

&lt;h5&gt;
  
  
  Disadvantages:
&lt;/h5&gt;

&lt;ul&gt;
&lt;li&gt;The response time can be slow when there is cache miss, because, it involves many io operations to fetch data from DB and store it in cache.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gratitude</category>
      <category>tailwindcss</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shallow Copy Vs Deep Copy</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Sat, 29 Oct 2022 12:57:16 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/shallow-copy-vs-deep-copy-56jf</link>
      <guid>https://dev.to/anikethsdeshpande/shallow-copy-vs-deep-copy-56jf</guid>
      <description>&lt;p&gt;In our day to day development tasks, we come across the need to copy objects and perform various operations.&lt;/p&gt;

&lt;p&gt;Python provides two important functions in the &lt;strong&gt;copy&lt;/strong&gt; library - &lt;em&gt;copy&lt;/em&gt; and &lt;em&gt;deepcopy&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Let us understand the difference between the two and their respective use cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shallow Copy - copy.copy(obj)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;It makes a copy of the obj at the surface level. &lt;/li&gt;
&lt;li&gt;It copies all the contents of the obj.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ferp0i8785js06ednujn9.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.amazonaws.com%2Fuploads%2Farticles%2Ferp0i8785js06ednujn9.png" alt="copy.copy()" width="752" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;However, the point to be noted here is that, in case the contents of x are mutable, then, y has reference to the contents of x. Any modification to the contents of x would be reflected in y as well.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F67ef2wud6bu5y3rq9ve7.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.amazonaws.com%2Fuploads%2Farticles%2F67ef2wud6bu5y3rq9ve7.png" alt="shallow copy" width="800" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this example we see that modifying the contents of x[1], also modified the contents of y[1].&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3o45byvy1gst3yy8q5tw.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.amazonaws.com%2Fuploads%2Farticles%2F3o45byvy1gst3yy8q5tw.png" alt="shallow copy" width="800" height="151"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Similarly, we see that modifying the contents of y, also modified the contents of x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deep Copy - copy.deepcopy(obj)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Deepcopy copies the object recursively. Recursively means, it copies the contents of the object and not merely its reference.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;So we should use deep copy in case we need an independent copy of the contents of the obj.&lt;/p&gt;&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F53buud6tqel6affms9k9.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.amazonaws.com%2Fuploads%2Farticles%2F53buud6tqel6affms9k9.png" alt="Deepcopy" width="726" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In deepcopy, any changes to the contents of x do not effect y, unlike shallow copy.&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn23a8du7apog6p8shsfx.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.amazonaws.com%2Fuploads%2Farticles%2Fn23a8du7apog6p8shsfx.png" alt="Deepcopy" width="774" height="172"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Copying objects properly is a very important &lt;em&gt;basic python concepts&lt;/em&gt;. This knowledge helps in building error free code wrt copying objects.&lt;/p&gt;

&lt;p&gt;Thank you&lt;br&gt;
Aniketh Deshpande&lt;/p&gt;

</description>
      <category>python</category>
      <category>deepcopy</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Dead Letter Queue</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Sun, 23 Oct 2022 11:50:55 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/dead-letter-queue-1ml2</link>
      <guid>https://dev.to/anikethsdeshpande/dead-letter-queue-1ml2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is going to be a very short introduction to dead letter queues. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What is a dead letter queue??
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Dead letter queues are messages queues specifically deployed to holding messages that could not be delivered to their intended queues.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Messages sometimes fail to get delivered to their intended queues as they might be unavailable or the queue is full.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Popular message queue tools that support or do not support DLQ:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS SQS (Simple Queue Service) supports DLQ.&lt;/li&gt;
&lt;li&gt;RabbitMQ (free and open source) also supports DLQ.&lt;/li&gt;
&lt;li&gt;Redis does not support DLQ.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;p&gt;Thanks for reading :)&lt;br&gt;
DLQ is a very important component of a scalable and resilient software architecture. This article only provides an introduction to the concept and helps readers with useful links.&lt;/p&gt;

&lt;p&gt;Following are some of the useful resources that give more indepth information.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://aws.amazon.com/about-aws/whats-new/2021/12/amazon-sqs-dead-letter-queue-management-experience-queues/" rel="noopener noreferrer"&gt;https://aws.amazon.com/about-aws/whats-new/2021/12/amazon-sqs-dead-letter-queue-management-experience-queues/&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/antirez/disque#dead-letter-queue" rel="noopener noreferrer"&gt;https://github.com/antirez/disque#dead-letter-queue&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://stackoverflow.com/questions/13824879/how-to-resend-from-dead-letter-queue-using-redis-mq" rel="noopener noreferrer"&gt;https://stackoverflow.com/questions/13824879/how-to-resend-from-dead-letter-queue-using-redis-mq&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I request the readers of this post to kindly add more helpful URLs in the comment section, or add your experiences using a DLQ in a real world project. I believe that would help beginners get even better idea about how to use this in their projects.&lt;/p&gt;

&lt;p&gt;Thank You&lt;br&gt;
Aniketh Deshpande&lt;/p&gt;

</description>
      <category>messagequeue</category>
      <category>systemdesign</category>
      <category>aws</category>
      <category>rabbitmq</category>
    </item>
    <item>
      <title>Change Data Capture - PostgreSQL</title>
      <dc:creator>Aniketh Deshpande</dc:creator>
      <pubDate>Sat, 22 Oct 2022 14:58:18 +0000</pubDate>
      <link>https://dev.to/anikethsdeshpande/change-data-capture-postgresql-bi8</link>
      <guid>https://dev.to/anikethsdeshpande/change-data-capture-postgresql-bi8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Change Data Capture&lt;/strong&gt; is the concept of recording the changes in the database table fields.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;It is very helpful in use cases where we want to track creation, updation, deletion of records in the table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We might want to use this information to make changes in other databases or notify customers or notify other services.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Example: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save a copy of this data in a warehouse post transform.&lt;/li&gt;
&lt;li&gt;Trigger notification service to notify users about this change.&lt;/li&gt;
&lt;li&gt;Cache the data.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  CDC In Postgres
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Using &lt;strong&gt;Notify/Listen&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY&lt;/code&gt; provides a mechanism for interprocess communication between the database and the service that is &lt;code&gt;LISTEN&lt;/code&gt;ing to this notification channel.&lt;/li&gt;
&lt;li&gt;One or more services could be listening to this notification channel.&lt;/li&gt;
&lt;li&gt;Name of this channel is usually the name of the database. However the user is free to set suitable names for these channels.&lt;/li&gt;
&lt;li&gt;Any change in the table is captured by the DB and a trigger is initiated, which calls a function that formats the message to notify.&lt;/li&gt;
&lt;li&gt;This usually contains the table name and the payload string.&lt;/li&gt;
&lt;li&gt;The listening server registers to the channel and gets the message from the DB.&lt;/li&gt;
&lt;li&gt;The service can then use this message and perform operations on it.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h4&gt;
  
  
  Pros:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Simple to implement. Use a trigger and a function to notify. Implement a listen service.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Weak reliability. There is always a risk of loss of message especially when the listening service is down. Messages in the queue do not persist.&lt;/li&gt;
&lt;/ul&gt;




&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Using &lt;strong&gt;Debezium&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debezium is an open source tool used for capturing changes in the database tables based on the WAL (write ahead log).&lt;/li&gt;
&lt;li&gt;The tool provides connectors to connect to a variety of databases.&lt;/li&gt;
&lt;li&gt;The &lt;em&gt;source connector&lt;/em&gt; is used to capture changes in the source database.&lt;/li&gt;
&lt;li&gt;The &lt;em&gt;sync connector&lt;/em&gt; is used to sync data directly in the destination database. &lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h4&gt;
  
  
  Pros:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;The changes are persistent as they can be streamed to kafka. Hence highly reliable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Debezium does not take into account changes in the schema, users need to update the schema changes. Otherwise there would be data loss.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Thank you&lt;br&gt;
Aniketh Deshpande&lt;/p&gt;

</description>
      <category>debezium</category>
      <category>postgres</category>
      <category>cdc</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
