<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Spark]]></title><description><![CDATA[Spark]]></description><link>https://thanh-de.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Spark</title><link>https://thanh-de.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 08:39:06 GMT</lastBuildDate><atom:link href="https://thanh-de.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I spent 6 hours studying PySpark join strategies. Here's what I learned]]></title><description><![CDATA[match keys between two tables and boom, you get results. That mindset worked fine in SQL databases. Then I started working with Spark on large datasets and my jobs started failing, timing out, or grinding for hours.
The reality: Spark join performanc...]]></description><link>https://thanh-de.hashnode.dev/i-spent-6-hours-studying-pyspark-join-strategies-heres-what-i-learned</link><guid isPermaLink="true">https://thanh-de.hashnode.dev/i-spent-6-hours-studying-pyspark-join-strategies-heres-what-i-learned</guid><category><![CDATA[apache]]></category><category><![CDATA[data]]></category><category><![CDATA[performance]]></category><category><![CDATA[PySpark]]></category><category><![CDATA[software]]></category><dc:creator><![CDATA[Trung Thành]]></dc:creator><pubDate>Wed, 06 May 2026 08:46:09 GMT</pubDate><content:encoded><![CDATA[<p>match keys between two tables and boom, you get results. That mindset worked fine in SQL databases. Then I started working with Spark on large datasets and my jobs started failing, timing out, or grinding for hours.</p>
<p>The reality: Spark join performance isn't about the join syntax. It's about which <strong>join strategy</strong> Spark chooses to execute your join. Understanding these strategies — and knowing how to influence them — is the difference between a 5-minute job and a 5-hour nightmare.</p>
<p>After 6 hours of reading documentation, running experiments, and debugging slow jobs, here's everything I learned about PySpark join strategies.</p>
<h3 id="heading-the-problem-space">The Problem Space</h3>
<p>In Spark, a join isn't a single operation. It's a multi-stage process that involves:</p>
<ol>
<li><strong>Reading</strong> data from sources</li>
<li><strong>Matching</strong> rows based on join keys</li>
<li><strong>Combining</strong> matched rows into output</li>
</ol>
<p>The critical variable is <strong>how</strong> Spark matches rows. This is where join strategies come in. The wrong strategy can cause:</p>
<ul>
<li><strong>Excessive data movement</strong> — shuffling terabytes across the network</li>
<li><strong>Out-of-memory errors</strong> — trying to hash massive datasets</li>
<li><strong>Skew issues</strong> — one partition becoming a bottleneck</li>
</ul>
<p>Spark has four main join strategies. Let me walk through each one.</p>
<h3 id="heading-broadcast-join-broadcast-hash-join">Broadcast Join (Broadcast Hash Join)</h3>
<p>The simplest and often fastest strategy: <strong>send the smaller table to all executors</strong>.</p>
<p>Here's how it works: Spark detects that one table is small enough to fit in memory on each executor. Instead of shuffling both tables, it broadcasts a full copy of the small table to every executor. Each executor then performs a local hash lookup against its partition of the large table.</p>
<p>This completely <strong>eliminates the shuffle</strong> of the large table.</p>
<pre><code># Force broadcast join
df1.join(df2, <span class="hljs-string">"key"</span>, <span class="hljs-string">"inner"</span>).hint(<span class="hljs-string">"broadcast"</span>)
</code></pre><p>Or let Spark auto-decide:</p>
<pre><code># Spark auto-broadcasts when smaller table &lt; <span class="hljs-number">10</span>MB (configurable)
df1.join(df2, <span class="hljs-string">"key"</span>)
</code></pre><h4 id="heading-when-it-works-best">When It Works Best</h4>
<ul>
<li>One table is small (typically &lt; 10MB, configurable via <code>spark.sql.autoBroadcastJoinThreshold</code>)</li>
<li>Dimension tables — small lookup tables joined against large fact tables</li>
<li>The classic star schema pattern</li>
</ul>
<h4 id="heading-when-it-breaks">When It Breaks</h4>
<ul>
<li>Both tables are large — you can't fit either in memory</li>
<li>The "small" table has high cardinality — memory pressure explodes</li>
<li>Broadcast is disabled or threshold is too low</li>
</ul>
<h3 id="heading-shuffle-hash-join">Shuffle Hash Join</h3>
<p>When both tables are too large to broadcast, Spark falls back to <strong>shuffle hash join</strong>.</p>
<p>Here's what happens:</p>
<ol>
<li>Both tables are <strong>shuffled</strong> by join key — rows with the same key end up on the same partition</li>
<li>Each executor builds a <strong>hash table</strong> from its partition of one table</li>
<li>Then it probes that hash table with rows from the other table's partition</li>
</ol>
<p>This is a two-phase shuffle + build approach. It's slower than broadcast because it actually shuffles data.</p>
<pre><code># Disable broadcast to force shuffle hash
spark.conf.set(<span class="hljs-string">"spark.sql.autoBroadcastJoinThreshold"</span>, <span class="hljs-number">0</span>)
df1.join(df2, <span class="hljs-string">"key"</span>)
</code></pre><h4 id="heading-when-it-works-best-1">When It Works Best</h4>
<ul>
<li>Medium-sized tables that are too big to broadcast</li>
<li>Both tables can fit in memory after shuffle (each executor needs to hold its partition)</li>
</ul>
<h4 id="heading-when-it-breaks-1">When It Breaks</h4>
<ul>
<li>One table has extreme skew — some partitions become too large to hash</li>
<li>Both tables are massive — memory pressure on each executor</li>
</ul>
<h3 id="heading-shuffle-sort-merge-join">Shuffle Sort Merge Join</h3>
<p>The most robust strategy for large tables: <strong>shuffle, sort, then merge</strong>.</p>
<p>This is the default for large joins in Spark 3.0+. Here's the process:</p>
<ol>
<li><strong>Shuffle</strong> — partition both tables by join key</li>
<li><strong>Sort</strong> — each partition sorts its rows by join key</li>
<li><strong>Merge</strong> — iterate through both sorted partitions simultaneously, matching keys</li>
</ol>
<p>The advantage: it's <strong>memory-efficient</strong>. Instead of building a hash table, you stream through sorted data. No random memory access, no explosion from skew.</p>
<pre><code># Explicitly request sort merge
df1.join(df2, <span class="hljs-string">"key"</span>, <span class="hljs-string">"inner"</span>).hint(<span class="hljs-string">"shuffle_sort"</span>)
</code></pre><h4 id="heading-when-it-works-best-2">When It Works Best</h4>
<ul>
<li>Very large tables</li>
<li>Data is already sorted (or can be sorted efficiently)</li>
<li>The default fallback when broadcast and shuffle hash aren't viable</li>
</ul>
<h4 id="heading-when-it-breaks-2">When It Breaks</h4>
<ul>
<li>Sorting overhead on unsorted data</li>
<li>Skew still causes issues in the merge phase</li>
</ul>
<h3 id="heading-broadcast-nested-loop-join">Broadcast Nested Loop Join</h3>
<p>The last resort. When Spark can't use any of the above strategies, it falls back to <strong>broadcast nested loop join</strong> — essentially a cross join with filtering.</p>
<pre><code># Force nested loop (rarely what you want)
df1.join(df2, <span class="hljs-string">"key"</span>, <span class="hljs-string">"inner"</span>).hint(<span class="hljs-string">"broadcast"</span>, <span class="hljs-string">"inner"</span>)
</code></pre><p>This is <strong>slow</strong>. It broadcasts one table and does a nested loop over the other. Time complexity is O(n × m). Only use this when you have no better option.</p>
<h3 id="heading-how-spark-chooses-a-strategy">How Spark Chooses a Strategy</h3>
<p>Spark's decision tree looks roughly like this:</p>
<pre><code><span class="hljs-number">1.</span> Is autoBroadcastJoinThreshold hit?
   → YES: Use Broadcast Hash Join
   → NO: Continue

