<?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: Maithreyan</title>
    <description>The latest articles on DEV Community by Maithreyan (@maithreyan11).</description>
    <link>https://dev.to/maithreyan11</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%2F527949%2F527ae63c-412d-4463-8c47-e15a7547d511.jpeg</url>
      <title>DEV Community: Maithreyan</title>
      <link>https://dev.to/maithreyan11</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maithreyan11"/>
    <language>en</language>
    <item>
      <title>My PySpark Pipeline Was Reading the Same Table 3 Times — Here's Why</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:21:38 +0000</pubDate>
      <link>https://dev.to/maithreyan11/my-pyspark-pipeline-was-reading-the-same-table-3-times-heres-why-ppd</link>
      <guid>https://dev.to/maithreyan11/my-pyspark-pipeline-was-reading-the-same-table-3-times-heres-why-ppd</guid>
      <description>&lt;p&gt;My PySpark pipeline was slow. I assumed it needed more cluster resources. It actually needed one line of code.&lt;/p&gt;

&lt;p&gt;I had a job that read from a source table, applied a chain of transformations, and then used that same transformed DataFrame in three different places downstream: one write to storage, one aggregation for a summary table, and one validation check. Straightforward pattern. The pipeline worked, but it was taking far longer than the data volume justified.&lt;/p&gt;

&lt;p&gt;When I looked at the Spark UI, the pattern became obvious. The source table was being read multiple times, and the exact same chain of transformations was showing up again and again across the job's stages — not once, but three separate times, once for each downstream action that touched the DataFrame.&lt;/p&gt;

&lt;p&gt;This post covers why that happens, how to spot it, and the actual fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: transformations are lazy
&lt;/h2&gt;

&lt;p&gt;Spark separates DataFrame operations into two categories: &lt;strong&gt;transformations&lt;/strong&gt; and &lt;strong&gt;actions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Transformations — &lt;code&gt;.filter()&lt;/code&gt;, &lt;code&gt;.select()&lt;/code&gt;, &lt;code&gt;.join()&lt;/code&gt;, &lt;code&gt;.withColumn()&lt;/code&gt;, &lt;code&gt;.groupBy()&lt;/code&gt; — don't execute anything when you call them. They just build up a lineage: a directed acyclic graph (DAG) describing what &lt;em&gt;would&lt;/em&gt; happen if you asked Spark to actually compute the result.&lt;/p&gt;

&lt;p&gt;Actions — &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; — are what actually trigger execution. When you call an action, Spark walks the lineage graph and executes every transformation needed to produce that result.&lt;/p&gt;

&lt;p&gt;Here's the part that catches people off guard: &lt;strong&gt;every action re-triggers the entire lineage from scratch&lt;/strong&gt;, all the way back to the original data source, unless you've explicitly told Spark to remember an intermediate result. Spark doesn't automatically cache anything for you between actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looked like in practice
&lt;/h2&gt;

&lt;p&gt;Here's a simplified version of the pattern I had:&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;transformed_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;source_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;status&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;active&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;reference_df&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="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;flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;otherwise&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;transformed_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;output/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;summary_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformed_df&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;flag&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;summary_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;span class="n"&gt;invalid_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformed_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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;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 are three actions here: &lt;code&gt;.write()&lt;/code&gt;, &lt;code&gt;.show()&lt;/code&gt;, and &lt;code&gt;.count()&lt;/code&gt;. Each one is a separate trigger for Spark to evaluate the lineage of &lt;code&gt;transformed_df&lt;/code&gt;. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The source table gets read from disk three separate times.&lt;/li&gt;
&lt;li&gt;The filter, join, and withColumn transformations each run three separate times.&lt;/li&gt;
&lt;li&gt;Any shuffle involved in the join happens three separate times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this shows up as an error. The job completes successfully. It's just doing two extra passes of work that were entirely avoidable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spotting it in the Spark UI
&lt;/h2&gt;

&lt;p&gt;The giveaway is in the Spark UI's SQL / Jobs tab. If you see the same source table scan and the same sequence of transformation stages repeated multiple times across what should logically be one pipeline, that's usually a sign the same DataFrame is being recomputed for each action instead of being reused.&lt;/p&gt;

&lt;p&gt;A quick sanity check: count how many actions your pipeline calls on a given DataFrame, and compare that to how many times the source read shows up in the UI. If they match, you've found your redundant computation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: cache() or persist()
&lt;/h2&gt;

&lt;p&gt;The fix is to tell Spark to materialize and store the result of the transformation chain the first time it's computed, so subsequent actions reuse that stored result instead of recomputing everything.&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;transformed_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;source_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;status&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;active&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;reference_df&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="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;flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;otherwise&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="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;transformed_df&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;# first action materializes the cache
&lt;/span&gt;
&lt;span class="n"&gt;transformed_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;output/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;summary_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformed_df&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;flag&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;summary_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;span class="n"&gt;invalid_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformed_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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail: &lt;code&gt;.cache()&lt;/code&gt; itself is lazy too. Calling it doesn't immediately store anything — it just marks the DataFrame as something to be cached the next time an action runs. That's why I added an explicit &lt;code&gt;.count()&lt;/code&gt; right after &lt;code&gt;.cache()&lt;/code&gt;, to force the first materialization at a predictable point rather than letting it happen implicitly on whichever action runs first.&lt;/p&gt;

&lt;p&gt;After that first action, every subsequent action on &lt;code&gt;transformed_df&lt;/code&gt; reads from the cached result instead of re-reading the source table and re-running the transformation chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  cache() vs. persist()
&lt;/h2&gt;

&lt;p&gt;These aren't two different mechanisms — &lt;code&gt;cache()&lt;/code&gt; is a convenience method that calls &lt;code&gt;persist()&lt;/code&gt; with a default storage level.&lt;/p&gt;

&lt;p&gt;For DataFrames, &lt;code&gt;cache()&lt;/code&gt; is equivalent to &lt;code&gt;persist(StorageLevel.MEMORY_AND_DISK)&lt;/code&gt;. This tries to keep the cached data in executor memory, and spills to disk if it doesn't fit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;persist()&lt;/code&gt; lets you choose the storage level explicitly:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;transformed_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StorageLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEMORY_ONLY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transformed_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StorageLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEMORY_AND_DISK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transformed_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StorageLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISK_ONLY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transformed_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StorageLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEMORY_AND_DISK_SER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;MEMORY_ONLY&lt;/code&gt; if you're confident the dataset fits comfortably in memory and want the fastest possible reuse. Use &lt;code&gt;MEMORY_AND_DISK&lt;/code&gt; (or just &lt;code&gt;.cache()&lt;/code&gt;) as a safer default when you're not certain. Use serialized variants (&lt;code&gt;_SER&lt;/code&gt; suffix) if memory pressure is a concern and you're willing to trade some CPU time for a smaller memory footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned that I wish I'd known earlier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Caching isn't automatically a win.&lt;/strong&gt; Every cached DataFrame occupies executor memory. If you cache too many large DataFrames across a pipeline, you create memory pressure that can slow the job down rather than speed it up — the opposite of the intended effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it doesn't fit in memory, the benefit shrinks.&lt;/strong&gt; If a cached DataFrame is too large for available memory and has to spill to disk, the performance gain over recomputation can become marginal. It's still often better than recomputing an expensive join or shuffle from scratch, but it's not the dramatic speedup you'd get from an in-memory cache hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't forget to release it.&lt;/strong&gt; Calling &lt;code&gt;.unpersist()&lt;/code&gt; once you're done with a cached DataFrame frees up that memory for the rest of the job. Skipping this step means the memory stays reserved even after you no longer need the cached data, which can starve other operations running later in the same job.&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;transformed_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpersist&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;Cache after your transformation chain is final, not mid-chain.&lt;/strong&gt; If you cache an intermediate DataFrame and then keep adding more transformations on top of it, you may end up caching a version of the data you don't actually need cached, while the version that's genuinely reused downstream never gets the benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only cache what's actually reused.&lt;/strong&gt; If a DataFrame is used in exactly one action, caching it adds overhead (the cost of materializing and storing it) without any payoff, since there's no second computation to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple rule of thumb
&lt;/h2&gt;

&lt;p&gt;Cache a DataFrame when all of the following are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's referenced by more than one action downstream.&lt;/li&gt;
&lt;li&gt;Recomputing it is expensive — it involves a join, a wide shuffle, or a costly aggregation.&lt;/li&gt;
&lt;li&gt;It comfortably fits within your cluster's available memory, or you're deliberately choosing a disk-backed storage level as a tradeoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip caching when a DataFrame is used exactly once, when it's cheap to recompute, or when your cluster is already under memory pressure from other cached data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Spark's lazy evaluation model is a deliberate design choice — it lets Spark's optimizer look at the full chain of transformations and plan an efficient execution strategy rather than executing operations one at a time as they're written. But that same laziness means the responsibility falls on you to recognize when a DataFrame is about to be computed more than once.&lt;/p&gt;

&lt;p&gt;A pipeline that "needs a bigger cluster" is sometimes actually a pipeline doing the same work two or three times over. Throwing more compute at that problem makes the redundant work finish a bit faster — it doesn't eliminate the redundancy. Adding one &lt;code&gt;.cache()&lt;/code&gt; call in the right place fixed what more executors never would have.&lt;/p&gt;

&lt;p&gt;Have you caught a Spark job silently recomputing the same DataFrame multiple times? What tipped you off — the Spark UI, a runtime that didn't add up, or something else?&lt;/p&gt;

</description>
      <category>pyspark</category>
      <category>dataengineering</category>
      <category>apachespark</category>
      <category>databricks</category>
    </item>
    <item>
      <title>95% of My PySpark Job Finished in 4 Minutes. The Last Task Took 40. Here's Why.</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:20:56 +0000</pubDate>
      <link>https://dev.to/maithreyan11/95-of-my-pyspark-job-finished-in-4-minutes-the-last-task-took-40-heres-why-309c</link>
      <guid>https://dev.to/maithreyan11/95-of-my-pyspark-job-finished-in-4-minutes-the-last-task-took-40-heres-why-309c</guid>
      <description>&lt;p&gt;I had a PySpark job joining a large transactions table with a customer dimension table. Nothing exotic — a standard join, then an aggregation. On paper, it looked like it should scale fine across the cluster.&lt;/p&gt;