<span class="hljs-number">2.</span> Can we build a hash table <span class="hljs-keyword">from</span> the smaller side <span class="hljs-keyword">in</span> memory?
   → YES: Use Shuffle Hash Join
   → NO: Continue

<span class="hljs-number">3.</span> Default to Shuffle Sort Merge Join
</code></pre><p>You can influence this with:</p>
<ul>
<li><strong>Hints</strong>: <code>.hint("broadcast")</code>, <code>.hint("shuffle_hash")</code>, <code>.hint("shuffle_sort")</code></li>
<li><strong>Config</strong>: <code>spark.sql.autoBroadcastJoinThreshold</code> (default 10MB)</li>
<li><strong>Join type</strong>: <code>broadcast</code> join in SQL</li>
</ul>
<h3 id="heading-practical-examples">Practical Examples</h3>
<h4 id="heading-example-1-small-dimension-table-join">Example 1: Small dimension table join</h4>
<pre><code># User dimension (small) + fact table (large)
users_df = spark.read.parquet(<span class="hljs-string">"s3://data/users_dim.parquet"</span>)  # <span class="hljs-number">50</span>MB
fact_df = spark.read.parquet(<span class="hljs-string">"s3://data/fact_table.parquet"</span>)  # <span class="hljs-number">100</span>GB