&lt;p&gt;In practice, the job would race through most of its tasks and then stall. The Spark UI told the real story: almost every task finished in a few minutes, but one or two tasks ran for over 40 minutes while their executors sat at 80–90% CPU, and the rest of the cluster sat mostly idle waiting for them to finish.&lt;/p&gt;

&lt;p&gt;This post walks through what data skew actually is, how I confirmed it was the cause, and the fix that brought the job back under control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What data skew actually is
&lt;/h2&gt;

&lt;p&gt;Data skew happens when one or a few keys hold a disproportionate share of the data. When Spark distributes work across partitions — usually via hash partitioning on a join or group-by key — all the rows for a given key land in the same partition. If one key has millions of rows and most others have a few thousand, that one partition (and the single task processing it) ends up doing far more work than every other partition combined.&lt;/p&gt;

&lt;p&gt;The result is a job where 95% of tasks look completely healthy, and the remaining 5% become the actual bottleneck. Total job time is dictated by the slowest task, not the average one — so a single skewed key can dominate your entire runtime even if it represents a tiny fraction of your total row count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I confirmed it
&lt;/h2&gt;

&lt;p&gt;The Spark UI's stage view was the first clue: a chart of task durations with almost every bar clustered together, and one or two bars stretching far beyond the rest. That pattern — uniform short tasks plus one long outlier — is close to a signature for skew.&lt;/p&gt;

&lt;p&gt;To confirm which key was responsible, I ran a simple aggregation on the join key before doing anything else:&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;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;df&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;customer_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;count&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;count&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output made it obvious: a small number of customer IDs had row counts orders of magnitude higher than the rest of the dataset. A handful of high-volume accounts were responsible for a disproportionate share of all transactions, and Spark's default hash partitioning sent every one of those rows into a single partition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: salting
&lt;/h2&gt;

&lt;p&gt;The technique that actually solved this is called &lt;strong&gt;salting&lt;/strong&gt;. The core idea:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the skewed join key and append a random "salt" value to it, splitting what was one hot key into several smaller synthetic keys.&lt;/li&gt;
&lt;li&gt;On the smaller side of the join, explode each row into one copy per possible salt value, so every salted variant of the key still has a matching row to join against.&lt;/li&gt;
&lt;li&gt;Join on the new salted key instead of the original key.&lt;/li&gt;
&lt;li&gt;Drop the salt column once the join is done, since it was only needed to spread the data across partitions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This doesn't change the result of the join — every row still matches correctly — but it changes &lt;em&gt;how the work is distributed&lt;/em&gt;. What used to be one massive partition handling all of a hot customer's rows gets split into several smaller partitions, each processed by a different executor in parallel.&lt;/p&gt;

&lt;p&gt;Here's a simplified version of what I used:&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.functions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concat_ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;

&lt;span class="n"&gt;SALT_BUCKETS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="n"&gt;large_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;large_df&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="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="n"&gt;SALT_BUCKETS&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="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;large_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;large_df&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;join_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;concat_ws&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;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;customer_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;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;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;salt_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="n"&gt;SALT_BUCKETS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;withColumnRenamed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;small_df_expanded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;small_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crossJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;small_df_expanded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;small_df_expanded&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;join_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;concat_ws&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;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;customer_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;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;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;result_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;large_df&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_df_expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drop&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;SALT_BUCKETS = 10&lt;/code&gt;, each previously-hot key gets split across 10 partitions instead of 1. The slowest task in the job dropped dramatically — no more single straggler holding up the entire stage while everything else finished and sat idle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Spark never warns you about skew.&lt;/strong&gt; There's no error, no red flag in the logs — just one task that quietly runs far longer than the rest while the cluster utilization graph looks strangely uneven. You only notice it if you're actually looking at task-level timing in the Spark UI, not just whether the job succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Salting isn't free.&lt;/strong&gt; Adding salt buckets increases the amount of data being shuffled and joined, since the smaller side of the join gets duplicated once per bucket. It's worth applying selectively to the specific hot keys causing the problem, not blindly salting an entire dataset — over-salting can add more overhead than it saves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check broadcast joins first.&lt;/strong&gt; If one side of your join is small enough to fit comfortably in executor memory (a common threshold cited is under roughly 500MB, though this depends on your cluster's &lt;code&gt;spark.sql.autoBroadcastJoinThreshold&lt;/code&gt; setting), a broadcast join avoids the shuffle — and the skew problem — entirely. It's simpler than salting and should usually be tried first if it's feasible for your data size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not all skew comes from joins.&lt;/strong&gt; The same problem shows up in &lt;code&gt;groupBy&lt;/code&gt; aggregations, &lt;code&gt;window&lt;/code&gt; functions partitioned by a skewed column, and even repartitioning by a skewed key. The detection method (checking row counts per key) and the fix (salting, or in some cases using Spark's adaptive query execution skew join optimization) generalize beyond just joins.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick detection checklist
&lt;/h2&gt;