# Spark auto-broadcasts users_df
result = fact_df.join(users_df, <span class="hljs-string">"user_id"</span>)
</code></pre><h4 id="heading-example-2-two-large-tables">Example 2: Two large tables</h4>
<pre><code># Both tables are <span class="hljs-number">50</span>GB — broadcast won<span class="hljs-string">'t work
df1 = spark.read.parquet("s3://data/table1.parquet")
df2 = spark.read.parquet("s3://data/table2.parquet")

# Force sort merge (the default, but explicit)
result = df1.join(df2, "id", "inner").hint("shuffle_sort")</span>
</code></pre><h4 id="heading-example-3-skewed-join-key">Example 3: Skewed join key</h4>
<pre><code># One key has <span class="hljs-number">90</span>% <span class="hljs-keyword">of</span> the data — hash join will choke
df1.join(df2, <span class="hljs-string">"region"</span>).hint(<span class="hljs-string">"shuffle_sort"</span>)  # Better <span class="hljs-keyword">for</span> skew
</code></pre><h4 id="heading-example-4-check-whats-being-used">Example 4: Check what's being used</h4>
<pre><code>df1.join(df2, <span class="hljs-string">"key"</span>).explain()
</code></pre><p>The <code>explain()</code> output shows the chosen strategy in the physical plan.</p>
<h3 id="heading-common-mistakes">Common Mistakes</h3>
<p><strong>Mistake 1: Forgetting broadcast</strong></p>
<p>Letting Spark shuffle a 100GB table when a 50MB lookup table could be broadcast.</p>
<pre><code># Fix: explicitly hint broadcast
fact_df.join(broadcast(users_df), <span class="hljs-string">"user_id"</span>)
</code></pre><p><strong>Mistake 2: Blindly increasing broadcast threshold</strong></p>
<p>Cranking <code>autoBroadcastJoinThreshold</code> to 500MB and wondering why executors are OOMing.</p>
<p><strong>Mistake 3: Ignoring skew</strong></p>
<p>One massive partition taking down the whole job. Use <code>spark.sql.shuffle.partitions</code> and consider salting.</p>
<p><strong>Mistake 4: Not checking the plan</strong></p>
<p>Never running <code>.explain()</code> to see what strategy was actually chosen.</p>
<h3 id="heading-key-configurations">Key Configurations</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Property</td><td>Default</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><code>spark.sql.autoBroadcastJoinThreshold</code></td><td>10MB</td><td>Max size for auto-broadcast</td></tr>
<tr>
<td><code>spark.sql.shuffle.partitions</code></td><td>200</td><td>Number of shuffle partitions</td></tr>
<tr>
<td><code>spark.sql.adaptive.enabled</code></td><td>false (Spark 2.x) / true (Spark 3.x)</td><td>Enable adaptive query execution</td></tr>
<tr>
<td><code>spark.sql.adaptive.coalescePartitions.enabled</code></td><td>true</td><td>Coalesce post-shuffle partitions</td></tr>
</tbody>
</table>
</div><p>I recommend enabling <strong>AQE</strong> (Adaptive Query Execution) in Spark 3.0+ — it dynamically re-optimizes joins at runtime.</p>
<h3 id="heading-outro">Outro</h3>
<p>Here's what stuck with me after 6 hours:</p>
<ol>
<li><strong>Broadcast is king</strong> when it works — it's the only strategy with zero shuffle</li>
<li><strong>Sort merge is the safe default</strong> for large tables — memory efficient and robust</li>
<li><strong>Shuffle hash is niche</strong> — useful when you know both tables fit post-shuffle</li>
<li><strong>Check your plan</strong> — <code>.explain()</code> is your best friend</li>
<li><strong>AQE is worth enabling</strong> — Spark gets smarter at runtime</li>
</ol>
<p>The biggest win isn't picking the right strategy manually — it's understanding why Spark chooses what it chooses, then nudging it when needed.</p>
<blockquote>
<p>📊 <strong>See also:</strong> <a target="_blank" href="../diagrams/pyspark-join-strategies-flowchart.md">PySpark Join Strategies Visual Guide</a> — decision tree and quick reference</p>
</blockquote>
<h3 id="heading-references">References</h3>
<p><em>[1] Apache Spark Documentation,</em> Join Strategies (2026)</p>
<p><em>[2] Databricks Blog,</em> Understanding Spark Spark Joins (2025)</p>
<p><em>[3] Medium,</em> PySpark Join Strategies Deep Dive (2025)</p>
<p><em>[4] Spark Summit Talk,</em> Adaptive Query Execution (2024)</p>
<hr />
<p>*</p>
]]></content:encoded></item><item><title><![CDATA[I spent 8 hours learning Spark partitioning and bucketing. Here's what I discovered]]></title><description><![CDATA[s one thing I've noticed: most Spark pipelines waste 30-60% of their compute time reading data they don't need or shuffling data that could have been pre-organized.
During my recent deep-dive, I spent 8 hours learning two important optimization techn...]]></description><link>https://thanh-de.hashnode.dev/i-spent-8-hours-learning-spark-partitioning-and-bucketing-heres-what-i-discovered</link><guid isPermaLink="true">https://thanh-de.hashnode.dev/i-spent-8-hours-learning-spark-partitioning-and-bucketing-heres-what-i-discovered</guid><category><![CDATA[apache]]></category><category><![CDATA[data]]></category><category><![CDATA[performance]]></category><category><![CDATA[software]]></category><dc:creator><![CDATA[Trung Thành]]></dc:creator><pubDate>Wed, 06 May 2026 08:33:50 GMT</pubDate><content:encoded><![CDATA[<p>s one thing I've noticed: most Spark pipelines waste 30-60% of their compute time reading data they don't need or shuffling data that could have been pre-organized.</p>
<p>During my recent deep-dive, I spent 8 hours learning two important optimization techniques: partitioning and bucketing. These aren't new concepts, but they're often misunderstood or misapplied.</p>
<p>This article is everything I distilled from my learning about how partitioning and bucketing work, when to use each, and the common pitfalls to avoid.</p>
<h3 id="heading-the-problem-space">The Problem Space</h3>
<p>Before diving into the solutions, let me set the context.</p>
<p>When dealing with large datasets in Spark, the way data is stored determines how efficiently it can be processed. If data is stored without consideration for query patterns, Spark ends up reading unnecessary data or moving data across the network during shuffles.</p>
<p>This is where partitioning and bucketing come in. They solve different problems and are often used together.</p>
<blockquote>
<p><strong>Partitioning</strong> eliminates unnecessary reads by organizing data into directories based on column values.
<strong>Bucketing</strong> eliminates shuffles by pre-organizing data so joins and aggregations don't need to move data.</p>
</blockquote>
<h3 id="heading-partitioning">Partitioning</h3>
<p>Partitioning is about organizing data into directories based on column values. When Spark reads the data, it can skip entire directories that don't match the filter — this is called partition pruning.</p>
<p>How does it work? When you write a DataFrame with <code>.partitionBy("column")</code>, Spark creates subdirectories for each distinct value of that column. For example, if you partition by <code>country</code>, you'll get directories like <code>country=US/</code>, <code>country=IN/</code>, <code>country=UK/</code>, and so on.</p>
<p>The key insight: partitioning is a read-time optimization. It reduces I/O by skipping data you don't need.</p>
<h4 id="heading-when-to-use-partitioning">When to Use Partitioning</h4>
<p>Partition by columns that are frequently filtered in your queries:</p>
<ul>
<li>Time-based columns (<code>date</code>, <code>year</code>, <code>month</code>) — the most common use case</li>
<li>Geographic columns (<code>country</code>, <code>region</code>, <code>state</code>)</li>
<li>Categorical columns with low-to-medium cardinality</li>
</ul>
<h4 id="heading-common-mistakes">Common Mistakes</h4>
<p>The most frequent mistake is partitioning by high-cardinality columns like <code>user_id</code> or <code>transaction_id</code>. This creates millions of tiny directories — the "small files problem" that destroys read performance.</p>
<p>The rule of thumb: keep the number of distinct partition values under 500. If you have 10,000 unique values in a column, that's 10,000 directories, and listing them alone takes minutes.</p>
<h3 id="heading-bucketing">Bucketing</h3>
<p>Bucketing is fundamentally different from partitioning. Instead of creating directories based on column values, bucketing hash-distributes rows into a fixed number of files (buckets) based on a column value.</p>
<p>When two tables are bucketed by the same column with the same number of buckets, a join between them requires zero shuffle — Spark knows that matching values are already in the same bucket files on both sides.</p>
<p>How does it work? Spark applies <code>hash(column) % numBuckets</code> to each row. All rows with the same hash value end up in the same bucket file, and data within each bucket can be sorted.</p>
<p>The key insight: bucketing is a write-time optimization that pays off at read time. You pay the shuffle cost once when writing, then save it on every join.</p>
<h4 id="heading-the-catch">The Catch</h4>
<p>Bucketing has requirements:</p>
<ol>
<li><strong>Must use <code>saveAsTable</code></strong> — bucketing doesn't work with <code>.parquet()</code> or <code>.csv()</code> writes. Spark needs the Hive metastore to track bucket metadata.</li>
<li><strong>Both tables must match</strong> — for shuffle elimination, both tables must be bucketed by the same column with the same number of buckets.</li>
<li><strong>Read via <code>spark.table()</code></strong> — not <code>spark.read.parquet(path)</code>, otherwise Spark doesn't recognize the buckets.</li>
</ol>
<h4 id="heading-when-to-use-bucketing">When to Use Bucketing</h4>
<p>Use bucketing when:</p>
<ul>
<li>Two large tables are frequently joined on the same key</li>
<li>GroupBy aggregations on the same key are common</li>
<li>You want to eliminate shuffle entirely in these operations</li>
</ul>
<h4 id="heading-common-mistakes-1">Common Mistakes</h4>
<ul>
<li>Using <code>save()</code> instead of <code>saveAsTable()</code></li>
<li>Mismatched bucket counts between tables</li>
<li>Bucketing on highly skewed columns (one bucket becomes massive)</li>
</ul>
<h3 id="heading-combining-both">Combining Both</h3>
<p>The powerful combination: partition by low-cardinality columns for filtering, bucket by high-cardinality join keys within each partition.</p>
<pre><code>df.write.partitionBy(<span class="hljs-string">"date"</span>).bucketBy(<span class="hljs-number">42</span>, <span class="hljs-string">"user_id"</span>).sortBy(<span class="hljs-string">"user_id"</span>).saveAsTable(<span class="hljs-string">"fact_table"</span>)
</code></pre><p>This gives you:</p>
<ul>
<li>Partition pruning on <code>date</code> for time-based queries</li>
<li>Shuffle-free joins on <code>user_id</code> within each partition</li>
<li>Sorted data within each bucket for sort-merge joins</li>
</ul>
<h3 id="heading-key-configurations">Key Configurations</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Property</td><td>Default</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><code>spark.sql.shuffle.partitions</code></td><td>200</td><td>Number of shuffle partitions</td></tr>
<tr>
<td><code>spark.sql.sources.bucketing.enabled</code></td><td>true</td><td>Enable bucketing</td></tr>
<tr>
<td><code>spark.sql.files.maxPartitionBytes</code></td><td>128MB</td><td>Max bytes per partition</td></tr>
</tbody>
</table>
</div><h3 id="heading-outro">Outro</h3>
<p>Above is everything I learned about Spark partitioning and bucketing.</p>
<p>The key takeaway: partitioning is for I/O reduction (skip data you don't need), bucketing is for shuffle elimination (skip data movement). They solve different problems and complement each other.</p>
<p>Use partitioning almost always for time-based data. Use bucketing when the same tables are repeatedly joined.</p>
<h3 id="heading-references">References</h3>
<p><em>[1] Darshil Parmar,</em> Partitioning &amp; Bucketing in PySpark (2026)</p>
<p><em>[2] Apache Spark Documentation,</em> Performance Tuning</p>
<p><em>[3] Sanjeeb Panda,</em> Best Practices for Bucketing in Spark (2025)</p>
<p><em>[4] DataOps Blog,</em> Apache Spark Partitioning and Bucketing (2025)</p>
<hr />
<p>*</p>
]]></content:encoded></item></channel></rss>