&lt;p&gt;If your Spark job "succeeds" but feels slower than it should be for the data volume involved, this is a fast way to check for skew before assuming the cluster just needs more resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the Spark UI and look at the stage's task duration distribution — a long tail with one or two extreme outliers is the classic signature.&lt;/li&gt;
&lt;li&gt;Run a &lt;code&gt;groupBy(key).count().orderBy(desc("count"))&lt;/code&gt; on your join or aggregation key and look for one or a few values that dominate the row count.&lt;/li&gt;
&lt;li&gt;Check executor CPU utilization during the slow stage — if most executors are idle while one or two are pegged, that's a strong secondary signal.&lt;/li&gt;
&lt;li&gt;If confirmed, evaluate a broadcast join first if feasible, and fall back to salting for genuinely large-to-large joins where broadcasting isn't an option.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;"My job finished" and "my job finished efficiently" are two very different claims. A completed Spark job can still be hiding a massive inefficiency in plain sight, and the only way to catch it is to look past the green checkmark and into task-level timing.&lt;/p&gt;

&lt;p&gt;Adding more cluster resources would not have fixed this — the bottleneck wasn't total compute capacity, it was that all the work for the hottest keys was funneled into a single partition no matter how many executors were available. Understanding &lt;em&gt;why&lt;/em&gt; a job is slow matters more than just throwing more hardware at it.&lt;/p&gt;

&lt;p&gt;Have you run into a skewed join in production? What tipped you off — the Spark UI, a cost spike, or something else?&lt;/p&gt;

</description>
      <category>pyspark</category>
      <category>dataengineering</category>
      <category>apachespark</category>
      <category>databricks</category>
    </item>
    <item>
      <title>Why Apache Airflow Instead of Cron? A Deep Dive Into How Airflow Actually Schedules Your DAGs</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Wed, 12 Aug 2026 00:07:25 +0000</pubDate>
      <link>https://dev.to/maithreyan11/why-apache-airflow-instead-of-cron-a-deep-dive-into-how-airflow-actually-schedules-your-dags-38np</link>
      <guid>https://dev.to/maithreyan11/why-apache-airflow-instead-of-cron-a-deep-dive-into-how-airflow-actually-schedules-your-dags-38np</guid>
      <description>&lt;p&gt;"Why not just use a cron job?" is the first question I get whenever someone sees an Airflow DAG. Fair question. Cron works. It's been around for decades. It's simple.&lt;/p&gt;

&lt;p&gt;The real answer isn't that cron is bad — it's that cron solves a different problem than Airflow does.&lt;/p&gt;

&lt;p&gt;Cron is a &lt;strong&gt;job scheduler&lt;/strong&gt;. It runs a command at a fixed time. That's it. It doesn't know whether the command succeeded, whether its dependencies are satisfied, or whether it should even run at all today. It just fires the command and moves on.&lt;/p&gt;

&lt;p&gt;Airflow is a &lt;strong&gt;workflow orchestrator&lt;/strong&gt;. It doesn't just schedule tasks — it models them as a graph of dependencies, tracks their state, retries failed ones, and gives you a UI to see what ran, what failed, and why.&lt;/p&gt;

&lt;p&gt;Here's where that difference actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem cron can't solve
&lt;/h2&gt;

&lt;p&gt;Imagine a simple ETL pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract raw data from an API&lt;/li&gt;
&lt;li&gt;Validate and clean it&lt;/li&gt;
&lt;li&gt;Load into a warehouse&lt;/li&gt;
&lt;li&gt;Run a transformation&lt;/li&gt;
&lt;li&gt;Send a Slack alert if anything fails&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With cron, you'd write five separate cron entries, one per step, and hope the timing works out. If step 2 fails but step 3 runs anyway, you now have bad data in your warehouse. If step 4 takes twice as long one day, you've silently broken your SLA. Nobody gets notified unless you manually add alerting logic to every script.&lt;/p&gt;

&lt;p&gt;With Airflow, you model this as a DAG:&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;airflow&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DAG&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.operators.python&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PythonOperator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dag_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily_etl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 6 * * *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;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;catchup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

    &lt;span class="n"&gt;extract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;extract_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;validate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;validate_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;load_to_warehouse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_transformation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;extract&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Airflow guarantees the order. If &lt;code&gt;validate&lt;/code&gt; fails, &lt;code&gt;load&lt;/code&gt; and &lt;code&gt;transform&lt;/code&gt; never run. You get automatic retries, failure alerts, and a web UI that shows exactly where the pipeline broke and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Airflow actually schedules a DAG
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Airflow doesn't just "run your Python script at a fixed time" the way cron does.&lt;/p&gt;

&lt;p&gt;When you define a DAG with a &lt;code&gt;schedule&lt;/code&gt; (or &lt;code&gt;schedule_interval&lt;/code&gt; in older versions), Airflow doesn't pass that cron expression directly to the OS scheduler. Instead, it converts it into a &lt;strong&gt;timetable&lt;/strong&gt; — an internal object that determines when a DAG run should be created.&lt;/p&gt;

&lt;p&gt;The scheduler process runs continuously, checking every few seconds whether any DAGs are ready to run based on their timetable. When a DAG is due, the scheduler creates a &lt;strong&gt;DagRun&lt;/strong&gt; object for that execution date and queues up the tasks. The actual execution happens on worker processes (via the executor you've configured — Local, Celery, or Kubernetes), not directly from the scheduler itself.&lt;/p&gt;

&lt;p&gt;This matters for two reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Airflow schedules based on data intervals, not wall-clock time.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A DAG with &lt;code&gt;schedule="0 6 * * *"&lt;/code&gt; (daily at 6 AM) doesn't run at 6 AM to process data &lt;em&gt;at that moment&lt;/em&gt;. It runs at 6 AM to process data for the &lt;em&gt;previous&lt;/em&gt; interval — typically yesterday, if you're on a daily schedule. Airflow's &lt;code&gt;execution_date&lt;/code&gt; is the start of the data interval, not the time the task actually runs.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;catchup=True&lt;/code&gt; (the default in older Airflow versions) can surprise you: if you deploy a new daily DAG on January 10th with a start date of January 1st, Airflow will immediately create DagRuns for every day from Jan 1–9 and try to backfill them all, because it thinks you're behind on processing those intervals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The scheduler is stateful and centralized.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Unlike cron, which runs independently on each machine, Airflow's scheduler is a single process (or a small cluster in HA setups) that maintains a global view of all DAGs, their schedules, and their current state. It knows which tasks are running, which are queued, which have failed, and which are blocked by upstream dependencies. This is what enables features like automatic retries, SLA monitoring, and the ability to pause or unpause a DAG from the UI without touching the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters in production
&lt;/h2&gt;

&lt;p&gt;The real difference isn't features — it's what happens when things go wrong.&lt;/p&gt;

&lt;p&gt;Cron jobs fail silently. Logs are scattered across servers. Backfilling a missed run means manually re-running scripts in the right order. Scaling to 50+ pipelines means managing hundreds of crontab entries across multiple machines, with no central visibility into what's running or what's broken.&lt;/p&gt;

&lt;p&gt;Airflow tracks everything — task state, execution history, retry counts, SLAs. You can backfill a date range with one command. You can see which tasks are blocking others. You can add sensors that wait for external data to arrive before starting a downstream task. None of this exists in cron; you'd have to build it yourself, and you'd build it worse than Airflow already has.&lt;/p&gt;

&lt;p&gt;I've seen this play out directly: a team running 30+ cron-based ETL scripts had no idea when a critical pipeline silently failed for three days because the script exited with a 0 status code even though the data was stale. Moving to Airflow meant that same pipeline would have automatically retried, alerted on failure, and shown up in red on a dashboard — impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  When cron is actually the right choice
&lt;/h2&gt;

&lt;p&gt;I'm not saying "never use cron." Cron is perfect for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Simple, independent tasks (daily backups, log rotation, health checks)&lt;/li&gt;
&lt;li&gt;Small-scale automation (fewer than ~5 scripts, no dependencies between them)&lt;/li&gt;
&lt;li&gt;Situations where silent failure is acceptable or you have other monitoring in place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your pipeline is "run this one script at 2 AM and that's it," cron is fine. If your pipeline has dependencies, retries, alerts, or cross-team visibility needs, Airflow pays for itself quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Cron tells a task &lt;strong&gt;when&lt;/strong&gt; to run. Airflow decides &lt;strong&gt;what&lt;/strong&gt; should run, &lt;strong&gt;in which order&lt;/strong&gt;, and &lt;strong&gt;what happens if something fails&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That difference becomes critical the moment your workflows grow beyond "one script, one schedule."&lt;/p&gt;

&lt;p&gt;Do you still use cron for anything in production, or has everything moved to an orchestrator?&lt;/p&gt;

</description>
      <category>airflow</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>etl</category>
    </item>
    <item>
      <title>How SQL Actually Works Under the Hood — A Deep Dive Into Snowflake and Redshift</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:30:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/how-sql-actually-works-under-the-hood-a-deep-dive-into-snowflake-and-redshift-2434</link>
      <guid>https://dev.to/maithreyan11/how-sql-actually-works-under-the-hood-a-deep-dive-into-snowflake-and-redshift-2434</guid>
      <description>&lt;p&gt;Most people think SQL is fast because "the database is optimized." That's true, but it's such a shallow answer it's almost useless. If you've ever had a query run fine on a 50K-row staging table and crawl on a 40M-row production table with the exact same SQL, you already know the real answer is more interesting — and more mechanical — than "the engine is smart."&lt;/p&gt;

&lt;p&gt;This post breaks down what actually happens between you hitting Run and getting your rows back, specifically on Snowflake and Redshift, since the two take genuinely different architectural approaches to solving the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your SQL is a request, not an instruction
&lt;/h2&gt;

&lt;p&gt;The first thing to internalize: SQL is declarative. You describe &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt; to get it. The engine's query optimizer is the component that decides &lt;em&gt;how&lt;/em&gt;. It parses your query, checks statistics about your tables (row counts, value distributions, min/max ranges per partition), and evaluates multiple possible execution plans before picking the one it estimates will cost the least.&lt;/p&gt;

&lt;p&gt;This is why identical SQL can perform completely differently depending on data size, indexing, or even how recently statistics were refreshed. The query text never changes; the plan behind it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snowflake's architecture: separating storage, compute, and metadata
&lt;/h2&gt;

&lt;p&gt;Snowflake splits itself into three distinct layers, and understanding this split explains almost everything about why it behaves the way it does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storage layer&lt;/strong&gt; — your data sits in cloud object storage (S3, Azure Blob, or GCS) as compressed, columnar &lt;strong&gt;micro-partitions&lt;/strong&gt;, typically 50–500MB of uncompressed data each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute layer&lt;/strong&gt; — "virtual warehouses" are independent MPP compute clusters you spin up to run queries. Multiple warehouses can query the same underlying data simultaneously without contending for resources, because compute is fully decoupled from storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services layer&lt;/strong&gt; — this is the part people forget about. It manages metadata, security, and — critically — the optimizer itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why Snowflake queries can be fast without indexes at all
&lt;/h3&gt;

&lt;p&gt;Snowflake doesn't use traditional indexes. Instead, every micro-partition carries metadata about the min/max values of each column stored inside it. When you filter with a &lt;code&gt;WHERE&lt;/code&gt; clause, Snowflake's optimizer uses this metadata to skip entire micro-partitions that can't possibly contain matching rows — a technique called &lt;strong&gt;partition pruning&lt;/strong&gt;. If your table is well-clustered on the columns you filter by often, this can mean scanning a tiny fraction of the actual data on disk, even on a table with billions of rows.&lt;/p&gt;

&lt;p&gt;Snowflake also processes data using &lt;strong&gt;vectorized execution&lt;/strong&gt; — operating on batches of column values at once rather than row-by-row — and worker nodes exchange data directly during joins, avoiding some of the shuffle-heavy overhead that plagues naive distributed joins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The catch
&lt;/h3&gt;

&lt;p&gt;None of this is free if your data isn't organized well. If a table isn't clustered on the columns you actually filter by, pruning doesn't help much, and Snowflake falls back to scanning far more micro-partitions than necessary — which shows up directly in your compute cost, since Snowflake bills by warehouse runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redshift's architecture: a leader node and a fleet of workers
&lt;/h2&gt;

&lt;p&gt;Redshift takes a more classically MPP (massively parallel processing) approach, structured around actual physical clusters rather than fully decoupled layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leader node&lt;/strong&gt; — receives your query, parses it, builds the execution plan, and compiles the plan into executable code. It also coordinates communication with your SQL client and handles a small set of functions that run exclusively on the leader node itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute nodes&lt;/strong&gt; — each stores a slice of your table's data and executes its portion of the plan in parallel. Results get sent back to the leader node for final aggregation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why distribution keys and sort keys matter so much
&lt;/h3&gt;

&lt;p&gt;Because Redshift's compute nodes each own a physical slice of the data, &lt;em&gt;how that data is distributed across nodes&lt;/em&gt; directly determines whether a join can run locally on each node or requires shuffling rows across the network first. If you join two tables on a column that isn't the distribution key, Redshift has to redistribute rows across nodes to align matching keys before the join can proceed — this is often the single biggest hidden cost in a slow Redshift query.&lt;/p&gt;

&lt;p&gt;Sort keys work similarly to Snowflake's micro-partition metadata: Redshift tracks the min/max range of sorted columns per block, and can skip blocks that fall outside your filter range entirely — this is Redshift's version of partition pruning, and it depends entirely on your table being sorted on the columns you commonly filter by.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compiled code, not interpreted SQL
&lt;/h3&gt;

&lt;p&gt;One detail that surprises people: Redshift doesn't re-interpret your SQL text every time you run a query. The leader node &lt;strong&gt;compiles&lt;/strong&gt; the execution plan into actual executable code tailored to your specific query and schema, and that compiled code can be reused for repeated executions of similar queries — meaning stable, frequently-run queries genuinely get faster the more they're run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two architectures actually diverge in practice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Snowflake&lt;/th&gt;
&lt;th&gt;Redshift&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compute/storage&lt;/td&gt;
&lt;td&gt;Fully decoupled, independently scalable&lt;/td&gt;
&lt;td&gt;Coupled to node/cluster you provision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data organization&lt;/td&gt;
&lt;td&gt;Automatic micro-partitions + clustering&lt;/td&gt;
&lt;td&gt;Manual/auto distribution keys + sort keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pruning mechanism&lt;/td&gt;
&lt;td&gt;Micro-partition min/max metadata&lt;/td&gt;
&lt;td&gt;Sort key block ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join cost driver&lt;/td&gt;
&lt;td&gt;Data co-location via clustering&lt;/td&gt;
&lt;td&gt;Distribution key alignment across nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency model&lt;/td&gt;
&lt;td&gt;Multiple independent virtual warehouses&lt;/td&gt;
&lt;td&gt;Shared cluster, workload manager (WLM) queues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query execution&lt;/td&gt;
&lt;td&gt;Vectorized, direct worker-to-worker exchange&lt;/td&gt;
&lt;td&gt;Compiled code per query, reused on repeat runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The part that actually matters for you as an engineer
&lt;/h2&gt;

&lt;p&gt;Neither engine's speed comes from magic — it comes from &lt;strong&gt;giving the optimizer good conditions to work with&lt;/strong&gt;. On Snowflake, that means understanding clustering and not fighting it with poorly organized load patterns. On Redshift, that means deliberately choosing distribution and sort keys that match your actual join and filter patterns, not just letting &lt;code&gt;DISTSTYLE AUTO&lt;/code&gt; guess forever.&lt;/p&gt;

&lt;p&gt;I've seen this play out directly: a query that ran in seconds on a small staging table took over 4 minutes in production. The SQL hadn't changed. What had changed was that the join column in the production table didn't match the distribution key, so Redshift redistributed rows across every node before it could even start the join — pure network and shuffle overhead, invisible in the query text itself. Aligning the distribution key to the join column brought it back down to seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;SQL performance isn't about clever syntax. It's a systems problem — the optimizer, the storage layout, the distribution of data across compute, and the statistics the engine has about your tables all matter more than how you phrase your &lt;code&gt;SELECT&lt;/code&gt;. Writing fast SQL means understanding what your specific engine needs to make a good decision, and then giving it that, deliberately, instead of hoping the optimizer figures it out on its own.&lt;/p&gt;

&lt;p&gt;Once you start thinking in terms of "what will the optimizer actually do with this," you stop debugging queries by rewriting SQL syntax and start debugging them by checking &lt;code&gt;EXPLAIN&lt;/code&gt;, clustering keys, and distribution keys — which is a completely different (and much more effective) way to work.&lt;/p&gt;

&lt;p&gt;What's the deepest "it wasn't the SQL, it was the architecture" bug you've run into on Snowflake or Redshift?&lt;/p&gt;

</description>
      <category>sql</category>
      <category>dataengineering</category>
      <category>snowflake</category>
      <category>redshift</category>
    </item>
    <item>
      <title>How I Stopped Recalculating the Same MAX(date) Subquery on Redshift</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/how-i-stopped-recalculating-the-same-maxdate-subquery-on-redshift-j50</link>
      <guid>https://dev.to/maithreyan11/how-i-stopped-recalculating-the-same-maxdate-subquery-on-redshift-j50</guid>
      <description>&lt;p&gt;A subquery that looked perfectly fine on its own was quietly running dozens of times a day across our Redshift pipeline. Here's the story of how I found it, why it hurt specifically on Redshift, and the fix that made it a non-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I had a query that needed the most recent record for a given entity — a fairly common pattern:&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="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt; &lt;span class="o"&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;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&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&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&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 a &lt;strong&gt;correlated subquery&lt;/strong&gt; — the inner &lt;code&gt;SELECT&lt;/code&gt; references the outer query's row (&lt;code&gt;e2.entity_id = events.entity_id&lt;/code&gt;), so it can't just run once. It worked, it was correct, and it was fast enough on its own. I moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it went wrong
&lt;/h2&gt;

&lt;p&gt;The problem wasn't this one query — it was that the same "latest record per entity" logic kept showing up downstream: a dashboard filter, a data quality check, a reconciliation job, a couple of reports. Each of those had its own copy of a similar &lt;code&gt;MAX(date)&lt;/code&gt; subquery.&lt;/p&gt;

&lt;p&gt;Individually, every query looked fine in isolation. Nobody flagged it in review because each query's runtime on its own was acceptable. The real cost only showed up in aggregate — the same underlying calculation was being recomputed from scratch every time something needed it, hitting the same table repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hurts more on Redshift specifically
&lt;/h2&gt;

&lt;p&gt;This is where Redshift's architecture makes the problem worse than it might be elsewhere. Redshift is a columnar, MPP (massively parallel processing) engine, and correlated subqueries interact badly with that design for a few specific reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited parallelism.&lt;/strong&gt; Because a correlated subquery is logically re-evaluated per outer row, it constrains how much the query can be parallelized across compute nodes — you lose some of the benefit of Redshift's distributed architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-optimal query plans.&lt;/strong&gt; Redshift's optimizer has documented issues generating efficient plans for correlated subqueries, particularly with &lt;code&gt;EXISTS&lt;/code&gt; / &lt;code&gt;NOT EXISTS&lt;/code&gt; patterns — sometimes producing nested loop joins, which are the slowest join type Redshift supports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data movement across nodes.&lt;/strong&gt; If the subquery's join key isn't the table's distribution key, Redshift has to redistribute rows across the cluster to evaluate the correlation, adding network I/O on top of the recomputation cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS's own query design guidance is explicit about this: use a &lt;code&gt;CASE&lt;/code&gt; expression for complex aggregations instead of scanning the same table multiple times, and prefer subqueries only when they return a small result set (under roughly 200 rows) used purely as a filter — not as a repeated per-row calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Instead of repeating the subquery everywhere it was needed, I computed the max date once and joined it back to the base table as an extra 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;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
&lt;span class="k"&gt;JOIN&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;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&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;max_date&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&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;entity_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things mattered for making this actually fast on Redshift, not just "correct":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The join key matches the table's distribution key.&lt;/strong&gt; When you join on the distribution key, Redshift can complete the join on each node in parallel without shuffling rows across the cluster. If &lt;code&gt;entity_id&lt;/code&gt; isn't your distribution key, this join can still trigger a redistribution step — worth checking with &lt;code&gt;EXPLAIN&lt;/code&gt; before assuming it's free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The result gets reused, not recalculated.&lt;/strong&gt; Now &lt;code&gt;max_date&lt;/code&gt; is a column that lives on the row. Any downstream query, dashboard filter, or reconciliation check just reads that column and compares (&lt;code&gt;event_date = max_date&lt;/code&gt;) instead of running its own version of the subquery.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  An alternative: window functions
&lt;/h2&gt;

&lt;p&gt;A window function version is also worth considering on Redshift, especially if you want to flag &lt;em&gt;which&lt;/em&gt; row is the latest, not just know the date:&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="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="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&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;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_id&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;max_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROW_NUMBER&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;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_id&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;event_date&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;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&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;ranked&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids a self-join entirely and often performs better than either the correlated subquery or the join approach on large tables, since Redshift can compute it as a single sort-and-scan rather than a join. Redshift performance guides specifically recommend window functions over self-joins as a general optimization pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Materializing beyond a single query
&lt;/h2&gt;

&lt;p&gt;If this value needs to be read very frequently across many downstream consumers, you have a few options on Redshift specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A CTE&lt;/strong&gt; if it's scoped to a single pipeline run — just be aware that Redshift sometimes rewrites &lt;code&gt;WITH&lt;/code&gt; clauses into temporary &lt;code&gt;volt_tt&lt;/code&gt; tables internally, which can add overhead on complex CTEs. Simpler joins or window functions sometimes outperform an equivalent CTE for this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A materialized view&lt;/strong&gt; if the underlying data doesn't change constantly — Redshift materialized views precompute the result set, which is ideal for a value like "max date per entity" that many queries read but few queries update. Note that Redshift disables automatic materialized view refresh by default (citing planning-time overhead), so you'd trigger a manual refresh as the last step of your ETL job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A physical column updated via your ETL job&lt;/strong&gt; if this needs extremely frequent reads and refresh timing is predictable — this trades some storage and write complexity for guaranteed fast reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Correlated subqueries repeated across multiple queries multiply their cost silently — nothing looks wrong until you count the total load, and Redshift's MPP architecture makes that cost worse due to limited parallelism and potential data redistribution&lt;/li&gt;
&lt;li&gt;Precomputing shared logic once — via a join on the distribution key, a window function, or a materialized view — turns N recalculations into one&lt;/li&gt;
&lt;li&gt;Always check the query plan with &lt;code&gt;EXPLAIN&lt;/code&gt; when in doubt. Don't assume a subquery, join, or window function is faster without testing against your actual table's distribution and sort keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you run into logic that looked fine solo but got expensive once it was reused across a Redshift pipeline? Curious how others have handled this.&lt;/p&gt;

</description>
      <category>redshift</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>aws</category>
    </item>
    <item>
      <title>Why CTE vs Subquery Matters More on Redshift Than Anywhere Else</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/why-cte-vs-subquery-matters-more-on-redshift-than-anywhere-else-llm</link>
      <guid>https://dev.to/maithreyan11/why-cte-vs-subquery-matters-more-on-redshift-than-anywhere-else-llm</guid>
      <description>&lt;p&gt;On Snowflake or modern Postgres, CTE vs subquery barely matters for performance. On Redshift, it can matter a lot — and I learned this the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption I carried over
&lt;/h2&gt;

&lt;p&gt;I carried an assumption over from Snowflake: that the optimizer would treat a CTE and an equivalent subquery the same way. Redshift doesn't play by those rules. Redshift's query planner is derived from an older Postgres lineage, and it historically doesn't inline CTEs the way newer engines do — it can materialize them as a separate step before the outer query even runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters on an MPP engine
&lt;/h2&gt;

&lt;p&gt;This matters a lot on Redshift specifically because it's a columnar, MPP (massively parallel processing) engine. Materializing a CTE means writing an intermediate result set across the cluster's compute nodes before the next step even starts. On a small CTE, that's harmless. On a CTE scanning millions of rows from a big fact table, that's a real cost — extra I/O and redistribution across nodes that a well-written subquery or a join might avoid entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;I hit this on a Redshift ETL query pulling from a large events table. I had it as a CTE, referenced once in the outer query, nothing complex. When I flattened it into a subquery instead, the query planner pushed filters down earlier and pruned way more data before the expensive join — noticeably faster on that specific table.&lt;/p&gt;

&lt;h2&gt;
  
  
  My rule of thumb for Redshift now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a subquery when it's referenced once and simple — let the planner push predicates down early&lt;/li&gt;
&lt;li&gt;Use a CTE mainly for readability on complex multi-step logic, but check &lt;code&gt;EXPLAIN&lt;/code&gt; before trusting it won't materialize unnecessarily&lt;/li&gt;
&lt;li&gt;Always check the query plan (&lt;code&gt;EXPLAIN&lt;/code&gt;) rather than assuming CTE behavior — Redshift doesn't guarantee the same optimizations as Snowflake or Postgres 12+&lt;/li&gt;
&lt;li&gt;For genuinely reused logic, a temp table often beats both if the CTE is being scanned multiple times&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Patterns that are "safe defaults" on one warehouse can be real performance traps on another. Always validate with &lt;code&gt;EXPLAIN&lt;/code&gt; before assuming.&lt;/p&gt;

&lt;p&gt;Anyone else been burned by carrying optimizer assumptions across warehouses?&lt;/p&gt;

</description>
      <category>redshift</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
