<?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" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Canadian Data Guy Unfiltered: Deep Dive]]></title><description><![CDATA[These are long-form blog posts in which I aim to detail everything I know on the subject.]]></description><link>https://www.canadiandataguy.com/s/deep-dive</link><image><url>https://substackcdn.com/image/fetch/$s_!n3Eg!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F30cc7753-f8fb-4300-ac7f-1806e112a06a_1024x1024.png</url><title>Canadian Data Guy Unfiltered: Deep Dive</title><link>https://www.canadiandataguy.com/s/deep-dive</link></image><generator>Substack</generator><lastBuildDate>Thu, 30 Apr 2026 04:10:04 GMT</lastBuildDate><atom:link href="https://www.canadiandataguy.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Canadian Data Guy]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[canadiandataguy@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[canadiandataguy@substack.com]]></itunes:email><itunes:name><![CDATA[Canadian Data Guy]]></itunes:name></itunes:owner><itunes:author><![CDATA[Canadian Data Guy]]></itunes:author><googleplay:owner><![CDATA[canadiandataguy@substack.com]]></googleplay:owner><googleplay:email><![CDATA[canadiandataguy@substack.com]]></googleplay:email><googleplay:author><![CDATA[Canadian Data Guy]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Stop Waiting for Connectors: Stream ANYTHING into Spark (It's 4 Functions)]]></title><description><![CDATA[Listen now | How to ingest data from any source into Apache Spark &#8212; demystified with real-world example of BlockChain Ingestion]]></description><link>https://www.canadiandataguy.com/p/stop-waiting-for-connectors-stream</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/stop-waiting-for-connectors-stream</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Mon, 03 Nov 2025 17:24:45 GMT</pubDate><enclosure url="https://api.substack.com/feed/podcast/177861173/2ee733487ca1a5ca414a57c4cede2c92.mp3" length="0" type="audio/mpeg"/><content:encoded><![CDATA[<h3>&#128161; What You&#8217;ll Learn</h3><p>By the end of this guide, you&#8217;ll understand that building a custom Spark streaming source isn&#8217;t rocket science. It&#8217;s actually a well-defined conversation between Spark and your code, with just <strong>5 key methods</strong> to implement. We&#8217;ll use a real Ethereum blockchain streaming example to show you exactly how it works.</p><h2>The Problem: You Have Data, Spark Wants It</h2><p>You&#8217;ve got data streaming in from somewhere unique &#8212; maybe it&#8217;s IoT sensors, a blockchain, a custom message queue, or an internal database. You want to process it with Spark&#8217;s powerful distributed engine, but there&#8217;s no pre-built connector. What do you do?</p><p>The good news: <strong>You can build your own custom source</strong>. The even better news: <strong>It&#8217;s simpler than you think</strong>.</p><div class="pullquote"><p><strong>Real-World Use Case:</strong> In this guide, we&#8217;ll walk through streaming Ethereum blockchain data into Spark. The same principles apply to any data source &#8212; from proprietary APIs to custom databases. The pattern is universal.</p></div><h2>The Secret: It&#8217;s Just a Conversation</h2><p>Think of building a custom Spark streaming source as a conversation between two specialists:</p><p><strong>The Two Characters in Our Story</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qNYK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qNYK!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 424w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 848w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 1272w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qNYK!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png" width="1200" height="561.2167300380228" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:369,&quot;width&quot;:789,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:187528,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!qNYK!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 424w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 848w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 1272w, https://substackcdn.com/image/fetch/$s_!qNYK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F16ac2a20-a2f0-4a5a-9210-7c50b282584e_789x369.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Spark&#8217;s job</strong> (the Project Manager) is to handle all the complex distributed computing stuff: checkpointing, fault tolerance, distributing work across a cluster, and guaranteeing exactly-once processing semantics.</p><p><strong>Your code&#8217;s job</strong> (the Data Specialist) is much simpler: answer Spark&#8217;s questions about where your data is, how to access it, and how to break it into chunks that can be processed in parallel.</p><div class="pullquote"><p><strong>&#127919; Key Insight:</strong> You don&#8217;t need to understand distributed systems, fault tolerance algorithms, or checkpoint mechanisms. You just need to implement 5 simple methods that answer Spark&#8217;s questions about your data source.</p></div><h2>The 5 Questions Spark Will Ask You</h2><p>Spark&#8217;s conversation with your code follows a predictable pattern. It asks 5 questions, and you provide straightforward answers. Let&#8217;s look at each one:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!LND2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!LND2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 424w, https://substackcdn.com/image/fetch/$s_!LND2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 848w, https://substackcdn.com/image/fetch/$s_!LND2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 1272w, https://substackcdn.com/image/fetch/$s_!LND2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!LND2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png" width="828" height="662" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:662,&quot;width&quot;:828,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:130242,&quot;alt&quot;:&quot;1initialOffset() &#8212; &#8220;Where do we start?&#8221;Spark asks: &#8220;This is a brand new query. Where should I begin reading?&#8221;You answer: {&#8221;offset&#8221;: 1000} &#8212; &#8220;Start at block 1000&#8221;2latestOffset() &#8212; &#8220;What&#8217;s the newest data?&#8221;Spark asks: &#8220;What&#8217;s the most recent data available right now?&#8221;You answer: {&#8221;offset&#8221;: 1100} &#8212; &#8220;Latest block is 1100&#8221;3partitions() &#8212; &#8220;How do we split this work?&#8221;Spark asks: &#8220;We need to process blocks 1000-1100. Break this into parallel chunks&#8221;You answer: [Partition(1000-1025), Partition(1025-1050), ...] &#8212; &#8220;4 chunks of 25 blocks&#8221;4read() &#8212; &#8220;Fetch the data!&#8221;Spark tells each worker: &#8220;Here&#8217;s your chunk. Go get the actual data&#8221;You fetch: Loop through blocks 1000-1025, fetch each one, yield Row objects5commit() &#8212; &#8220;All done!&#8221; [optional]checkpoint/commit/{N} file created. Optional method for cleanup tasks.&quot;,&quot;title&quot;:&quot;1initialOffset() &#8212; &#8220;Where do we start?&#8221;Spark asks: &#8220;This is a brand new query. Where should I begin reading?&#8221;You answer: {&#8221;offset&#8221;: 1000} &#8212; &#8220;Start at block 1000&#8221;2latestOffset() &#8212; &#8220;What&#8217;s the newest data?&#8221;Spark asks: &#8220;What&#8217;s the most recent data available right now?&#8221;You answer: {&#8221;offset&#8221;: 1100} &#8212; &#8220;Latest block is 1100&#8221;3partitions() &#8212; &#8220;How do we split this work?&#8221;Spark asks: &#8220;We need to process blocks 1000-1100. Break this into parallel chunks&#8221;You answer: [Partition(1000-1025), Partition(1025-1050), ...] &#8212; &#8220;4 chunks of 25 blocks&#8221;4read() &#8212; &#8220;Fetch the data!&#8221;Spark tells each worker: &#8220;Here&#8217;s your chunk. Go get the actual data&#8221;You fetch: Loop through blocks 1000-1025, fetch each one, yield Row objects5commit() &#8212; &#8220;All done!&#8221; [optional]checkpoint/commit/{N} file created. Optional method for cleanup tasks.&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="1initialOffset() &#8212; &#8220;Where do we start?&#8221;Spark asks: &#8220;This is a brand new query. Where should I begin reading?&#8221;You answer: {&#8221;offset&#8221;: 1000} &#8212; &#8220;Start at block 1000&#8221;2latestOffset() &#8212; &#8220;What&#8217;s the newest data?&#8221;Spark asks: &#8220;What&#8217;s the most recent data available right now?&#8221;You answer: {&#8221;offset&#8221;: 1100} &#8212; &#8220;Latest block is 1100&#8221;3partitions() &#8212; &#8220;How do we split this work?&#8221;Spark asks: &#8220;We need to process blocks 1000-1100. Break this into parallel chunks&#8221;You answer: [Partition(1000-1025), Partition(1025-1050), ...] &#8212; &#8220;4 chunks of 25 blocks&#8221;4read() &#8212; &#8220;Fetch the data!&#8221;Spark tells each worker: &#8220;Here&#8217;s your chunk. Go get the actual data&#8221;You fetch: Loop through blocks 1000-1025, fetch each one, yield Row objects5commit() &#8212; &#8220;All done!&#8221; [optional]checkpoint/commit/{N} file created. Optional method for cleanup tasks." title="1initialOffset() &#8212; &#8220;Where do we start?&#8221;Spark asks: &#8220;This is a brand new query. Where should I begin reading?&#8221;You answer: {&#8221;offset&#8221;: 1000} &#8212; &#8220;Start at block 1000&#8221;2latestOffset() &#8212; &#8220;What&#8217;s the newest data?&#8221;Spark asks: &#8220;What&#8217;s the most recent data available right now?&#8221;You answer: {&#8221;offset&#8221;: 1100} &#8212; &#8220;Latest block is 1100&#8221;3partitions() &#8212; &#8220;How do we split this work?&#8221;Spark asks: &#8220;We need to process blocks 1000-1100. Break this into parallel chunks&#8221;You answer: [Partition(1000-1025), Partition(1025-1050), ...] &#8212; &#8220;4 chunks of 25 blocks&#8221;4read() &#8212; &#8220;Fetch the data!&#8221;Spark tells each worker: &#8220;Here&#8217;s your chunk. Go get the actual data&#8221;You fetch: Loop through blocks 1000-1025, fetch each one, yield Row objects5commit() &#8212; &#8220;All done!&#8221; [optional]checkpoint/commit/{N} file created. Optional method for cleanup tasks." srcset="https://substackcdn.com/image/fetch/$s_!LND2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 424w, https://substackcdn.com/image/fetch/$s_!LND2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 848w, https://substackcdn.com/image/fetch/$s_!LND2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 1272w, https://substackcdn.com/image/fetch/$s_!LND2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F403de673-2c13-4cba-bd3a-5fabd03f3d14_828x662.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Let&#8217;s See Real Code: Streaming Ethereum Blocks</h2><p>Theory is great, but let&#8217;s look at actual implementation. Here&#8217;s how these 5 methods work in practice for streaming Ethereum blockchain data:</p><h3><strong>1.</strong> initialOffset() &#8212; Setting the Starting Point</h3><pre><code><code>def initialOffset(self) -&gt; dict:
    &#8220;&#8221;&#8220;
    Called ONCE when starting a brand new query.
    Return where to begin reading.
    &#8220;&#8221;&#8220;
    start_block = self.options.get(&#8221;start_block&#8221;, 0)
    return {&#8221;offset&#8221;: int(start_block)}
</code></code></pre><p>That&#8217;s it! Just return a dictionary with your starting position. Spark saves this and uses it as the baseline for the entire query lifecycle.</p><h3><strong>2.</strong> latestOffset() &#8212; Checking What&#8217;s Available</h3><pre><code><code>def latestOffset(self) -&gt; dict:
    &#8220;&#8221;&#8220;
    Called at the START of every batch.
    Connect to your source and return the newest available data.
    &#8220;&#8221;&#8220;
    latest_block = self.w3.eth.block_number
    return {&#8221;offset&#8221;: int(latest_block)}
</code></code></pre><p>This method connects to your data source (in this case, an Ethereum node) and asks &#8220;what&#8217;s the latest?&#8221; The answer defines the upper bound for the current batch.</p><blockquote><p><strong>&#9888;&#65039; Python API Limitation:</strong> In PySpark, <code>latestOffset()</code> must return the absolute latest data point. If you&#8217;re backfilling from very old data, your first batch could be huge. The Scala API offers more fine-grained control here, but for most real-time use cases, the Python API works perfectly.<br><br><strong>&#128221; Note:</strong> This limitation is actively being addressed - there&#8217;s currently a pull request in progress to fix this in Spark.</p></blockquote><h3><strong>3.</strong> partitions() &#8212; Dividing the Work</h3><pre><code><code>def partitions(self, start: dict, end: dict) -&gt; list:
    &#8220;&#8221;&#8220;
    Spark gives you a range (start &#8594; end).
    You break it into smaller chunks for parallel processing.
    &#8220;&#8221;&#8220;
    start_block = start[&#8221;offset&#8221;]
    end_block = end[&#8221;offset&#8221;]  # This is EXCLUSIVE (not included)
    
    num_partitions = self.spark.conf.get(&#8221;spark.sql.shuffle.partitions&#8221;, &#8220;4&#8221;)
    blocks_per_partition = (end_block - start_block) // int(num_partitions)
    
    partitions = []
    for i in range(int(num_partitions)):
        partition_start = start_block + (i * blocks_per_partition)
        partition_end = partition_start + blocks_per_partition
        if i == int(num_partitions) - 1:  # Last partition gets any remainder
            partition_end = end_block
            
        partitions.append(BlockRangePartition(partition_start, partition_end))
    
    return partitions
</code></code></pre><p><strong>How Partitioning Works</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!nC1l!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!nC1l!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 424w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 848w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 1272w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!nC1l!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png" width="778" height="344" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fa0236f5-7080-4243-be23-b427bd18fd86_778x344.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:344,&quot;width&quot;:778,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:43967,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!nC1l!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 424w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 848w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 1272w, https://substackcdn.com/image/fetch/$s_!nC1l!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa0236f5-7080-4243-be23-b427bd18fd86_778x344.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>s</p><blockquote><p><strong>&#128273; Critical Detail:</strong> Notice that the end block (1100) is <strong>exclusive</strong>. This means partition ranges are [1000, 1025), [1025, 1050), etc. Block 1100 is NOT processed&#8212;it becomes the start of the next batch. This [start, end) pattern is how Spark guarantees no data is ever processed twice.</p></blockquote><h3><strong>4</strong> read() &#8212; Actually Fetching the Data</h3><pre><code><code>def read(self, partition: BlockRangePartition):
    &#8220;&#8221;&#8220;
    This runs on EXECUTOR nodes (distributed across the cluster).
    Each executor gets one partition and must fetch its assigned data.
    
    Must be DETERMINISTIC - same input = same output, every time.
    This allows Spark to safely retry failed tasks.
    &#8220;&#8221;&#8220;
    for block_number in range(partition.start_block, partition.end_block):
        # Connect to Ethereum and fetch this specific block
        block = self.w3.eth.get_block(block_number, full_transactions=True)
        
        # Convert to Spark Row format
        yield Row(
            block_number=block.number,
            block_hash=block.hash.hex(),
            timestamp=block.timestamp,
            transaction_count=len(block.transactions),
            # ... more fields ...
        )
</code></code></pre><p>This is where the real work happens! Each executor in your cluster runs this method for its assigned partition, fetching the actual data.</p><div class="pullquote"><p><strong>&#128170; The Power of Parallelism:</strong> If you have 10 executors and create 100 partitions, all 10 executors work simultaneously. Each one processes its chunk, and as executors finish, Spark automatically assigns them new partitions. This is how Spark achieves massive throughput.</p></div><h3><strong>5</strong> commit() &#8212; Cleanup (Usually Empty)</h3><pre><code><code>def commit(self, end: dict):
    &#8220;&#8221;&#8220;
    Called AFTER all partitions successfully complete.
    The checkpoint/commit/{N} file gets created at this point.
    This method is optional - mainly used for cleanup tasks.
    &#8220;&#8221;&#8220;
    pass  # Usually empty unless you need cleanup
</code></code></pre><p>In most cases, this method is empty. The checkpoint/commit/{N} file gets created automatically. You only need to implement this if you have cleanup tasks to perform after a batch completes.</p><h2>The Complete Flow: Visual Walkthrough</h2><p>Now let&#8217;s see how these methods work together in a complete streaming query:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!rHAT!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!rHAT!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 424w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 848w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 1272w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!rHAT!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png" width="1200" height="901.3392857142857" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/aabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6b14386f-dc2b-4334-8310-c8aab15f34c7_896x673.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:673,&quot;width&quot;:896,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:128772,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b14386f-dc2b-4334-8310-c8aab15f34c7_896x673.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!rHAT!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 424w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 848w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 1272w, https://substackcdn.com/image/fetch/$s_!rHAT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faabc703d-d9e3-4c55-b900-a70ed91e0bb1_896x673.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Why This Design Is Brilliant</h2><h4>&#128737;&#65039; Fault Tolerance</h4><p>If an executor fails while reading blocks 1025-1050, Spark simply restarts that task on another machine. Because <code>read()</code> is deterministic, it fetches exactly the same data again. The user never knows a failure occurred.</p><h4>&#9889; Exactly-Once Semantics</h4><p>The [start, end) exclusive range pattern means no block is ever processed twice. Block 1100 is the start of the next batch, not the end of the previous one. Combined with checkpointing, this guarantees exactly-once processing.</p><h4>&#128640; Massive Parallelism</h4><p>By implementing <code>partitions()</code>, you tell Spark how to break work into chunks. Spark handles distributing those chunks to hundreds or thousands of executors. You get massive scale &#8220;for free.&#8221;</p><h4>&#129513; Separation of Concerns</h4><p>You focus on <em>your data source&#8217;s logic</em>. Spark handles scheduling, distribution, checkpointing, fault recovery, and coordination. Clean boundaries make complex systems manageable.</p><h2>What About Edge Cases?</h2><h3>Handling Source Failures</h3><p>What if Ethereum node goes down during <code>read()</code>?</p><pre><code><code>def read(self, partition: BlockRangePartition):
    max_retries = 3
    for block_number in range(partition.start_block, partition.end_block):
        for attempt in range(max_retries):
            try:
                block = self.w3.eth.get_block(block_number, full_transactions=True)
                yield Row(...)
                break  # Success!
            except Exception as e:
                if attempt == max_retries - 1:
                    raise  # Let Spark handle the failure
                time.sleep(2 ** attempt)  # Exponential backoff
</code></code></pre><p>If retries don&#8217;t work, the exception bubbles up, Spark marks the task as failed, and restarts it on another executor. Eventually the source recovers and processing continues from the checkpoint.</p><h3>Dealing with Large Batches</h3><p>What if <code>latestOffset()</code> returns a huge number?</p><blockquote><p><strong>The Golden Rule:</strong> Your processing rate should be greater than your input rate. Ideally, aim for <strong>10x faster processing than data arrival</strong>. This is the key design principle.<br><br>If you&#8217;re processing data faster than it&#8217;s arriving, Spark will naturally catch up with any backfill over the next few batches. You don&#8217;t need to worry about temporarily large batch sizes.<br><br><strong>About spark.sql.shuffle.partitions:</strong> You can adjust this, but don&#8217;t set it to an extremely high number. A reasonable partition count is sufficient as long as your processing rate exceeds your input rate.</p></blockquote><h3>Ensuring Determinism in read()</h3><p>The golden rule: <strong>Same partition input must produce same output</strong>.</p><p>Bad (non-deterministic):</p><pre><code><code># &#10060; DON&#8217;T DO THIS
def read(self, partition):
    current_time = time.time()  # Different each time!
    yield Row(timestamp=current_time, ...)
</code></code></pre><p>Good (deterministic):</p><pre><code><code># &#9989; DO THIS
def read(self, partition):
    block = self.w3.eth.get_block(partition.block_number)
    yield Row(timestamp=block.timestamp, ...)  # Block timestamp is consistent
</code></code></pre><h2>The Complete Picture: Architecture</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!73zt!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!73zt!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 424w, https://substackcdn.com/image/fetch/$s_!73zt!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 848w, https://substackcdn.com/image/fetch/$s_!73zt!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 1272w, https://substackcdn.com/image/fetch/$s_!73zt!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!73zt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png" width="826" height="541" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:541,&quot;width&quot;:826,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:104672,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!73zt!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 424w, https://substackcdn.com/image/fetch/$s_!73zt!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 848w, https://substackcdn.com/image/fetch/$s_!73zt!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 1272w, https://substackcdn.com/image/fetch/$s_!73zt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F813ddac6-37ae-4fc8-892e-b7935028e236_826x541.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>&#127919; You&#8217;re Ready to Build Your Own!</h2><blockquote><p>You now understand the complete lifecycle of a custom Spark streaming source. It&#8217;s not magic&#8212;it&#8217;s a well-designed conversation between Spark and your code.</p><p>Just implement 5 methods, and Spark handles the rest: fault tolerance, distribution, checkpointing, and exactly-once semantics.</p></blockquote><h2>Quick Reference: The 5 Methods</h2><p><strong>Your Implementation Checklist</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!sR-g!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!sR-g!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 424w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 848w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 1272w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!sR-g!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png" width="869" height="575" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:575,&quot;width&quot;:869,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:77932,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/177539932?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!sR-g!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 424w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 848w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 1272w, https://substackcdn.com/image/fetch/$s_!sR-g!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffea27c0b-cd3e-41f3-8c2b-e3bcb2c3f690_869x575.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Final Thoughts: Why This Matters</h2><p>The beauty of this architecture is its universality. Whether you&#8217;re streaming from Ethereum, MongoDB, a proprietary API, or carrier pigeons &#128038;, the pattern is the same:</p><ol><li><p><strong>Define where to start</strong> (<code>initialOffset</code>)</p></li><li><p><strong>Check what&#8217;s new</strong> (<code>latestOffset</code>)</p></li><li><p><strong>Break work into chunks</strong> (<code>partitions</code>)</p></li><li><p><strong>Fetch the data</strong> (<code>read</code>)</p></li><li><p><strong>Confirm completion</strong> (<code>commit</code>)</p></li></ol><p>Spark handles everything else&#8212;checkpointing, distribution, scheduling, fault recovery. You just focus on the specifics of your data source.</p><h3>&#128640; Take Action</h3><div class="pullquote"><p>The barrier to entry is lower than you thought. Pick a data source you&#8217;re working with, implement these 5 methods, and you&#8217;ll have a production-ready Spark streaming source in an afternoon.</p><p><strong>Start small:</strong> Get <code>initialOffset()</code> and <code>latestOffset()</code> working first. Then add <code>partitions()</code> and <code>read()</code>. Test with a single partition before scaling up. You&#8217;ve got this! &#128170;</p></div><p><strong>Now go build something amazing with Spark Streaming. The data world is your oyster. &#127754;</strong></p><h2><a href="https://github.com/jiteshsoni/ethereum-streaming-pipeline/blob/6e06cdea573780ba09a33a334f7f07539721b85e/ethereum_block_stream_chainstack.py">Download the code</a></h2>]]></content:encoded></item><item><title><![CDATA[How to write your first Spark application with Stream-Stream Joins with working code]]></title><description><![CDATA[A Practical, Hands-On Guide to Joining Real-Time Data Streams in Spark Structured Streaming]]></description><link>https://www.canadiandataguy.com/p/how-to-write-your-first-spark-application-c23</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/how-to-write-your-first-spark-application-c23</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Wed, 15 Oct 2025 17:39:41 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lVsP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Have you been waiting to try Streaming but cannot take the plunge?</p><p>In a single blog, we will teach you whatever needs to be understood about Streaming Joins. We will give you a working code which you can use for your next Streaming Pipeline.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>The steps involved:</p><ol><li><p>Create a fake dataset at scale</p></li><li><p>Set a baseline using traditional SQL</p></li><li><p>Define Temporary Streaming Views</p></li><li><p>Inner Joins with optional Watermarking</p></li><li><p>Left Joins with Watermarking</p></li><li><p>The cold start edge case: withEventTimeOrder</p></li><li><p>Cleanup</p></li></ol><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lVsP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lVsP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lVsP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png" width="1024" height="1024" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1024,&quot;width&quot;:1024,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1505856,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/176255602?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!lVsP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!lVsP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54172092-ba0b-49db-b7ae-033b9bef0640_1024x1024.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2><strong>What is Stream-Stream Join?</strong></h2><p>Stream-stream join is a widely used operation in stream processing where two or more data streams are joined based on some common attributes or keys. It is essential in several use cases, such as real-time analytics, fraud detection, and IoT data processing.</p><h3><strong>Concept of Stream-Stream Join</strong></h3><p>Stream-stream join combines two or more streams based on a common attribute or key. The join operation is performed on an ongoing basis, with each new data item from the stream triggering a join operation. In stream-stream join, each data item in the stream is treated as an event, and it is matched with the corresponding event from the other stream based on matching criteria. This matching criterion could be a common attribute or key in both streams.</p><p>When it comes to joining data streams, there are a few key challenges that must be addressed to ensure successful results. One of the biggest hurdles is the fact that, at any given moment, neither stream has a complete view of the dataset. This can make it difficult to find matches between inputs and generate accurate join results.</p><p>To overcome this challenge, it&#8217;s important to buffer past input as a streaming state for both input streams. This allows for every future input to be matched with past input, which can help to generate more accurate join results. Additionally, this buffering process can help to automatically handle late or out-of-order data, which can be common in streaming environments.</p><p>To further optimize the join process, it&#8217;s also important to use watermarks to limit the state. This can help to ensure that only the most relevant data is being used to generate join results, which can help to improve accuracy and reduce processing times.</p><h3><strong>Types of Stream-Stream Join</strong></h3><p>Depending on the nature of the join and the matching criteria, there are several types of stream-stream join operations. Some of the popular types of stream-stream join are:</p><p><strong>Inner Join</strong><br>In inner join, only those events are returned where there is a match in both the input streams. This type of join is useful when combining the data from two streams with a common key or attribute.</p><p><strong>Outer Join</strong><br>In outer join, all events from both the input streams are included in the joined stream, whether or not there is a match between them. This type of join is useful when we need to combine data from two streams, and there may be missing or incomplete data in either stream.</p><p><strong>Left Join</strong><br>In left join, all events from the left input stream are included in the joined stream, and only the matching events from the right input stream are included. This type of join is useful when we need to combine data from two streams and keep all the data from the left stream, even if there is no matching data in the right stream.</p><h2><strong>1. The Setup: Create a fake dataset at scale</strong></h2><p>Most people do not have 2 streams just hanging around for one to experiment with Stream Steam Joins. Thus I used Faker to mock 2 different streams which we will use for this example.</p><p>The name of the library being used is Faker and faker_vehicle to create Datasets.</p><pre><code>!pip install faker_vehicle
!pip install faker</code></pre><p>Imports</p><pre><code>from faker import Faker
from faker_vehicle import VehicleProvider
from pyspark.sql import functions as F
import uuid
from utils import logger</code></pre><p>Parameters</p><pre><code># define schema name and where should the table be stored
schema_name = &#8220;test_streaming_joins&#8221;
schema_storage_location = &#8220;/tmp/CHOOSE_A_PERMANENT_LOCATION/&#8221;</code></pre><p><strong>Create the Target Schema/Database</strong><br>Create a Schema and set location. This way, all tables would inherit the base location.</p><pre><code>create_schema_sql = f&#8221;&#8221;&#8221;
 CREATE SCHEMA IF NOT EXISTS {schema_name}
 COMMENT &#8216;This is {schema_name} schema&#8217;
 LOCATION &#8216;{schema_storage_location}&#8217;
 WITH DBPROPERTIES ( Owner=&#8217;Jitesh&#8217;);
 &#8220;&#8221;&#8221;
print(f&#8221;create_schema_sql: {create_schema_sql}&#8221;)
spark.sql(create_schema_sql)</code></pre><p>Use Faker to define functions to help generate fake column values</p><pre><code>fake = Faker()
fake.add_provider(VehicleProvider)</code></pre><pre><code>event_id = F.udf(lambda: str(uuid.uuid4()))
vehicle_year_make_model = F.udf(fake.vehicle_year_make_model)
vehicle_year_make_model_cat = F.udf(fake.vehicle_year_make_model_cat)
vehicle_make_model = F.udf(fake.vehicle_make_model)
vehicle_make = F.udf(fake.vehicle_make)
vehicle_model = F.udf(fake.vehicle_model)
vehicle_year = F.udf(fake.vehicle_year)
vehicle_category = F.udf(fake.vehicle_category)
vehicle_object = F.udf(fake.vehicle_object)</code></pre><pre><code>latitude = F.udf(fake.latitude)
longitude = F.udf(fake.longitude)
location_on_land = F.udf(fake.location_on_land)
local_latlng = F.udf(fake.local_latlng)
zipcode = F.udf(fake.zipcode)</code></pre><p>Generate Streaming source data at your desired rate</p><pre><code>def generated_vehicle_and_geo_df (rowsPerSecond:int , numPartitions :int ):
    return (
        spark.readStream.format(&#8221;rate&#8221;)
        .option(&#8221;numPartitions&#8221;, numPartitions)
        .option(&#8221;rowsPerSecond&#8221;, rowsPerSecond)
        .load()
        .withColumn(&#8221;event_id&#8221;, event_id())
        .withColumn(&#8221;vehicle_year_make_model&#8221;, vehicle_year_make_model())
        .withColumn(&#8221;vehicle_year_make_model_cat&#8221;, vehicle_year_make_model_cat())
        .withColumn(&#8221;vehicle_make_model&#8221;, vehicle_make_model())
        .withColumn(&#8221;vehicle_make&#8221;, vehicle_make())
        .withColumn(&#8221;vehicle_year&#8221;, vehicle_year())
        .withColumn(&#8221;vehicle_category&#8221;, vehicle_category())
        .withColumn(&#8221;vehicle_object&#8221;, vehicle_object())
        .withColumn(&#8221;latitude&#8221;, latitude())
        .withColumn(&#8221;longitude&#8221;, longitude())
        .withColumn(&#8221;location_on_land&#8221;, location_on_land())
        .withColumn(&#8221;local_latlng&#8221;, local_latlng())
        .withColumn(&#8221;zipcode&#8221;, zipcode())
        )

# You can uncomment the below display command to check if the code in this cell works
#display(generated_vehicle_and_geo_df)</code></pre><pre><code># You can uncomment the below display command to check if the code in this cell works
#display(generated_vehicle_and_geo_df)</code></pre><p>Now let&#8217;s generate the base source table and let&#8217;s call it Vehicle_Geo</p><pre><code>def stream_write_to_vehicle_geo_table(rowsPerSecond: int = 1000, numPartitions: int = 10):
    table_name_vehicle_geo= &#8220;vehicle_geo&#8221;
    (
        generated_vehicle_and_geo_df(rowsPerSecond, numPartitions)
            .writeStream
            .queryName(f&#8221;write_to_delta_table: {table_name_vehicle_geo}&#8221;)
            .option(&#8221;checkpointLocation&#8221;, f&#8221;{schema_storage_location}/{table_name_vehicle_geo}/_checkpoint&#8221;)
            .format(&#8221;delta&#8221;)
            .toTable(f&#8221;{schema_name}.{table_name_vehicle_geo}&#8221;)
    )
stream_write_to_vehicle_geo_table(rowsPerSecond = 1000, numPartitions = 10)</code></pre><p>Let the above code run for a few iterations, and you can play with rowsPerSecond and numPartitions to control how much data you would like to generate. Once you have generated enough data, kill the above stream and get a base line for row count.</p><pre><code>spark.read.table(f&#8221;{schema_name}.{table_name_vehicle_geo}&#8221;).count()</code></pre><pre><code>display(
    spark.sql(f&#8221;&#8220;&#8221;
    SELECT * 
    FROM {schema_name}.{table_name_vehicle_geo}
&#8220;&#8221;&#8220;)
)</code></pre><p>Let&#8217;s also get a min &amp; max of the timestamp column as we would be leveraging it for watermarking.</p><pre><code>display(
    spark.sql(f&#8221;&#8220;&#8221;
    SELECT 
         min(timestamp)
        ,max(timestamp)
        ,current_timestamp()
    FROM {schema_name}.{table_name_vehicle_geo}
&#8220;&#8221;&#8220;)
)</code></pre><h3><strong>Next, we will break this Delta table into 2 different tables</strong></h3><p>Because for Stream-Stream Joins we need 2 different streams. We will use Delta To Delta Streaming here to create these tables.</p><ol><li><p><strong>a ) Table: Vehicle</strong></p></li></ol><pre><code>vehicle_df = (
        spark.readStream.format(&#8221;delta&#8221;).option(&#8221;maxFilesPerTrigger&#8221;,&#8221;100&#8221;).table(f&#8221;{schema_name}.vehicle_geo&#8221;)
        .selectExpr(
            &#8220;event_id&#8221;
            ,&#8221;timestamp as vehicle_timestamp&#8221;
            ,&#8221;vehicle_year_make_model&#8221;
            ,&#8221;vehicle_year_make_model_cat&#8221;
            ,&#8221;vehicle_make_model&#8221;
            ,&#8221;vehicle_make&#8221;
            ,&#8221;vehicle_year&#8221;
            ,&#8221;vehicle_category&#8221;
            ,&#8221;vehicle_object&#8221;
            )
    )
#display(vehicle_df)
def stream_write_to_vehicle_table():
    table_name_vehicle = &#8220;vehicle&#8221;
    (   vehicle_df
        .writeStream
        #.trigger(availableNow=True)
        .queryName(f&#8221;write_to_delta_table: {table_name_vehicle}&#8221;)
        .option(&#8221;checkpointLocation&#8221;, f&#8221;{schema_storage_location}/{table_name_vehicle}/_checkpoint&#8221;)
        .format(&#8221;delta&#8221;)
        .toTable(f&#8221;{schema_name}.{table_name_vehicle}&#8221;)
    )

stream_write_to_vehicle_table()   </code></pre><ol><li><p><strong>b) Table: Geo</strong></p></li></ol><p>We have added a filter when we write to this table. This would be useful when we emulate the left join scenario. Filter: <code>where(&#8221;value like &#8216;1%&#8217; &#8220;)</code></p><pre><code>geo_df = (
    spark.readStream.format(&#8221;delta&#8221;).option(&#8221;maxFilesPerTrigger&#8221;,&#8221;100&#8221;).table(f&#8221;{schema_name}.vehicle_geo&#8221;)
        .selectExpr(
            &#8220;event_id&#8221;
            ,&#8221;value&#8221;
            ,&#8221;timestamp as geo_timestamp&#8221;
            ,&#8221;latitude&#8221;
            ,&#8221;longitude&#8221;
            ,&#8221;location_on_land&#8221;
            ,&#8221;local_latlng&#8221;
            ,&#8221;cast( zipcode as integer) as zipcode&#8221;
        ).where(&#8221;value like &#8216;1%&#8217; &#8220;) 
    )
#geo_df.printSchema()
#display(geo_df)

def stream_write_to_geo_table():
    table_name_geo = &#8220;geo&#8221;
    (   geo_df
        .writeStream
        #.trigger(availableNow=True)
        .queryName(f&#8221;write_to_delta_table: {table_name_geo}&#8221;)
        .option(&#8221;checkpointLocation&#8221;, f&#8221;{schema_storage_location}/{table_name_geo}/_checkpoint&#8221;)
        .format(&#8221;delta&#8221;)
        .toTable(f&#8221;{schema_name}.{table_name_geo}&#8221;)
    )
    
stream_write_to_geo_table()    </code></pre><h2><strong>2. Set a baseline using traditional SQL</strong></h2><p>Before we do the actual streaming joins. Let&#8217;s do a regular join and figure out the expected row count.</p><p><strong>Get row count from Inner Join</strong></p><pre><code>sql_query_batch_inner_join = f&#8217;&#8216;&#8217;
        SELECT count(vehicle.event_id) as row_count_for_inner_join
        FROM {schema_name}.{table_name_vehicle} vehicle
        JOIN {schema_name}.{table_name_geo} geo
        ON vehicle.event_id = geo.event_id
    AND vehicle_timestamp &gt;= geo_timestamp  - INTERVAL 5 MINUTES        
        &#8216;&#8217;&#8216;
print(f&#8217;&#8216;&#8217; Run SQL Query: 
          {sql_query_batch_inner_join}       
       &#8216;&#8217;&#8216;)
display( spark.sql(sql_query_batch_inner_join) )</code></pre><p><strong>Get row count from Inner Join</strong></p><pre><code>sql_query_batch_left_join = f&#8217;&#8216;&#8217;
        SELECT count(vehicle.event_id) as row_count_for_left_join
        FROM {schema_name}.{table_name_vehicle} vehicle
        LEFT JOIN {schema_name}.{table_name_geo} geo
        ON vehicle.event_id = geo.event_id
            -- Assume there is a business logic that timestamp cannot be more than 15 minutes off
    AND vehicle_timestamp &gt;= geo_timestamp  - INTERVAL 5 MINUTES
        &#8216;&#8217;&#8216;
print(f&#8217;&#8216;&#8217; Run SQL Query: 
          {sql_query_batch_left_join}       
       &#8216;&#8217;&#8216;)
display( spark.sql(sql_query_batch_left_join) )</code></pre><h2><strong>Summary so far:</strong></h2><ol><li><p>We created a Source Delta Table: vehicle_geo</p></li><li><p>We took the previous table and divided its column into two tables: Vehicle and Geo</p></li><li><p>Vehicle row count matches with vehicle_geo, and it has a subset of those columns</p></li><li><p>The Geo row count is lesser than Vehicle because we added a filter when we wrote to the Geo table</p></li><li><p>We ran 2 SQL to identify what the row count should be after we do stream-stream join</p></li></ol><h2><strong>3. Define Temporary Streaming Views</strong></h2><p>Some people prefer to write the logic in SQL. Thus, we are creating streaming views which could be manipulated with SQL. The below code block will help create a view and set a watermark on the stream.</p><pre><code>def stream_from_delta_and_create_view (schema_name: str, table_name:str, column_to_watermark_on:str, how_late_can_the_data_be: str = &#8220;2 minutes&#8221; , maxFilesPerTrigger: int = 100):
    view_name = f&#8221;_streaming_vw_{schema_name}_{table_name}&#8221;
    print(f&#8221;Table {schema_name}.{table_name} is now streaming under a temporoary view called {view_name}&#8221;)
    (
        spark.readStream.format(&#8221;delta&#8221;)
        .option(&#8221;maxFilesPerTrigger&#8221;, f&#8221;{maxFilesPerTrigger}&#8221;)
        .option(&#8221;withEventTimeOrder&#8221;, &#8220;true&#8221;)
        .table(f&#8221;{schema_name}.{table_name}&#8221;)
        .withWatermark(f&#8221;{column_to_watermark_on}&#8221;,how_late_can_the_data_be)
        .createOrReplaceTempView(view_name)
    )
</code></pre><p><strong>3. a Create Vehicle Stream</strong></p><h2>Get CanadianDataGuy.com&#8217;s stories in your inbox</h2><p>Join Medium for free to get updates from this writer.</p><p>Subscribe</p><p>Let&#8217;s create a Vehicle Stream and set its watermark as 1mins</p><pre><code>stream_from_delta_and_create_view(schema_name =schema_name, table_name = &#8216;vehicle&#8217;, column_to_watermark_on =&#8221;vehicle_timestamp&#8221;, how_late_can_the_data_be = &#8220;1 minutes&#8221; )</code></pre><p>Let&#8217;s visualize the stream.</p><pre><code>display(
    spark.sql(f&#8217;&#8216;&#8217;
        SELECT *
        FROM _streaming_vw_test_streaming_joins_vehicle
    &#8216;&#8217;&#8216;)
)</code></pre><p>You can also do an aggregation on the stream. It&#8217;s out of the scope of this blog, but I wanted to show you how you can do it</p><pre><code>display(
    spark.sql(f&#8217;&#8216;&#8217;
        SELECT 
            vehicle_make
            ,count(1) as row_count
        FROM _streaming_vw_test_streaming_joins_vehicle
        GROUP BY vehicle_make
        ORDER BY vehicle_make
    &#8216;&#8217;&#8216;)
)</code></pre><p><strong>3. b Create Geo Stream</strong></p><p>Let&#8217;s create a Geo Stream and set its watermark as 2 mins</p><pre><code>stream_from_delta_and_create_view(schema_name =schema_name, table_name = &#8216;geo&#8217;, column_to_watermark_on =&#8221;geo_timestamp&#8221;, how_late_can_the_data_be = &#8220;2 minutes&#8221; )</code></pre><p>Have a look at what the data looks like</p><pre><code>display(
    spark.sql(f&#8217;&#8216;&#8217;
        SELECT *
        FROM _streaming_vw_test_streaming_joins_geo
    &#8216;&#8217;&#8216;)
)</code></pre><h2><strong>4. Inner Joins with optional Watermarking</strong></h2><p>While inner joins on any kind of columns and with any kind of conditions are possible in streaming environments, it&#8217;s important to be aware of the potential for unbounded state growth. As new input arrives, it can potentially match with any input from the past, leading to a rapidly increasing streaming state size.</p><p>To avoid this issue, it&#8217;s essential to define additional join conditions that prevent indefinitely old inputs from matching with future inputs. By doing so, it&#8217;s possible to clear old inputs from the state, which can help to prevent unbounded state growth and ensure more efficient processing.</p><p>There are a variety of techniques that can be used to define these additional join conditions. For example, you might limit the scope of the join by only matching on a subset of columns, or you might set a time-based constraint that prevents old inputs from being considered after a certain period of time has elapsed.</p><p>Ultimately, the key to managing streaming state size and ensuring efficient join processing is to consider the unique requirements of your specific use case carefully and to leverage the right techniques and tools to optimize your join conditions accordingly. <strong>Although watermarking could be optional, I would highly recommend you set a watermark on both streams.</strong></p><pre><code>sql_for_stream_stream_inner_join = f&#8221;&#8220;&#8221;
    SELECT 
        vehicle.*
        ,geo.latitude
        ,geo.longitude
        ,geo.zipcode
    FROM _streaming_vw_test_streaming_joins_vehicle vehicle
    JOIN _streaming_vw_test_streaming_joins_geo geo
    ON vehicle.event_id = geo.event_id
    -- Assume there is a business logic that timestamp cannot be more than X minutes off
    AND vehicle_timestamp BETWEEN geo_timestamp  - INTERVAL 5 MINUTES AND geo_timestamp
&#8220;&#8221;&#8220;
#display(spark.sql(sql_for_stream_stream_inner_join))</code></pre><pre><code>table_name_stream_stream_innner_join =&#8217;stream_stream_innner_join&#8217;

(   spark.sql(sql_for_inner_join)
    .writeStream
    #.trigger(availableNow=True)
        .queryName(f&#8221;write_to_delta_table: {table_name_stream_stream_innner_join}&#8221;)
        .option(&#8221;checkpointLocation&#8221;, f&#8221;{schema_storage_location}/{table_name_stream_stream_innner_join}/_checkpoint&#8221;)
        .format(&#8221;delta&#8221;)
        .toTable(f&#8221;{schema_name}.{table_name_stream_stream_innner_join}&#8221;)
)</code></pre><p>If the stream has finished then in the next step. You should find that the row count should match up with the regular batch SQL Job</p><pre><code>spark.read.table(f&#8221;{schema_name}.{table_name_stream_stream_innner_join}&#8221;).count()</code></pre><h3><strong>How was the watermark computed in this scenario?</strong></h3><p>When we defined streaming views for Vehicle and Geo, we set them as 1 min and 2 min, respectively.</p><p>If you look at the join condition we mentioned :</p><pre><code>AND vehicle_timestamp &gt;= geo_timestamp - INTERVAL 5 minutes</code></pre><p>5 min + 2 min = 7 min.</p><p>Spark Streaming would automatically calculate this 7 min number and the state would be cleared after that.</p><h2><strong>5. Left Joins with Watermarking</strong></h2><p>While the watermark + event-time constraints is optional for inner joins, for outer joins they must be specified. This is because for generating the NULL results in outer join, the engine must know when an input row is not going to match with anything in future. Hence, the watermark + event-time constraints must be specified for generating correct results.</p><h3><strong>5.a How Left Joins works differently than an Inner Join</strong></h3><p>One important factor is that the outer NULL results will be generated with a delay that depends on the specified watermark delay and the time range condition. This delay is necessary to ensure that there were no matches, and that there will be no matches in the future.</p><p>In the current implementation of the micro-batch engine, watermarks are advanced at the end of each micro-batch, and the next micro-batch uses the updated watermark to clean up the state and output outer results. However, this means that the generation of outer results may be delayed if there is no new data being received in the stream. If either of the two input streams being joined does not receive data for a while, the outer output (in both left and right cases) may be delayed.</p><pre><code>sql_for_stream_stream_left_join = f&#8221;&#8220;&#8221;
    SELECT 
        vehicle.*
        ,geo.latitude
        ,geo.longitude
        ,geo.zipcode
    FROM _streaming_vw_test_streaming_joins_vehicle vehicle
    LEFT JOIN _streaming_vw_test_streaming_joins_geo geo
    ON vehicle.event_id = geo.event_id
        AND vehicle_timestamp BETWEEN geo_timestamp  - INTERVAL 5 MINUTES AND geo_timestamp
&#8220;&#8221;&#8220;
#display(spark.sql(sql_for_stream_stream_left_join))

table_name_stream_stream_left_join =&#8217;stream_stream_left_join&#8217;

(   spark.sql(sql_for_stream_stream_left_join)
    .writeStream
    #.trigger(availableNow=True)
        .queryName(f&#8221;write_to_delta_table: {table_name_stream_stream_left_join}&#8221;)
        .option(&#8221;checkpointLocation&#8221;, f&#8221;{schema_storage_location}/{table_name_stream_stream_left_join}/_checkpoint&#8221;)
        .format(&#8221;delta&#8221;)
        .toTable(f&#8221;{schema_name}.{table_name_stream_stream_left_join}&#8221;)
)</code></pre><p>If the stream has finished, then in the next step. You should find that the row count should match up with the regular batch SQL Job.</p><pre><code>spark.read.table(f&#8221;{schema_name}.{table_name_stream_stream_left_join}&#8221;).count()</code></pre><blockquote><p><em><strong>You will find that some records that could not match are not being released, which is expected. </strong>The outer NULL results will be generated with a delay that depends on the specified watermark delay and the time range condition. This is because the engine has to wait for that long to ensure there were no matches and there will be no more matches in future.</em></p><p><em><strong>**Watermark will advance once new data is pushed to it**</strong></em></p></blockquote><p>Thus let&#8217;s generate some more fate data to the base table: <strong>vehicle_geo. </strong>This time we are sending a much lower volume of 10 records per second. Let the below command run for at least one batch and then kill it.</p><pre><code>stream_write_to_vehicle_geo_table(rowsPerSecond = 10, numPartitions = 10)</code></pre><h3><strong>5. b What to observe:</strong></h3><ol><li><p>Soon you should see the watermark moves ahead and the number of records in &#8216;Aggregation State&#8217; goes down.</p></li><li><p>If you click on the running stream and click the raw data tab and look for &#8220;watermark&#8221;. You will see it has advanced</p></li><li><p>Once 0 records per second are being processed, that means your stream has caught up, and now your row count should match up with the traditional SQL left join</p></li></ol><pre><code>spark.read.table(f&#8221;{schema_name}.{table_name_stream_stream_left_join}&#8221;).count()</code></pre><h2><strong>6. The cold start edge case: withEventTimeOrder</strong></h2><blockquote><p><em>&#8220;When using a Delta table as a stream source, the query first processes all of the data present in the table. The Delta table at this version is called the initial snapshot. By default, the Delta table&#8217;s data files are processed based on which file was last modified. However, the last modification time does not necessarily represent the record event time order.</em></p><p><em>In a stateful streaming query with a defined watermark, processing files by modification time can result in records being processed in the wrong order. This could lead to records dropping as late events by the watermark.</em></p><p><em>You can avoid the data drop issue by enabling the following option:</em></p><p><em>withEventTimeOrder: Whether the initial snapshot should be processed with event time order.</em></p></blockquote><p><strong>If you use startingVersion then withEventTimeOrder attribute is ignored.</strong></p><p>In our scenario, I pushed this inside Step 3 when we created the temporary streaming views.</p><pre><code>spark.readStream.format(&#8221;delta&#8221;)
        .option(&#8221;maxFilesPerTrigger&#8221;, f&#8221;{maxFilesPerTrigger}&#8221;)
        .option(&#8221;withEventTimeOrder&#8221;, &#8220;true&#8221;)
        .table(f&#8221;{schema_name}.{table_name}&#8221;)</code></pre><h2><strong>7. Cleanup</strong></h2><p>Drop all tables in the database and delete all the checkpoints</p><pre><code>spark.sql(
    f&#8221;&#8220;&#8221;
    drop schema if exists {schema_name} CASCADE
&#8220;&#8221;&#8220;
)


dbutils.fs.rm(schema_storage_location, True)</code></pre><p>If you have reached so far, you now have a working pipeline and a solid example which you can use going forward.</p><h2><strong>Download the code</strong></h2><p><a href="https://github.com/jiteshsoni/material_for_public_consumption/blob/main/notebooks/spark_stream_stream_join.py">https://github.com/jiteshsoni/material_for_public_consumption/blob/main/notebooks/spark_stream_stream_join.py</a></p><h3><strong>References:</strong></h3><ol><li><p><a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#stream-stream-joins">https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#stream-stream-joins</a></p></li><li></li></ol><div id="youtube2-hyZU_bw1-ow" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;hyZU_bw1-ow&quot;,&quot;startTime&quot;:&quot;1181&quot;,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/hyZU_bw1-ow?start=1181&amp;rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><ol><li></li></ol><div id="youtube2-1cBDGsSbwRA" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;1cBDGsSbwRA&quot;,&quot;startTime&quot;:&quot;1500s&quot;,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/1cBDGsSbwRA?start=1500s&amp;rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><ol><li><p><a href="https://www.databricks.com/blog/2022/08/22/feature-deep-dive-watermarking-apache-spark-structured-streaming.html">https://www.databricks.com/blog/2022/08/22/feature-deep-dive-watermarking-apache-spark-structured-streaming.html</a></p></li><li><p><a href="https://docs.databricks.com/structured-streaming/delta-lake.html#process-initial-snapshot-without-data-being-dropped">https://docs.databricks.com/structured-streaming/delta-lake.html#process-initial-snapshot-without-data-being-dropped</a></p></li></ol><h2><strong>Footnote:</strong></h2><p>Thank you for taking the time to read this article. If you found it helpful or enjoyable, please consider clapping to show appreciation and help others discover it. Don&#8217;t forget to follow me for more insightful content, and visit my website <strong><a href="https://canadiandataguy.com/">CanadianDataGuy.com</a></strong> for additional resources and information. Your support and feedback are essential to me, and I appreciate your engagement with my work.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[A Deep Dive into Skewed Joins, GroupBy Bottlenecks, and Smart Strategies to Keep Your Spark Jobs Flying]]></title><description><![CDATA[Unlock comprehensive, practical solutions to conquer data skew in Apache Spark&#8212;step-by-step from basics to advanced strategies for perfectly balanced workloads and optimized job performance.]]></description><link>https://www.canadiandataguy.com/p/a-deep-dive-into-skewed-joins-groupby</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/a-deep-dive-into-skewed-joins-groupby</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Fri, 06 Jun 2025 03:11:24 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Data skew in Apache Spark refers to an <strong>uneven distribution of data across partitions</strong>, often manifesting during shuffle-intensive operations like joins or group-by aggregations. In a skewed scenario, one or a few partitions end up holding far more records for a particular key than others, leading to <strong>hotspots</strong> and <strong>straggler tasks</strong>. This imbalance causes <strong>performance bottlenecks</strong> (tasks processing heavy partitions take much longer) and <strong>inefficient resource usage</strong> (some executors sit idle). In extreme cases, heavily skewed partitions can even exhaust executor memory and cause job failures. Below, we delve into why skew occurs in joins and aggregations, and provide comprehensive strategies&#8212;ranging from Spark configuration tweaks to code-level patterns and architectural designs&#8212;to alleviate data skew. </p><h2>Why Data Skew Occurs in Joins and Aggregations</h2><p><strong>Join Operations:</strong> In Spark (excluding broadcast joins), joining two datasets on a key requires redistributing data so that records with the same key end up on the same partition (for a shuffle hash join or sort-merge join). If the key distribution is highly uneven (e.g. one key value appears in 90% of the records), the partition handling that key will be <strong>massive compared to others</strong>, causing skew. All records for that popular key funnel into one task, creating a severe load imbalance. For example, consider joining a large transactions table with a user table on <code>user_id</code> when a few &#8220;power users&#8221; have the vast majority of transactions. The join partition corresponding to those user_ids will handle hundreds of thousands of records, while other partitions process only a few &#8211; resulting in stragglers and possibly out-of-memory errors.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!6y5D!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6y5D!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 424w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 848w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 1272w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!6y5D!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png" width="1200" height="1210.7142857142858" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7aa8254b-9360-4d6d-8772-87863babbf7b_3806x3840.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:1469,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:1090821,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/165303261?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7aa8254b-9360-4d6d-8772-87863babbf7b_3806x3840.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6y5D!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 424w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 848w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 1272w, https://substackcdn.com/image/fetch/$s_!6y5D!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cdbf698-3d8d-4341-85aa-1fbc57a1b7d6_3806x3840.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>GroupBy and Aggregations:</strong> Similarly, grouping or aggregating by a key brings all data for each key onto one executor. If some keys occur far more frequently than others, those keys&#8217; partitions become disproportionately large. For instance, a <code>groupBy("customer_id")</code> on an orders dataset where a handful of customers account for most orders will produce skew: the reducer for those popular customers must aggregate an extremely large list, while others handle trivial amounts<a href="https://www.linkedin.com/pulse/what-data-skewness-spark-how-handle-code-soutir-sen-xf6hf#:~:text=Skewness%20often%20arises%20during%20operations,For%20example">l</a>. Even though Spark performs map-side partial aggregation, a single reduce task will still have to combine all intermediate results for a heavy key, leading to one very slow task.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-TIJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-TIJ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 424w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 848w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 1272w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-TIJ!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png" width="1200" height="1246.1538461538462" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f70466ce-9b00-4cdd-9e70-f55d0cd1f468_3699x3840.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:1512,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:1239456,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/165303261?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff70466ce-9b00-4cdd-9e70-f55d0cd1f468_3699x3840.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-TIJ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 424w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 848w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 1272w, https://substackcdn.com/image/fetch/$s_!-TIJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcd7580bc-ed95-47cf-abd8-70d6e0c2e6eb_3699x3840.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>Understanding these root causes guides us to solutions. Next, we address <strong>join skew</strong> and <strong>groupBy/aggregation skew</strong> separately, discussing targeted techniques for each.</p><h2>How do we know if we have a Skew Problem?</h2><p>To identify if there is a skew problem in Spark, several indicators and methods can be employed:</p><ol><li><p><strong>Task Duration Discrepancy</strong>:</p><ul><li><p>If all tasks in a shuffle stage finish except for a few that hang for a long time, this may indicate data skew.</p></li></ul></li><li><p><strong>Spark UI Analysis</strong>:</p><ul><li><p>Check the tasks summary metrics in the Spark UI. A significant difference between the minimum and maximum shuffle read sizes can suggest skewness.</p></li></ul></li><li><p><strong>Data Spills</strong>:</p><ul><li><p>If, despite tuning the number of shuffle partitions, there are numerous data spills, this might point to data skew.</p></li></ul></li><li><p><strong>Row Count Disparity</strong>:</p><ul><li><p>Counting rows grouped by join or aggregation columns can reveal skew. A significant difference in row counts for different groups indicates potential skew issues.</p></li></ul></li><li><p><strong>Compression Ratios</strong>:</p><ul><li><p>Highly compressed tables can affect the estimation of shuffle partitions, leading to spills. Monitoring this can help identify such cases.</p></li></ul></li></ol><p><a href="https://www.databricks.com/discover/pages/optimize-data-workloads-guide">Additionally, Spark SQL's Adaptive Query Execution (AQE) can help detect and sometimes resolve data skew dynamically by adjusting execution strategies as needed. </a></p><h2>Mitigating Skew in Join Operations</h2><p>When joining two datasets on a key, Spark must shuffle records so that identical keys end up on the same partition. If one key is heavily overrepresented, its partition can become a bottleneck. Below are strategies ordered from most to least recommended</p><h3>1. Adaptive Query Execution (AQE) &#8211; Automatic Skew Handling</h3><p>Spark 3.0+ introduced <strong>Adaptive Query Execution (AQE)</strong>, which can dynamically detect and correct skewed partitions during runtime. When AQE is enabled, Spark measures the size of each shuffle partition after the initial shuffle. If it finds any partition that is both exceptionally large in absolute terms and multiple times larger than the median partition size, it automatically splits that partition into smaller sub-tasks and replicates the corresponding rows from the other side of the join so each sub-task can run independently.</p><h4>How It Works</h4><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!N7Vj!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!N7Vj!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 424w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 848w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 1272w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!N7Vj!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png" width="536" height="1159" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/051f8937-4934-4d8e-929f-71c4bf2a6d48_536x1159.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1159,&quot;width&quot;:536,&quot;resizeWidth&quot;:536,&quot;bytes&quot;:110917,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/165303261?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F051f8937-4934-4d8e-929f-71c4bf2a6d48_536x1159.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!N7Vj!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 424w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 848w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 1272w, https://substackcdn.com/image/fetch/$s_!N7Vj!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe422a76d-a93f-4dc5-b0e4-1db069c12a33_536x1159.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><ol><li><p><strong>Collect Partition Statistics:</strong></p><ul><li><p>After the shuffle phase, Spark records the size (bytes) of every partition on both sides of the join.</p></li></ul></li><li><p><strong>Identify Skewed Partitions:</strong><br>A partition is marked as &#8220;skewed&#8221; only if it meets <strong>both</strong> criteria:</p><ul><li><p><strong>Absolute&#8208;Size Threshold: </strong><code>spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes </code>Default: <code>256MB</code></p></li><li><p><strong>Relative&#8208;Size Factor: </strong><code>spark.sql.adaptive.skewJoin.skewedPartitionFactor</code></p><p>(Default: <code>5.0</code>)</p></li></ul><p>If the median shuffle&#8208;partition size is 50 MB, a factor of 5.0 means any partition &gt; 250 MB qualifies&#8212;provided it also exceeds the 256 MB absolute threshold.</p></li><li><p><strong>Split &amp; Replicate:</strong></p><ul><li><p>Suppose partition #17 is 1 GB and the coalesced&#8208;partition target is 250 MB. Spark divides that 1 GB into four ~250 MB sub-partitions.</p></li><li><p>For a join, each of those sub-partitions must still see all matching rows from the opposite dataset. Spark duplicates those matching rows N times (once per sub-partition) so each sub-task can run a local join.</p></li></ul></li><li><p><strong>Run Subtasks in Parallel &amp; Merge Results:</strong></p><ul><li><p>Instead of a single, massive task pulling 1 GB, Spark launches N tasks (e.g., four tasks pulling ~250 MB each plus replicated rows).</p></li><li><p>When those sub-tasks finish, Spark concatenates their outputs to produce the final joined result.</p></li></ul></li></ol><p>Because this splitting and replication occur <strong>after</strong> the initial shuffle&#8212;when Spark has accurate sizes&#8212;no query rewriting or manual &#8220;hints&#8221; are required.</p><h4>Configuration</h4><pre><code># Enable AQE (on by default in Spark 3.2+)
spark.sql.adaptive.enabled=true

# Enable skew-join correction
spark.sql.adaptive.skewJoin.enabled=true

# Absolute-size threshold for skewed partitions
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256MB

# Relative-size factor: if a partition is &gt; factor &#215; median size, it's skewed
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5.0

# (Spark 3.3+) Force AQE to apply skew-join splitting even if it adds shuffle overhead
spark.sql.adaptive.forceOptimizeSkewedJoin=true
</code></pre><h4>Pros &amp; Cons</h4><ul><li><p><strong>Pros:</strong></p><ul><li><p><strong>Zero code changes</strong>: No query rewrites, no manual hints.</p></li><li><p><strong>Runtime intelligence</strong>: Works on any sort-merge or shuffle-hash join where skew is severe.</p></li><li><p>Eliminates straggler tasks without requiring you to identify skewed keys in advance.</p></li></ul></li><li><p><strong>Cons:</strong></p><ul><li><p>Applies only to <strong>shuffle joins</strong> (sort-merge and shuffle-hash). Broadcast joins never shuffle, so they aren&#8217;t &#8220;skewed.&#8221;</p></li><li><p>Splitting and replicating can introduce extra shuffle I/O; mild skew might not trigger or be worth splitting.</p></li><li><p>You may need to tune thresholds (<code>skewedPartitionThresholdInBytes</code> and <code>skewedPartitionFactor</code>) to avoid splitting on nearly-skewed partitions.</p></li></ul></li></ul><h2><strong>Keep This Post Discoverable: Your Engagement Counts!</strong></h2><p>Your engagement with this blog post is crucial! Without claps, comments, or shares, this valuable content might become lost in the vast sea of online information. Search engines like Google rely on user engagement to determine the relevance and importance of web pages. If you found this information helpful, please take a moment to clap, comment, or share. Your action not only helps others discover this content but also ensures that you&#8217;ll be able to find it again in the future when you need it. Don&#8217;t let this resource disappear from search results &#8212; show your support and help keep quality content accessible!</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share CanadianDataGuy&#8217;s No Fluff Newsletter&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.canadiandataguy.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share CanadianDataGuy&#8217;s No Fluff Newsletter</span></a></p><div><hr></div><h3>2. Broadcast Hash Join (Small&#8211;Large Optimization)</h3><p>If one side of a join is small enough to fit in memory on every executor, a <strong>broadcast hash join</strong> eliminates virtually all skew risk. By broadcasting the smaller dataset to every executor, Spark can join on the large side without shuffling it by key. Even a &#8220;hot&#8221; key on the large side is processed in parallel across many tasks, because each task already has the complete, in-memory copy of the smaller table.</p><h4>How It Works</h4><ol><li><p><strong>Spark Optimizer Picks It Automatically</strong> (if small side &#8804; 10 MB by default):</p><ul><li><p>Controlled by:</p><p><code>spark.sql.autoBroadcastJoinThreshold </code>(Default: <code>10MB</code>)</p></li><li><p>Raise this value to allow larger small tables but not more than 1 GB practically </p></li></ul></li><li><p><strong>Explicitly Force Broadcast in DataFrame Code:</strong></p></li></ol><pre><code>from pyspark.sql.functions import broadcast

result = largeDF.join(broadcast(smallDF), "joinKey")</code></pre><ol start="3"><li><p><strong>Spark SQL Hint:</strong></p></li></ol><pre><code>SELECT /*+ BROADCAST(s) */ *
FROM large l
JOIN small s
  ON l.joinKey = s.joinKey;</code></pre><p>Since the large dataset is not shuffled by key, no single reducer processes all rows for a heavy key. Instead, each task hashes the broadcasted small side in-memory, and streams its assigned partitions of the large side through that hash.</p><h3>Pros &amp; Cons</h3><ul><li><p><strong>Pros:</strong></p><ul><li><p><strong>No shuffle</strong> on large side&#8212;completely eliminates skew related to the small side.</p></li><li><p>Simple to implement via <code>broadcast()</code> hints or by tuning <code>spark.sql.autoBroadcastJoinThreshold</code>.</p></li><li><p>Dramatic speedups when one side is truly small and the other side has a hot key.</p></li></ul></li><li><p><strong>Cons:</strong></p><ul><li><p>The &#8220;small&#8221; table must <strong>fit comfortably</strong> in each executor&#8217;s memory. If it&#8217;s too large (hundreds of MB), broadcasting can create memory pressure or OOM.</p></li><li><p>Not applicable when <strong>both</strong> sides are large.</p></li><li><p>Total cluster memory usage for the small table = (# executors) &#215; (size of small table).</p></li></ul></li></ul><div><hr></div><h3>3. Handling Skewed Keys Separately (Divide &amp; Conquer)</h3><p>If you know exactly which key(s) are skewed, you can <strong>split your data into two subsets</strong>&#8212;the skewed-key subset and the &#8220;rest&#8221;&#8212;process them separately, then recombine</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!IBuf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IBuf!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 424w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 848w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 1272w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!IBuf!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png" width="1200" height="1356.5217391304348" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f95c396b-5af1-4412-867e-41e02383a572_1035x1170.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f9ea8026-d737-41ec-bed7-fa8b1716e8b6_1035x1170.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:1170,&quot;width&quot;:1035,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:129951,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/165303261?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff9ea8026-d737-41ec-bed7-fa8b1716e8b6_1035x1170.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" srcset="https://substackcdn.com/image/fetch/$s_!IBuf!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 424w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 848w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 1272w, https://substackcdn.com/image/fetch/$s_!IBuf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff95c396b-5af1-4412-867e-41e02383a572_1035x1170.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">caption...</figcaption></figure></div><h4>How It Works</h4><ol><li><p><strong>Split Each Dataset into &#8220;Skewed&#8221; vs. &#8220;Rest&#8221;:</strong></p></li></ol><pre><code>skewed_keys = ["USA"]

# Dataset A (large or small, doesn&#8217;t matter)
A_skew   = A.filter(F.col("country") == "USA")
A_rest   = A.filter(F.col("country") != "USA")

# Dataset B
B_skew   = B.filter(F.col("country") == "USA")
B_rest   = B.filter(F.col("country") != "USA")
</code></pre><ol start="2"><li><p><strong>Join the &#8220;Rest&#8221; Subsets Normally:</strong></p></li></ol><pre><code>main_join = A_rest.join(B_rest, "country")</code></pre><p>Since &#8220;USA&#8221; is removed, these partitions will be balanced&#8212;assuming no other keys are extremely skewed.</p><ol start="3"><li><p><strong>Join the &#8220;Skewed&#8221; Subsets Separately with an Optimized Strategy:</strong></p></li></ol><ul><li><p>If <code>B_skew</code> is small enough, <strong>broadcast</strong> it:</p></li></ul><pre><code>skew_join = A_skew.join(broadcast(B_skew), "country")</code></pre><ul><li><p>Otherwise, you could <strong>salt</strong> only the &#8220;USA&#8221; key (as shown above) or use any other technique.</p></li></ul><ol start="4"><li><p><strong>Union the Two Results:</strong></p></li></ol><pre><code>final_result = main_join.unionByName(skew_join)</code></pre><h4>Pros &amp; Cons</h4><ul><li><p><strong>Pros:</strong></p><ul><li><p><strong>Simplicity</strong>: Process the skewed key in isolation; non-skewed data is untouched.</p></li><li><p>You choose exactly how to handle the problematic key (e.g., broadcast, salt, or extra resources).</p></li><li><p>No need to change logic for the majority of keys.</p></li></ul></li><li><p><strong>Cons:</strong></p><ul><li><p>Requires an extra read/scan (filter) on each dataset&#8212;though filter is usually cheap.</p></li><li><p>Increases job complexity: two join operations instead of one.</p></li><li><p>If more than one key is skewed, you must repeat this process for each key or group of keys&#8212;still subject to skew within that sub&#8208;subset.</p></li><li><p>Must identify skewed key(s) beforehand.</p></li></ul></li></ul><h3>4. <strong>Salting Every Key (Uniform Distribution Across N Buckets)</strong></h3><p>In real-world joins&#8212;especially at scale&#8212;any single key with extremely high cardinality (for example, a superstar YouTuber like &#8220;mr_beast&#8221;) can overwhelm one partition, leading to severe performance bottlenecks. While you might compensate by detecting and salting just that one &#8220;hot&#8221; key, a more robust approach is to uniformly salt every <code>youtuber_id</code>, ensuring that even unexpected popularity spikes are handled gracefully. By applying a deterministic salt to all keys, each <code>youtuber_id</code> is augmented with a bucket index, distributing its rows across up to N partitions. Matching rows from both tables still join correctly because the salt is derived deterministically from the join key (and potentially another column like <code>video_id</code>).</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!jIDC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!jIDC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 424w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 848w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 1272w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!jIDC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png" width="513" height="1187" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/82c2e15d-e050-4746-bdf0-9b36a62214f9_513x1187.png&quot;,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1187,&quot;width&quot;:513,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:88122,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/165303261?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F82c2e15d-e050-4746-bdf0-9b36a62214f9_513x1187.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!jIDC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 424w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 848w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 1272w, https://substackcdn.com/image/fetch/$s_!jIDC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5686bc8d-98f9-4765-a448-fe64b18f3080_513x1187.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><div><hr></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h4>How It Works</h4><ol><li><p><strong>Choose a Salt Count (N)</strong></p><ul><li><p>Decide how many buckets to split <strong>every</strong> <code>youtuber_id</code> into (for example, <code>N = 10</code>).</p></li><li><p>Aim for each salted partition to be on the order of 100&#8211;300 MB (or your target). Use the Spark UI&#8217;s &#8220;Shuffle Read Size by Task&#8221; to gauge ideal bucket size.</p></li></ul></li><li><p><strong>Compute a Deterministic Salt for Each Row</strong></p><ul><li><p>For each row, compute:</p></li></ul></li></ol><pre><code>salt = abs(hash(concat(youtuber_id, video_id))) % N
salted_youtuber = CONCAT(youtuber_id, "_", salt)</code></pre><p>This ensures:</p><ul><li><p><strong>All rows belonging to the same (youtuber_id, video_id)</strong> produce the same <code>(salted_youtuber, video_id)</code> pair in both tables.</p></li><li><p><strong>Every youtuber_id is split</strong> across up to N buckets&#8212;popular keys will spread widely, less-popular keys may cluster in fewer buckets if they have fewer distinct <code>video_id</code> values.</p></li></ul><p><strong>3. Salt Both Tables in PySpark</strong></p><pre><code>import pyspark.sql.functions as F

N = 10

def saltAllExpr(yid_col, vid_col):
    """
    Deterministic salt for every (youtuber_id, video_id):
    salted_youtuber = youtuber_id + "_" + (abs(hash(youtuber_id || video_id)) % N)
    """
    return F.concat(
        yid_col,
        F.lit("_"),
        (F.abs(F.hash(F.concat(yid_col, vid_col))) % N).cast("string")
    )

# Salt the IMPRESSIONS table
salted_impressions = impressions.withColumn(
    "salted_youtuber",
    saltAllExpr(F.col("youtuber_id"), F.col("video_id"))
)

# Salt the CLICKS table
salted_clicks = clicks.withColumn(
    "salted_youtuber",
    saltAllExpr(F.col("youtuber_id"), F.col("video_id"))
)
</code></pre><ul><li><p>Every <code>(youtuber_id, video_id)</code> pair gets a consistent bucket index in <code>[0..9]</code>.</p></li><li><p>For <code>"mr_beast"</code> with <code>video_id = "abc123"</code>, <code>salted_youtuber = "mr_beast_4"</code> (for example).</p></li><li><p>A different video <code>"xyz789"</code> might map to <code>"mr_beast_7"</code>.</p></li><li><p>A less-popular youtuber with only one or two videos may occupy only 1&#8211;2 buckets&#8212;but that&#8217;s fine.</p></li></ul><ol start="4"><li><p><strong>Perform the Salted Join</strong></p></li></ol><pre><code>joined = salted_impressions.alias("imp").join(
    salted_clicks.alias("clk"),
    on=[ "salted_youtuber", "video_id" ],
    how="inner"
)
</code></pre><ul><li><p><strong>Before</strong> salting: All <code>"mr_beast"</code> rows (across any <code>video_id</code>) would land in a single partition.</p></li><li><p><strong>After</strong> salting: Each distinct <code>(youtuber_id, video_id)</code> combination goes to a bucket <code>youtuber_id_&lt;0..9&gt;</code>, so <code>"mr_beast"</code> content spreads across up to 10 partitions&#8212;one per bucket index.</p></li><li><p>This eliminates a single &#8220;hot&#8221; partition for <code>"mr_beast"</code>.</p></li></ul><ol start="5"><li><p><strong>Spark SQL Equivalent</strong></p></li></ol><pre><code>WITH salted_impressions AS (
  SELECT
    *,
    CONCAT(
      youtuber_id,
      '_',
      CAST(ABS(hash(CONCAT(youtuber_id, video_id))) % 10 AS STRING)
    ) AS salted_youtuber
  FROM impressions
),
salted_clicks AS (
  SELECT
    *,
    CONCAT(
      youtuber_id,
      '_',
      CAST(ABS(hash(CONCAT(youtuber_id, video_id))) % 10 AS STRING)
    ) AS salted_youtuber
  FROM clicks
)
SELECT
  imp.*,
  clk.viewer_id,
  clk.timestamp AS click_timestamp
FROM salted_impressions imp
JOIN salted_clicks clk
  ON imp.salted_youtuber = clk.salted_youtuber
 AND imp.video_id       = clk.video_id;
</code></pre><ul><li><p>Each <code>(youtuber_id, video_id)</code> deterministically maps to one of 10 buckets.</p></li><li><p>Even if <code>"mr_beast"</code> has 100 videos, those 100 distinct <code>(youtuber_id, video_id)</code> pairs spread across up to 10 buckets.</p></li></ul><div><hr></div><h4>Pros &amp; Cons</h4><p><strong>Pros:</strong></p><ul><li><p><strong>Uniform Distribution for All Keys</strong><br>Any youtuber with many videos&#8212;like <code>"mr_beast"</code>&#8212;will spread its rows across N buckets.</p></li><li><p><strong>No Conditional Logic on &#8220;Hot&#8221; Keys</strong><br>You don&#8217;t need to first identify which youtuber is skewed; every key is salted uniformly.</p></li><li><p><strong>Deterministic</strong><br>Matching <code>(youtuber_id, video_id)</code> always end up in the same bucket on both sides, so joins remain correct.</p></li><li><p><strong>Works for Any Join</strong><br>Applies whether one or both tables are large&#8212;no reliance on broadcast.</p></li></ul><p><strong>Cons:</strong></p><ul><li><p><strong>Extra Shuffle Volume</strong><br>Every row in both tables carries an extra salted key, and all rows must shuffle by <code>(salted_youtuber, video_id)</code>.</p><ul><li><p>If a youtuber is lightly used, its rows may end up in only one or two buckets&#8212;but they still shuffle.</p></li><li><p>If data was quite balanced originally, salting &#8220;everything&#8221; may introduce more shuffle than strictly necessary.</p></li></ul></li><li><p><strong>Choosing the Right N Is Crucial</strong></p><ul><li><p>If N is too small, heavily skewed keys (like <code>"mr_beast"</code>) still concentrate too much data in one bucket.</p></li><li><p>If N is too large, you create many small partitions, which increases scheduler overhead.</p></li></ul></li><li><p><strong>Need to Drop </strong><code>salted_youtuber</code><strong> After the Join</strong><br>If you only care about the original key (<code>youtuber_id</code>), drop <code>salted_youtuber</code> once the join is done.</p></li></ul><div><hr></div><h4>When to Use &#8220;Salt Everything&#8221;</h4><p>Use this approach when:</p><ul><li><p><strong>You don&#8217;t know in advance</strong> which keys will be skewed (e.g., an Uber driver of the week suddenly goes viral, or any youtuber&#8217;s popularity spikes).</p></li><li><p><strong>Data volume is large and dynamic</strong>, and you want a one&#8208;size&#8208;fits&#8208;all solution rather than conditionally checking for hot keys.</p></li><li><p><strong>You want consistent distribution</strong> for all <code>(youtuber_id, video_id)</code> pairs without maintaining a list of skewed keys.</p></li></ul><div><hr></div><h2>Additional Considerations (Ideally; try to avoid getting into these)</h2><ul><li><p><strong>Tuning Shuffle Partitions</strong></p></li></ul><p>Adjust: spark.sql.shuffle.partitions to a value higher than the default (200), ideally a few times your cluster&#8217;s total cores, so that partitions remain small. Too many partitions cause scheduler overhead; too few cause each partition to be large.</p><ul><li><p><strong>Speculative Execution:</strong>  Enabling speculation (spark.speculation=true) can alleviate the impact of skew by attempting to re-run straggling tasks on another executor. This doesn&#8217;t fix the skew itself, but if a task is slow (perhaps due to skew or maybe a slow node), Spark will launch a duplicate task elsewhere. Whichever finishes first wins. In a skew scenario, a speculated task is still doing the same heavy work, so it won&#8217;t magically complete faster unless the original executor was anomalously slow. However, speculation can sometimes help if, say, one executor was busy with garbage collection while another could do the work faster &#8211; it provides a safety net for stragglers. It&#8217;s generally good to enable in large clusters, but note it causes extra resource usage for those duplicate tasks.</p></li><li><p><strong>Monitoring with the Spark UI</strong></p><ul><li><p>In the <strong>Stages</strong> tab, expand a SQL stage and click <strong>Physical Plan</strong>.</p></li><li><p>Under <strong>Shuffle Read Size by Task</strong>, look for a single bar that towers over the others&#8212;that&#8217;s your skewed partition.</p></li><li><p>Use those insights to decide between AQE or manual salting.</p></li></ul></li><li><p><strong>Filtering Out Problematic Rows</strong></p><ul><li><p>If certain values (e.g., <code>NULL</code> or outliers) cause extreme skew but are not essential, you can drop them before the join, Only do this if you can accept losing those rows from the result.</p></li></ul></li></ul><pre><code>cleanedDF = originalDF.filter(F.col("country").isNotNull())</code></pre><ul><li><p><strong>Use Skew Hints (Spark 3.4+)</strong></p><ul><li><p>You can annotate specific keys as skewed in a Spark SQL query so that Spark generates a plan that avoids shuffling them into a single reducer</p></li></ul></li><li><p><strong>Memory and Shuffle Tuning:</strong> While not fixing skew, you might need to adjust memory configs to handle it. For instance, if one partition is huge, increasing executor memory or shuffle buffer sizes (spark.shuffle.spill.numElementsForceSpillThreshold, spark.shuffle.file.buffer, etc.) won&#8217;t solve the skew but might prevent OOM crashes by allowing Spark to spill gracefully. Similarly, ensure spark.memory.fraction or spark.sql.autoBroadcastJoinThreshold are set such that the heavy data can be handled (e.g., give more memory to shuffle if needed). These are more about coping with skew than removing it.</p></li><li><p><strong>Adaptive Query Execution (AQE):</strong> As discussed, ensure spark.sql.adaptive.enabled=true (should be default on modern Spark) and spark.sql.adaptive.skewJoin.enabled=true. You can adjust spark.sql.adaptive.skewJoin.skewedPartitionFactor (default 5) and ...skewedPartitionThresholdInBytes (default 256MB) to tune how aggressively Spark flags partitions as skewed Lowering these values makes Spark split smaller skews, but setting them too low might cause unnecessary splitting. In Spark 3.3+, if you really want to force skew join handling, spark.sql.adaptive.forceOptimizeSkewedJoin=true will apply the optimization even if it might add extra shuffle overhead.</p></li></ul><div><hr></div><h2>Tackling Skew in Spark Aggregations: From Simple Sums to Semi-Additive Metrics</h2><p>Aggregation operations like <code>groupBy().agg()</code> in Spark can become major performance bottlenecks when data is skewed. A small number of high-cardinality keys can result in uneven workload distribution, where one reducer is overloaded while others remain idle. While Spark's map-side partial aggregation helps, it alone can&#8217;t prevent reducers from becoming overwhelmed when skewed keys funnel massive data into single tasks.</p><p>In this deep dive, we&#8217;ll explore practical patterns to mitigate skew during aggregations, especially focusing on semi-additive metrics like averages, distinct counts, and ratios&#8212;metrics that can't always be merged as trivially as sums or counts.</p><div><hr></div><h3>1. Two-Stage Aggregation with Salting</h3><p>The most effective method for aggregation skew is a two-stage salted aggregation. In the first stage, you add a salt (random or deterministic) to the key, distributing rows across more groups. In the second stage, you aggregate these partials back to the original key.</p><h4>How It Works:</h4><ul><li><p>Add a new column (e.g., <code>salt = rand() % N</code>) to the grouping key</p></li><li><p>Group by <code>(key, salt)</code> and compute partial aggregates</p></li><li><p>Re-group by <code>key</code> to merge the partials</p></li></ul><h4>PySpark Example:</h4><pre><code><code>from pyspark.sql.functions import col, rand, floor, sum as _sum, count as _count

N = 10
salted_df = df.withColumn("salt", floor(rand() * N))

# First stage: partial aggregation
partial = salted_df.groupBy("key", "salt").agg(
    _sum("value").alias("partial_sum"),
    _count("value").alias("partial_count")
)

# Second stage: final aggregation
final = partial.groupBy("key").agg(
    _sum("partial_sum").alias("total_sum"),
    _sum("partial_count").alias("total_count")
)</code></code></pre><p>This works well for <strong>semi-additive metrics</strong> like average:</p><pre><code><code>final.withColumn("avg", col("total_sum") / col("total_count"))</code></code></pre><h4>Pros:</h4><ul><li><p>Greatly reduces skew on hot keys</p></li><li><p>Flexible: works for sums, counts, averages, etc.</p></li></ul><h4>Cons:</h4><ul><li><p>Not directly applicable to non-associative metrics (like median, percentile)</p></li><li><p>Requires an extra stage of aggregation and data shuffle</p></li><li><p>You must choose N carefully</p></li></ul><div><hr></div><h3>2. Favor Combiner-Friendly DataFrame Operations</h3><p>In the DataFrame API, Spark automatically performs map-side combine for aggregation functions like <code>sum</code>, <code>count</code>, and <code>avg</code>. This significantly reduces data shuffled across the network.</p><h4>Best Practices:</h4><ul><li><p>Avoid collecting all values per key using <code>collect_list</code> or <code>collect_set</code> unless needed</p></li><li><p>Prefer built-in aggregation functions that support partial aggregation</p></li></ul><h4>Example:</h4><pre><code><code>df.groupBy("user_id").agg(
    _sum("impressions").alias("total_impressions"),
    _count("clicks").alias("click_count")
)</code></code></pre><p>This automatically benefits from map-side combine.</p><div><hr></div><h3>3. Hierarchical or Incremental Aggregation</h3><p>Instead of grouping by the final key directly, first group on a <strong>compound key</strong> (e.g., key + day), then roll up to the main key. This acts like salting but uses a meaningful secondary attribute.</p><p>Example: Group by <code>(customer_id, date)</code>, then group again by <code>customer_id</code>.</p><h4>Pros:</h4><ul><li><p>Uses natural structure in data</p></li><li><p>More interpretable than random salt</p></li></ul><h4>Cons:</h4><ul><li><p>Only works if meaningful secondary keys exist</p></li><li><p>Adds complexity to query logic</p></li></ul><div><hr></div><h3>4. Isolate Skewed Keys</h3><p>When just a few keys are skewed (e.g., "mr_beast" on YouTube), isolate them:</p><ul><li><p>Filter the skewed keys</p></li><li><p>Aggregate them separately</p></li><li><p>Aggregate the rest normally</p></li><li><p>Union results</p></li></ul><h4>Pros:</h4><ul><li><p>Simple logic for non-skewed keys</p></li><li><p>You can fine-tune treatment of skewed keys</p></li></ul><h4>Cons:</h4><ul><li><p>Manual, doesn&#8217;t scale to many skewed keys</p></li><li><p>Separate logic paths = more complexity</p></li></ul><div><hr></div><h3>Special Note: Semi-Additive Metrics</h3><p>For metrics like <strong>averages</strong>, <strong>ratios</strong>, or <strong>distinct counts</strong>, special care is needed:</p><ul><li><p><strong>Average:</strong> Use partial sums and counts, then divide</p></li><li><p><strong>Ratios:</strong> Keep numerator/denominator separate, aggregate both, then divide</p></li><li><p><strong>Count Distinct:</strong> Use <code>approx_count_distinct()</code> for scalable approximations</p></li></ul><p>Some metrics cannot be split and recombined (e.g., exact percentiles). In those cases, use isolation or rethink the need for exact aggregation.</p><div><hr></div><h3>Final Thoughts for Aggregates</h3><p>Aggregation skew is an invisible killer in Spark jobs. The best strategy is proactive design: salt heavy keys, use partial aggregation, and always choose APIs that favor combiners. With these patterns, even semi-additive or tricky metrics can be made scalable at massive volumes.</p><p>If you're dealing with skew, don't just throw resources at it. Design for it.</p><h2>Summary of Recommendations for Joins</h2><ul><li><p><strong>Recommended first Adaptive Query Execution (AQE)</strong>: Zero code changes, runtime splitting for any sort-merge or shuffle-hash join.</p></li><li><p><strong>Broadcast Hash Join</strong></p><ul><li><p><strong>When one side is small</strong> (&#8804; 10 MB by default to 1GB). Hint in DataFrame or SQL.</p></li><li><p>Avoids all skew because no shuffle on the small side.</p></li></ul></li><li><p><strong>Salting the Key</strong></p><ul><li><p><strong>When neither side is small</strong>, but you know exactly which key(s) dominate.</p></li><li><p>Manual, but guaranteed to split a hot key across N partitions.</p></li></ul></li><li><p><strong>Handle Skewed Keys Separately</strong></p><ul><li><p><strong>When you can isolate a small number of skewed keys</strong>.</p></li><li><p>Split data into &#8220;skewed&#8221; vs. &#8220;rest&#8221;; optimize skewed subset, then union.</p></li></ul></li></ul><p>By applying these strategies in order&#8212;starting with AQE&#8217;s automatic handling, then broadcasting small tables, and, if necessary, resorting to manual salting or custom partitioning&#8212;you can eliminate or dramatically reduce skew-related stragglers in your Spark join operations. Choose the approach that best fits your cluster&#8217;s Spark version, data volume, and the complexity you&#8217;re willing to maintain.</p><p></p><p></p><h2>Architectural Patterns and Data Design to Reduce Skew</h2><p>Beyond individual Spark jobs, you can sometimes address skew at the <strong>data architecture level</strong> to prevent issues before they happen:</p><ul><li><p><strong>Skew-Aware Data Partitioning:</strong> As discussed, designing how data is partitioned or bucketed in storage can reduce skew. For example, if you frequently group or join by a key that&#8217;s skewed, consider storing the data partitioned by that key <em>and a secondary split</em>. A real-world practice: if one category of data is 90% of the dataset, you might partition that category&#8217;s data further by another field. Essentially, <strong>acknowledge the skewed key in your data model</strong> and subdivide it. This could mean separate tables or partitions for heavy categories. When you process the data, you then handle those partitions in parallel. The benefit is you're not repeatedly shuffling the entire dataset to discover the same skew; you&#8217;ve pre-divided it.</p></li><li><p><strong>Pre-Aggregation / Summaries:</strong> If your use-case allows, maintain rolling aggregates for skewed keys. For instance, if one user has a million events per day and you always compute their daily total, consider updating a running total for that user in a database or a separate file, rather than recomputing from scratch in each Spark job. By reducing the raw data volume for that key through prior aggregation, you avoid the huge shuffle for that key at query time. This is applicable in pipelines where data is appended incrementally (common in streaming or daily ETL). You trade off storage (keeping summary data) for performance.</p></li><li><p><strong>Alternate Algorithms:</strong> In some cases, you might choose a different approach entirely. For example, for a skewed distinct count, using an approximate algorithm (like HyperLogLog) per partition can avoid bringing all data together. Or using Bloom filters to reduce data before join (filter out records that won&#8217;t match). These are specific to certain problems but can mitigate skew by cutting down the data processed.</p></li><li><p><strong>Scaling Up Hot Data Separately:</strong> This is more of an infrastructure pattern &#8211; if one key&#8217;s data is massive, you could route that to a specialized system. For instance, maybe that one key corresponds to a particular customer &#8211; you could give them their own dedicated processing or database, and exclude those records from the general Spark workflow. It&#8217;s an extreme solution, but sometimes separating concerns (multi-tenancy isolation) helps if one tenant&#8217;s data skews the whole system.</p></li><li><p><strong>Monitoring and Iteration:</strong> A softer &#8220;pattern&#8221; is to continuously monitor your Spark job metrics (especially in Spark UI or via logs) to catch skew issues and then adjust. Over time, you may adapt your data ingestion or job logic to handle new skewed keys as data grows. For example, if a new user becomes a power user, you might add them to the &#8220;skewed key list&#8221; for salting. In practice, skew patterns can change, so an architecture that can adjust (or a code path that can automatically detect top N heavy keys and treat them differently) can be very useful.</p></li></ul><p>In essence, architectural approaches are all about <strong>not putting all eggs in one basket</strong> &#8211; distribute data smartly from the ground up, and treat the outliers with special care. This reduces the burden on any single Spark job to handle an immense skew on the fly.</p><h2>References</h2><ul><li><p><a href="https://docs.databricks.com/aws/en/optimizations/aqe#dynamically-handle-skew-join">https://docs.databricks.com/aws/en/optimizations/aqe#dynamically-handle-skew-join</a></p></li><li><p><a href="https://medium.com/@suffyan.asad1/handling-data-skew-in-apache-spark-techniques-tips-and-tricks-to-improve-performance-e2934b00b021">https://medium.com/@suffyan.asad1/handling-data-skew-in-apache-spark-techniques-tips-and-tricks-to-improve-performance-e2934b00b021</a></p></li><li><p><a href="https://www.databricks.com/discover/pages/optimize-data-workloads-guide">https://www.databricks.com/discover/pages/optimize-data-workloads-guide</a></p></li><li><p><a href="https://www.dataengi.com/post/2019/02/06/spark-data-skew-problem/#:~:text=We%20can%20reduce%20data%20skew,impact%20of%20data%20skew%20before">https://www.dataengi.com/post/2019/02/06/spark-data-skew-problem/#:~:text=We%20can%20reduce%20data%20skew,impact%20of%20data%20skew%20before</a></p></li><li><p><a href="https://spark.apache.org/docs/3.5.3/sql-performance-tuning.html#:~:text=,3.0.0">https://spark.apache.org/docs/3.5.3/sql-performance-tuning.html#:~:text=,3.0.0</a></p></li></ul><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Why Your PySpark UDF Is Slowing Everything Down]]></title><description><![CDATA[An in-depth exploration of architecture, execution flow, bottlenecks, and optimization strategies for PySpark UDFs]]></description><link>https://www.canadiandataguy.com/p/why-your-pyspark-udf-is-slowing-everything</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/why-your-pyspark-udf-is-slowing-everything</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Thu, 24 Apr 2025 22:39:47 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!21cQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>1. Introduction</h2><p>PySpark&#8217;s User Defined Functions (UDFs) empower developers to inject custom Python logic into Spark DataFrames. They feel like a convenient escape hatch when built-in SQL functions don&#8217;t cut it. However, under the hood, each UDF invocation triggers a complex ballet of inter-process communication, serialization, and single-threaded Python loops. This blog peels back each layer of that architecture to reveal why PySpark UDFs can become a massive performance drain &#8212; and then walks through concrete alternatives and optimizations to keep your jobs blazing fast.</p><div><hr></div><h2>2. The Problem with PySpark UDFs</h2><p>When you sprinkle UDF calls across your Spark SQL or DataFrame pipeline, you&#8217;re effectively handing off portions of your query plan to a &#8220;black box&#8221; Python function. That comes at a steep cost:</p><h3>2.1 Catalyst Optimizer Becomes Blind</h3><ul><li><p><strong>No predicate pushdown:</strong> Spark&#8217;s Catalyst optimizer can&#8217;t inspect or reorder the logic inside your UDF, so it abandons optimizations like pushing filters down to data sources.</p></li><li><p><strong>No whole-stage code generation:</strong> The code-gen engine can&#8217;t fuse your UDF into JVM bytecode, so you lose out on compiler-level speed gains.</p></li></ul><h3>2.2 Serialization/Deserialization Overhead</h3><ul><li><p><strong>Row-by-row data shuffling:</strong> Each row must be marshalled from the JVM heap into a Python object, sent over a local socket, then converted back. After your Python code runs, the result takes the reverse path back into the JVM.</p></li><li><p><strong>Millions of crossings:</strong> With millions (or even billions) of rows, that boundary-crossing cost balloons.</p></li></ul><h3>2.3 Single-Threaded Python Execution</h3><ul><li><p><strong>Global Interpreter Lock (GIL):</strong> Your UDF runs in a standard CPython process under a single core. All per-row work happens sequentially.</p><p>ide the UDF.</p></li></ul><h3>2.4 Memory and Stability Risks</h3><ul><li><p><strong>Python OOMs:</strong> Unlike JVM operations, Spark doesn&#8217;t manage Python worker memory. Processing large batches can crash with out-of-memory errors.</p></li><li><p><strong>Uncaught exceptions:</strong> A bug in your UDF can fail an entire Spark task. Null handling, pickling errors, and non-serializable closures often catch teams by surprise.</p></li></ul><div><hr></div><h2>3. Under the Hood: PySpark&#8217;s Dual-Runtime Architecture</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!21cQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!21cQ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 424w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 848w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 1272w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!21cQ!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png" width="1200" height="532.4175824175824" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/62cbed82-2c4b-48a4-b0c0-5d53d50e2737_3840x1705.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:646,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:282499,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/162008971?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F62cbed82-2c4b-48a4-b0c0-5d53d50e2737_3840x1705.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!21cQ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 424w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 848w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 1272w, https://substackcdn.com/image/fetch/$s_!21cQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69a18ec1-e86f-4e35-9a40-4a0512545c0f_3840x1705.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Py4J is a communication bridge/library that lets Python and Java interoperate by exchanging objects over sockets. In Spark, it powers two key workflows: setting up the Python <code>SparkContext</code> and converting data types in PySpark SQL. When you start a PySpark session, Py4J opens a socket connection between your Python driver and the underlying Java driver. Later, whenever Spark SQL operations run, Py4J translates Python types into their Java equivalents (and back) so the Python API can seamlessly drive the JVM-based SQL engine. Under the hood, every Python UDF invocation follows this path:</p><pre><code>Python Driver &#8594; SparkContext &#8594; Py4J &#8594; JVM &#8594; JavaSparkContext  </code></pre><p>Because each UDF call must cross this socket boundary, it adds measurable latency to your job.</p><h3>3.1 Py4J: Bridging Python and the JVM</h3><p>At startup, PySpark uses <a href="https://www.py4j.org/">Py4J</a> to:</p><ol><li><p><strong>Connect the Python driver to the JVM driver.</strong></p></li><li><p><strong>Translate data types</strong> between Python and Java during SQL operations and UDF calls.</p></li></ol><p>Every call into Spark SQL or a UDF crosses this bridge &#8212; think of it as a high-latency tunnel for each record.</p><h3>3.2 Driver, Executors, and Python Workers</h3><ol><li><p><strong>Driver (Python process):</strong> You call <code>df.withColumn("foo", my_udf(col("bar")))</code>.</p></li><li><p><strong>JVM Driver:</strong> Receives the UDF registration, plans the query.</p></li><li><p><strong>Executor JVMs:</strong> Spin up separate Python subprocesses per task.</p></li><li><p><strong>Python Workers:</strong> Handle the actual UDF logic on deserialized batches.</p></li></ol><div><hr></div><h2>4. Lifecycle of a PySpark UDF Call</h2><h3>4.1 Registration &amp; Serialization of the Python Function</h3><pre><code>from pyspark.sql.functions import udf
from pyspark.sql.types import StringType

def uppercase(val):
    return val.upper()

uppercase_udf = udf(uppercase, StringType())
</code></pre><ul><li><p><code>_create_udf</code> wraps your Python function into a serializable form and tags it with return types.</p></li><li><p><strong>UDF object</strong> travels in the Spark plan to all executors.</p></li></ul><h3>4.2 Data Flow on Executors</h3><ol><li><p>Executor receives a task partition.</p></li><li><p>JVM serializes partition rows into Arrow or Pickle bytes.</p></li><li><p>Bytes stream over TCP to the Python worker.</p></li><li><p>Python worker deserializes, applies your function row-by-row.</p></li><li><p>Results are serialized back to JVM for further operators.</p></li></ol><h3>4.3 Detailed Serialization Cycle</h3><pre><code>JVM row object
  &#9492;&#9472;serialize&#9472;&#9654; Python bytes
      &#9492;&#9472;deserialize&#9472;&#9654; Python object
           &#9492;&#9472;apply UDF&#9472;&#9654; Python object
                &#9492;&#9472;serialize&#9472;&#9654; Python bytes
                     &#9492;&#9472;JVM bytes
                          &#9492;&#9472;deserialize&#9472;&#9654; JVM row</code></pre><p>Multiply that by every row, every partition, every stage &#8212; and you see why simple operations feel so sluggish.</p><div><hr></div><h2>5. Performance Implications</h2><h3>5.1 Quantifying the Overhead</h3><ul><li><p><strong>Catalyst loss:</strong> 10&#8211;30% longer query planning in UDF-heavy jobs.</p></li><li><p><strong>Serialization tax:</strong> 0.5&#8211;5 ms per row crossing (tested on medium-sized clusters).</p></li><li><p><strong>CPU utilization:</strong> &lt; 25% CPU usage across nodes despite heavy transforms.</p></li></ul><h3>5.2 Real-World Benchmark Example</h3><blockquote><p><strong>Scenario:</strong> Uppercasing a 100 million-row column.</p><ul><li><p><strong>Native Spark SQL:</strong></p></li></ul><pre><code>df.selectExpr("upper(name) as name")</code></pre><p>&#8594; 12 seconds end-to-end</p><ul><li><p><strong>Python UDF:</strong></p></li></ul><pre><code>df.withColumn("name", uppercase_udf("name"))</code></pre><p>&#8594; reorders, serialization, single-thread overhead &#8594; <strong>85 seconds</strong><br><em>7&#215; slower for a trivial transform.</em></p></blockquote><div><hr></div><h2>6. Strategies for Faster Custom Logic</h2><h3>6.1 Leverage Built-in Spark Functions</h3><p>Whenever possible, reach for Spark&#8217;s SQL functions (<code>upper</code>, <code>concat</code>, <code>regexp_replace</code>, etc.) &#8212; they run entirely in the JVM, enjoy whole-stage codegen, and scale across all cores.</p><h3>6.2 Pandas UDFs (Vectorized)</h3><p>Introduced in Spark 2.3, Pandas UDFs batch rows into <code>pandas.Series</code> and use <a href="https://arrow.apache.org/">Apache Arrow</a> for zero-copy transfer.</p><pre><code>from pyspark.sql.functions import pandas_udf
from pyspark.sql.types import StringType
import pandas as pd

@pandas_udf(StringType())
def upper_series(s: pd.Series) -&gt; pd.Series:
    return s.str.upper()

df.withColumn("name", upper_series("name"))
</code></pre><ul><li><p><strong>Batch size:</strong> Typically 8 K&#8211;64 K rows per call</p></li><li><p><strong>Vectorized ops:</strong> Internal loops in C, parallelized across cores in Python worker</p></li><li><p><strong>Results:</strong> 5&#8211;10&#215; speed-up over row-UDFs</p></li></ul><h3>6.3 Scala/Java UDFs</h3><p>If you need custom logic beyond SQL but want JVM speed:</p><ol><li><p><strong>Write a Scala object</strong> implementing <code>UserDefinedFunction</code>.</p></li><li><p><strong>Register it</strong> via <code>spark.udf.registerJava(...)</code>.</p></li><li><p><strong>Invoke</strong> from PySpark as if it were a native function.</p></li></ol><ul><li><p><strong>No Python serialization</strong> needed.</p></li><li><p><strong>Runs inside the executor JVM</strong> with full multi-core utilization.</p></li></ul><h3>6.4 Threading &amp; Parallelism in Python UDFs</h3><p>If you absolutely must call an external API or library row-by-row:</p><ul><li><p><strong>Use multithreading</strong> inside your Python UDF to hide network latency.</p></li><li><p><strong>Batch HTTP calls</strong> where possible.</p></li><li><p><strong>Be cautious</strong>: GIL still applies for CPU-bound work, and thread pools can exhaust memory.</p></li></ul><div><hr></div><h2>7. Common Pitfalls &amp; Debugging Tips</h2><ul><li><p><strong>PicklingError:</strong> Ensure functions and closures reference only top-level functions and serializable objects.</p></li><li><p><strong>Null handling:</strong> Always guard inputs with <code>if v is None: return None</code>.</p></li><li><p><strong>Schema drift:</strong> Explicitly set return types; mismatches lead to confusing errors at shuffle boundaries.</p></li><li><p><strong>Memory leaks:</strong> Monitor Python worker logs for <code>MemoryError</code> and tune <code>spark.python.worker.memory</code>.</p></li></ul><div><hr></div><h2>8. Summary &amp; Best Practices</h2><blockquote><p><em>Our newsletter is 100% free and always will be, but without your claps, comments, or shares, search engines may bury this post forever. A quick <strong>clap</strong> not only tells us this content resonates but also makes sure you (and everyone else) can find it again when it matters most.</em></p></blockquote><ol><li><p><strong>Avoid plain Python UDFs</strong> whenever built-in Spark SQL functions suffice.</p></li><li><p><strong>Prefer Pandas UDFs</strong> for vectorized, batch transforms&#8212;they dramatically reduce boundary crossings via Apache Arrow. In fact, the vectorized nature and rapid Arrow improvements often make Pandas UDFs faster than even Scala/Java UDFs.</p></li><li><p><strong>Consider Scala/Java UDFs</strong> only when you need JVM-native logic that can&#8217;t be expressed in SQL or Pandas UDFs.</p></li><li><p><strong>Design for serializability</strong>: keep UDFs self-contained, stateless, and null-safe.</p></li><li><p><strong>Benchmark early</strong>: compare native vs. Pandas vs. Python vs. Scala/Java UDFs on representative data.</p></li><li><p><strong>Moving forward, hands down use native functions first, then Pandas UDFs in almost all cases.</strong></p></li><li><p><strong>When you must call external APIs inside a UDF loop</strong>, embed threading or async parallelism to help latency&#8212;see this <a href="https://www.youtube.com/watch?v=n9jodzYq1e4">video on parallelization within a loop</a> for an example.</p></li></ol><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!86fC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!86fC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 424w, https://substackcdn.com/image/fetch/$s_!86fC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 848w, https://substackcdn.com/image/fetch/$s_!86fC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 1272w, https://substackcdn.com/image/fetch/$s_!86fC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!86fC!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png" width="1200" height="243.95604395604394" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:296,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:75946,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/162008971?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!86fC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 424w, https://substackcdn.com/image/fetch/$s_!86fC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 848w, https://substackcdn.com/image/fetch/$s_!86fC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 1272w, https://substackcdn.com/image/fetch/$s_!86fC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd7d0814-b5de-4e1a-a4a7-fc74d5717ea9_1670x340.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>By understanding the multi-stage journey of data through the PySpark UDF pipeline &#8212; from JVM serialization, through Python&#8217;s single-threaded interpreter, back to the JVM &#8212; you can make informed choices that balance flexibility with performance. Next time you need custom logic, pause to ask: <em>&#8220;Can I batch or vectorize? &#8221;</em> Your cluster (and your users) will thank you.</p><p><a href="https://www.databricksters.com/p/everything-you-ever-wanted-to-know">To learn more about how to improve things, read our deep dive blog on Pandas UDF. </a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ybZ0!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ybZ0!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ybZ0!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ybZ0!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!ybZ0!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e362a94-9995-4b0b-8814-05005ee33c03_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p> </p><h3>References</h3><ul><li><p>Ganesh, R. &#8220;Is really UDF hitting the performance in PySpark!&#8221; <em>Medium</em>, Jul 5, 2024. <a href="https://medium.com/%40rganesh0203/udf-is-hitting-the-performance-in-pysaprk-817b7e881dd2?utm_source=chatgpt.com">Medium</a></p></li><li><p></p><div id="youtube2-n9jodzYq1e4" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;n9jodzYq1e4&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/n9jodzYq1e4?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div></li><li><p>AWS Documentation. &#8220;Optimize user-defined functions,&#8221; <em>Tuning AWS Glue for Apache Spark</em> (AWS Prescriptive Guidance). <a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/tuning-aws-glue-for-apache-spark/optimize-user-defined-functions.html?utm_source=chatgpt.com">AWS Documentation</a></p></li><li><p>Tang, T. &#8220;Spark functions vs UDF performance?&#8221; <em>Stack Overflow</em>, Mar 5, 2018. <a href="https://stackoverflow.com/questions/38296609/spark-functions-vs-udf-performance?utm_source=chatgpt.com">Stack Overflow</a></p></li><li><p>Databricks. &#8220;Arrow-optimized Python UDFs in Apache Spark&#8482; 3.5,&#8221; <em>Databricks Blog</em>, Aug 26, 2024. <a href="https://www.databricks.com/blog/arrow-optimized-python-udfs-apache-sparktm-35?utm_source=chatgpt.com">Databricks</a></p></li><li><p>&#8220;Why You Should Avoid Using UDFs in PySpark,&#8221; <em>Det.Life Blog</em>, Jan 2024. <a href="https://blog.det.life/why-you-should-avoid-using-udf-in-pyspark-c57558af9d0a?utm_source=chatgpt.com">Data Engineer Things</a></p></li><li><p>Illustrious_Ad4259. &#8220;Are there any major disadvantages in performance for Spark when using PySpark?&#8221; <em>Reddit r/dataengineering</em>, Nov 2021. <a href="https://www.reddit.com/r/dataengineering/comments/qning9/are_there_any_major_disadvantages_in_performance/?utm_source=chatgpt.com">Reddit</a></p></li><li><p>Sen, Soutir. &#8220;PySpark UDFs (User-Defined Functions) &#8211; Complete Guide,&#8221; <em>LinkedIn Article</em>, Dec 2024. <a href="https://www.linkedin.com/pulse/pyspark-udfs-user-defined-functions-complete-guide-soutir-sen-jkd6f?utm_source=chatgpt.com">linkedin.com</a></p></li><li><p>Two Sigma. &#8220;Introducing Pandas UDFs for PySpark,&#8221; <em>Two Sigma Article</em>.</p><p></p></li></ul><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.canadiandataguy.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading CanadianDataGuy&#8217;s No Fluff Newsletter! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Spark Join Strategies Explained: Broadcast Hash Join]]></title><description><![CDATA[Everything You Need to Know About Broadcast Hash Join]]></description><link>https://www.canadiandataguy.com/p/spark-join-strategies-explained-broadcast</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/spark-join-strategies-explained-broadcast</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Mon, 14 Apr 2025 14:00:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apache Spark employs multiple join strategies to efficiently combine datasets in a distributed environment. This guide provides a <strong>zero-to-hero</strong> explanation of the three primary join strategies &#8211; <strong>Broadcast Hash Join (BHJ)</strong>, <strong>Shuffle Hash Join (SHJ)</strong>, and <strong>Sort-Merge Join (SMJ)</strong> &#8211; with a focus on Databricks. We will explore how each strategy works, their execution plans (DAG stages, partitioning, memory and shuffle behavior), and how to tune these joins on Databricks (including relevant configurations like AQE and join hints). A visual cheat sheet and further reading resources are provided at the end.</p><h2>Introduction to Spark Join Strategies</h2><p>In Spark SQL, a <em>join</em> combines two datasets by matching rows on a common key. The way Spark executes the join greatly impacts performance, especially with large data. Spark&#8217;s Catalyst optimizer will choose a join strategy based on data statistics (size of each side, join type, etc.), or you can influence it via hints and settings. The three main join strategies for equi-joins are:</p><ul><li><p><strong>Broadcast Hash Join (BHJ)</strong> &#8211; Broadcasts the entire smaller dataset to all executors, avoiding shuffles for that side&#8203;, Very fast when one side is sufficiently small, analogous to a map-side join in Hadoop&#8203;</p></li><li><p><strong>Shuffle Hash Join (SHJ)</strong> &#8211; Shuffles both datasets on the join key, then builds a hash table on the smaller side of each partition and streams the larger side to find matches&#8203;.</p><p>Avoids the sort step of SMJ but requires enough memory per partition.</p></li><li><p><strong>Sort-Merge Join (SMJ)</strong> &#8211; Shuffles both datasets on the join key and sorts them, then merges sorted partitions to find matches&#8203;. This is Spark&#8217;s default strategy for large data and supports all join types&#8203; . It&#8217;s robust (can spill to disk if needed) but involves heavy network and CPU overhead for sorting.</p></li></ul><p>Each strategy has optimal use cases and pitfalls. In Databricks (which uses Spark under the hood), adaptive query execution (AQE) can dynamically optimize joins (e.g. switching strategies or handling skew) to improve performance&#8203;. We&#8217;ll now dive into each strategy in detail.</p><h2>What is a Broadcast Hash Join (BHJ)?</h2><p>A <strong>Broadcast Hash Join</strong> is an efficient strategy used to join two datasets in Spark when one of them is significantly smaller than the other. Instead of moving data across the network (shuffling) for both sides of the join, Spark copies&#8212;or "broadcasts"&#8212;the entire small dataset to every worker node (executor). Then, each executor performs a local hash join between its partition of the larger dataset and the entire, locally cached, small dataset. This approach helps to avoid expensive network shuffling and the need for sorting on either side of the join.</p><div><hr></div><h3>The Broadcast Process in Detail</h3><p>The broadcast procedure involves:</p><ol><li><p><strong>Collecting the Data:</strong><br>The driver first gathers the entire small dataset and converts it into an efficient in-memory data structure (typically a hash map).</p></li></ol><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!2dL-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!2dL-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!2dL-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png" width="696" height="696" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/796ba06f-0553-4312-834e-611a5f5615af_1024x1024.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1024,&quot;width&quot;:1024,&quot;resizeWidth&quot;:696,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!2dL-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!2dL-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2adbe292-c6f7-49fd-b044-493aa6f8aa5f_1024x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><ol><li><p><strong>Distributing the Data:</strong><br>This hash map is then distributed (broadcast) to all executor nodes, usually via a network distribution algorithm akin to torrent distribution.</p></li><li><p><strong>Utilizing the Broadcast Data:</strong><br>Each executor then uses the broadcasted data to quickly look up matching join keys when processing its partition of the larger dataset.</p></li></ol><p>Understanding these steps is crucial because if any stage fails&#8212;whether due to memory limits on the driver, executor constraints, or even network issues&#8212;the entire query may fail.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!LLu1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!LLu1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 424w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 848w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 1272w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!LLu1!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png" width="1200" height="948.6263736263736" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png&quot;,&quot;srcNoWatermark&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/aaab4992-f0b0-4132-8663-112361f4f830_2432x1922.png&quot;,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:1151,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:549678,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.canadiandataguy.com/i/160914089?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faaab4992-f0b0-4132-8663-112361f4f830_2432x1922.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-large" alt="" srcset="https://substackcdn.com/image/fetch/$s_!LLu1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 424w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 848w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 1272w, https://substackcdn.com/image/fetch/$s_!LLu1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5f11e026-1f7b-465a-9cda-78e18c7cb38d_2432x1922.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h3>When Does Spark Use BHJ?</h3><p>Spark will automatically choose to perform a Broadcast Hash Join under these conditions:</p><ul><li><p><strong>Dataset Size:</strong> One side of the join is smaller than a pre-configured threshold, which is by default 10 MB in open-source Spark. In Databricks environments, this threshold is commonly increased (e.g., ~30 MB with adaptive execution), meaning Databricks can handle moderately larger tables.</p></li><li><p><strong>Join Type:</strong> The join condition is an equality condition (equi-join).</p></li></ul><p>The setting <code>spark.sql.autoBroadcastJoinThreshold</code> controls this threshold and can be adjusted based on available memory and expected performance benefits.</p><p>BHJ works well with these join types:</p><ul><li><p><strong>Supported:</strong> Inner joins, and left, semi, or anti joins (as long as the correct side is broadcast).</p></li><li><p><strong>Limitations:</strong> It is not supported for full outer joins. For right outer joins, only the left table can be broadcast; similarly, in left joins only the right table can be broadcast.</p></li></ul><p>If the join type is not supported by a BHJ, Spark may revert to another join strategy, such as a sort-merge join or a broadcast nested loop join when dealing with non-equi conditions.</p><div><hr></div><h4>Databricks and Adaptive Query Execution (AQE)</h4><p>In Databricks:</p><ul><li><p><strong>Adaptive Query Execution (AQE):</strong> AQE can dynamically convert a sort-merge join into a broadcast hash join if it determines at runtime that one side of the join is smaller than the broadcast threshold.</p></li><li><p><strong>Higher Thresholds:</strong> Databricks&#8217; default setting for auto-broadcast (often <code>spark.databricks.adaptive.autoBroadcastJoinThreshold</code>) may be set higher (e.g., 30 MB) to allow for broadcasting moderately larger tables.</p></li><li><p><strong>Forcing Broadcasts:</strong> Although AQE works automatically, you might sometimes use explicit hints (such as <code>/*+ BROADCAST(table) */</code> in SQL or wrapping a DataFrame with <code>broadcast(df)</code> in PySpark) to ensure the small dataset is broadcast immediately, thereby skipping unnecessary shuffles.</p><p></p></li></ul><div><hr></div><h2>Common Misconception- Order of Joins</h2><p>For optimal join order performance: Perform joins from smallest to largest tables first to minimize data shuffling&#8288;&#8288;&#8203; <strong>However, do broadcast joins last</strong>, even though this seems counterintuitive. This is because:&#8288;&#8288;&#8203;</p><ul><li><p>Broadcast joins don't require shuffles and can be executed efficiently even on large fact tables</p></li><li><p>If broadcast joins are done first, the joined data needs to be shuffled again for later joins</p></li><li><p>By doing broadcast joins last, we avoid having to shuffle that data again.</p></li><li><p>Group together joins that share the same ON clause to reduce shuffling, since the data is already arranged properly</p></li></ul><p></p><h3>Memory and Shuffle Considerations</h3><p>Using BHJ provides tremendous speedups by eliminating the costly shuffle of the larger dataset. However, it comes with some significant memory considerations:</p><ul><li><p><strong>Driver Memory:</strong> The whole small dataset must be collected on the driver before it can be broadcast. The driver has a memory limit, defined by <code>spark.driver.maxResultSize</code>, and exceeding this limit will cause the job to fail.</p></li><li><p><strong>Executor Memory:</strong> Each executor must have enough memory to store the broadcasted dataset along with its own processing workload. The available memory on the node with the smallest capacity is the practical limit.</p></li><li><p><strong>Timeout and Overload Risks:</strong> If the dataset is even moderately large, broadcasting it might overwhelm the driver or network, leading to out-of-memory (OOM) errors or timeouts. For example, while Databricks has even seen broadcasts for datasets up to a few GB in size, one must exercise extreme caution when attempting such operations.</p></li><li><p><strong>Compression Differences:</strong> Note that the on-disk size of data (like Parquet files in Delta tables) might be much smaller than the in-memory representation. Spark&#8217;s decisions are based on disk size, so actual in-memory data after decompression might far exceed the expected limits.</p></li></ul><p>To address these issues, you can either disable auto-broadcast by setting <code>spark.sql.autoBroadcastJoinThreshold</code> to -1 or lower the threshold to ensure no large table is inadvertently broadcasted. On Databricks with the Photon engine, <strong>executor-side broadcasts</strong> further alleviate pressure on the driver because the broadcast process does not rely solely on the driver's resources.</p><div><hr></div><h3>Performance Recommendations</h3><ul><li><p><strong>When to Use BHJ:</strong><br>Use Broadcast Hash Join when one dataset is much smaller than the other. This is commonly the case when joining large fact tables with much smaller dimension tables or when one table is the result of a selective filter.</p></li><li><p><strong>Why Forcing Broadcasts:</strong><br>While Spark&#8217;s optimizer may choose to broadcast small datasets automatically, in complex queries or skewed datasets the statistics might not be accurate. In those cases, manually forcing a broadcast using explicit hints ensures that the join operation skips the shuffle stage and executes as a broadcast join.</p></li><li><p><strong>Caution in Production:</strong><br>Forcing broadcasts in ad hoc queries or development is acceptable. However, in production workloads, it&#8217;s important to validate the dataset size at runtime. This can be done by checking record counts and partition sizes to avoid overloading any executor or the driver. Monitoring the Spark UI is critical to ensure broadcasts do not result in GC (garbage collection) pressure or other resource issues.</p></li></ul><div><hr></div><h3>Example SQL with Broadcast Hint</h3><p>To explicitly force a broadcast in SQL, you can include the following hint in your query:</p><pre><code>SELECT /*+ BROADCASTJOIN(table1)*/ table1.id, table1.col, table2.id, table2.int_col FROM table1 JOIN table2 ON table1.id = table2.id;</code></pre><p>In the physical plan, you will see a <code>BroadcastExchange</code> operator for the small table along with a <code>BroadcastHashJoin</code> operator, indicating that the join was executed without additional shuffling of the large table.</p><pre><code>SQL Query : 
select /*+ BROADCASTJOIN(table1)*/ table1.id,table1.col,table2.id,table2.int_col from table1 join table2 on table1.id = table2.id

Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false\n
  +- BroadcastHashJoin [id#271L], [id#286L], Inner, BuildLeft, false
 :- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint,   false]),false), [id=#955]
       :  +- Filter isnotnull(id#271L)
       :     +- Scan ExistingRDD[id#271L,col#272]
               +- Filter isnotnull(id#286L)
                 +- Scan ExistingRDD[id#286L,int_col#287L]

Number of records processed: 799541
Querytime : 15.35717314 seconds</code></pre><div><hr></div><h3>Key Pitfalls and Best Practices</h3><ul><li><p><strong>Avoid Broadcasting Too Much Data:</strong><br>Never broadcast a table that is too large (generally over 1GB) as it can overwhelm the driver and executors. Spark has a hard limit (roughly 8GB) on what it can broadcast.</p></li><li><p><strong>Watch for Non-Equi Joins:</strong><br>BHJ only supports joins using equality conditions (equi-joins). When using non-equi join conditions (such as range conditions), BHJ cannot be applied.</p></li><li><p><strong>Force with Caution:</strong><br>When you force a broadcast using hints or functions like <code>broadcast(df)</code>, you bypass Spark&#8217;s adaptive query execution optimizations. This is useful if you are sure the data size is small, but can cause performance issues if the dataset unexpectedly grows.</p></li><li><p><strong>Plan for Memory Needs:</strong><br>Increase the broadcast thresholds only if your driver and executors have ample memory. For instance, a driver with 32GB+ memory might safely use higher thresholds (like 200MB). Be sure to also configure <code>spark.driver.maxResultSize</code> appropriately to avoid driver-level memory errors.</p></li></ul><div><hr></div><h2>Production Advice</h2><p>When deploying BHJ in production workloads, careful planning and ongoing monitoring are essential to ensure stable performance:</p><ul><li><p><strong>Validate Data Sizes:</strong> Always verify that the dataset chosen for broadcasting is truly small both on disk and in-memory. Measure the record count and partition sizes before forcing a broadcast. This helps prevent unexpected OOM (out-of-memory) failures, which can occur when the dataset size exceeds available memory on the driver or executors.</p></li><li><p><strong>Check Data Size and Record Count</strong></p><ul><li><p><strong>Count the Records:</strong> Before attempting a broadcast, run a simple <code>df.count()</code> on the small dataset. This confirms that the number of records is within an acceptable range.</p></li><li><p><strong>Estimate Data Size in Memory:</strong> Sometimes the dataset's on-disk size differs from its in-memory footprint. You can either use approximations from your data source&#8217;s statistics or compute a rough estimate using:</p></li></ul><pre><code># Example in PySpark
data_size_in_bytes = df.rdd.map(lambda row: len(str(row))).sum()
print("Approximate in-memory size (bytes):", data_size_in_bytes)</code></pre><ul><li><p>While this isn&#8217;t exact, it provides an estimate that can be compared against thresholds like <code>spark.sql.autoBroadcastJoinThreshold</code></p></li></ul></li><li><p><strong>Threshold Validation before Forcing a Broadcast</strong></p><ul><li><p><strong>Compare Against Broadcast Thresholds:</strong> Before performing an explicit broadcast, validate that the data size is below the configured threshold (e.g., 10MB, 30MB, or a custom value in your Spark configuration). This might involve:</p></li></ul><pre><code>broadcast_threshold = int(spark.conf.get("spark.sql.autoBroadcastJoinThreshold").replace("b", ""))
# Assume approximate_size holds our computed or estimated size of the dataset in bytes.
if approximate_size &lt; int(broadcast_threshold):
    print("Proceed with broadcast")
    # Then use broadcast
    from pyspark.sql.functions import broadcast
    df_broadcasted = broadcast(df)
else:
    print("Data too large; do not broadcast")
</code></pre><ul><li><p>This validation helps avoid unintentionally broadcasting a dataset that is too big, potentially causing an OOM error.</p></li></ul></li><li><p><strong>Monitor Resource Usage:</strong> Leverage Spark&#8217;s UI and logging mechanisms to track metrics like GC (garbage collection) activity, memory usage, and broadcast sizes. The smallest available executor memory sets the limit, so ensure that the broadcast data comfortably fits on each node.</p></li><li><p><strong>Use Adaptive Query Execution (AQE) Carefully:</strong> While Spark&#8217;s AQE can convert joins to BHJ at runtime, explicitly broadcasting small datasets using hints or functions like <code>broadcast(df)</code> can bypass the overhead of shuffling. However, avoid hardcoding broadcast hints unless you are confident of the dataset's size, as data volumes may fluctuate in production workloads.</p></li><li><p><strong>Configure Thresholds Cautiously:</strong> Adjust configurations such as <code>spark.sql.autoBroadcastJoinThreshold</code> (and related thresholds in environments like Databricks) based on current cluster resources. For drivers with high memory (32GB+), thresholds can be increased, but setting these too high risks overwhelming your system if data volumes grow unexpectedly.</p></li><li><p><strong>Plan for Scalability and Edge Cases:</strong> Implement safeguards within your production pipelines. For instance, include runtime validations or logic to disable broadcasting dynamically when data sizes approach critical limits. This is especially important for pipelines handling dynamic or streaming data where bursts of data could otherwise lead to system instability.</p></li><li><p>If you&#8217;re running a driver with a lot of memory (32GB+), you can safely raise the broadcast thresholds to something like <strong>200MB</strong></p></li></ul><pre><code><code>set spark.sql.autoBroadcastJoinThreshold = 209715200;
set spark.databricks.adaptive.autoBroadcastJoinThreshold = 209715200;</code></code></pre><ul><li><p><strong>Why do we need to explicitly broadcast smaller tables if AQE can automatically broadcast smaller tables for us?</strong> The reason for this is that AQE optimizes queries while they are being executed.</p><ul><li><p>Spark needs to shuffle the data on both sides and then only AQE can alter the physical plan based on the statistics of the shuffle stage and convert to broadcast join</p></li><li><p>Therefore, if you explicitly broadcast smaller tables using hints, it skips the shuffle altogether and your job will not need to wait for AQE&#8217;s intervention to optimize the plan</p></li></ul></li><li><p><strong>Never broadcast a table bigger than 1GB</strong> because broadcast happens via the driver and a 1GB+ table will either cause OOM on the driver or make the drive unresponsive due to large GC pauses</p></li><li><p>Please take note that the size of a table in disk and memory will never be the same. Delta tables are backed by Parquet files, which can have varying levels of compression depending on the data. And Spark might broadcast them based on their size in the disk &#8212; however, they might actually be really big (even more than 8GB) in memory after the decompression and conversion from column to row format. Spark has a hard limit of 8GB on the table size it can broadcast. As a result, your job may fail with an exception in this circumstance. In this case, the solution is to either disable broadcasting by setting <code>spark.sql.autoBroadcastJoinThreshold</code> to -1 and do the explicit broadcast using hints (or the PySpark broadcast function) of the tables that are really small in the disk as well as in memory, or set the <code>spark.sql.autoBroadcastJoinThreshold</code> to smaller values like 100MB or 50MB instead of setting the threshold to -1.</p></li><li><p>The driver can only collect up to 1GB of data in memory at any given time, and anything more than that will trigger an error in the driver, causing the job to fail. However, since we want to broadcast tables larger than 10MB, we risk running into this problem. This problem can be solved by increasing the value of the following driver <a href="https://spark.apache.org/docs/latest/configuration.html#application-properties">configuration</a>.</p><ul><li><p>Please keep in mind that because this is a driver setting; it cannot be altered once the cluster is launched. Therefore, it should be set under the cluster&#8217;s advanced options as a Spark config. Setting this parameter to 8GB for a driver with &gt;32GB memory seems to work fine in most circumstances. In certain cases where the broadcast hash join is going to broadcast a very large table, setting this value to 16GB would also make sense.</p></li><li><p>In <a href="https://learn.microsoft.com/en-us/azure/databricks/runtime/photon">Photon</a>, we have the executor-side broadcast. So, you don&#8217;t have to change the following driver configuration if you use a Databricks Runtime (DBR) with Photon.</p></li></ul></li></ul><pre><code><code>spark.driver.maxResultSize 16g</code></code></pre><div><hr></div><h3>Final Thoughts</h3><p>In summary, Broadcast Hash Join is a fast and efficient joining strategy in Spark for skewed or unbalanced joins where one dataset is significantly smaller. It avoids the expensive shuffling of the larger dataset by replicating the small data across all executors, enabling quick local hash lookups. However, its effectiveness depends heavily on the small dataset fitting in memory on the driver and executors. Forcing broadcasts should be done judiciously, with thorough validations in production to prevent resource exhaustion and associated failures.</p><p>By understanding the details of how BHJ operates and its configurations, you can better optimize your Spark jobs and manage performance, especially in environments like Databricks where adaptive query execution and executor-side optimizations further enhance its capabilities.</p><h4>How the Process Works</h4><p>BHJ operates in <strong>two main phases</strong>:</p><ol><li><p><strong>Broadcast Phase:</strong></p><ul><li><p><strong>Collection and Broadcast:</strong> The small table is first collected by the Spark driver. After collection, the data is broadcast to all the executors across the cluster.</p></li><li><p><strong>Local Caching:</strong> Once received on each node, the small dataset is cached in memory as a read-only broadcast variable. This ensures that the data is immediately available for the join process without any further data movement.</p></li></ul></li><li><p><strong>Hash Join Phase:</strong></p><ul><li><p><strong>Building a Hash Map:</strong> Each executor creates an in-memory hash map from the broadcasted dataset. The hash map is built using the join key.</p></li><li><p><strong>Local Join Operation:</strong> As the larger dataset is processed, every row in each partition is checked against the hash map for matching join keys. Because the small dataset is already available locally, this lookup is very fast and eliminates the need for shuffling data across the network.</p></li></ul></li></ol><p>Since no sort or extra merge steps are required, this one-pass in-memory lookup per partition makes the Broadcast Hash Join particularly quick, especially in common scenarios like joining large fact tables with much smaller dimension tables (a typical star schema pattern).</p><h2>Further Reading</h2><p>For more in-depth information and the latest updates on Spark join optimizations, the following resources are highly recommended:</p><ul><li><p><strong>Apache Spark Official Documentation &#8211; SQL Performance Tuning:</strong> Covers join strategy hints, adaptive execution, etc. (See <em>&#8220;Join Strategy Hints&#8221;</em> and <em>&#8220;Adaptive Query Execution&#8221;</em> in the Spark docs)&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Join%20Strategy%20Hints%20for%20SQL,Queries">downloads.apache.org</a></p></li><li><p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/spark-tuning-glue-emr/using-join-hints-in-spark-sql.html#:~:text=,all%20executors%20within%20the%20cluster">Tuning Spark SQL queries for AWS Glue and Amazon EMR Spark jobs</a></p></li><li><p><strong>Apache Spark Official Documentation &#8211; Adaptive Query Execution (AQE):</strong> Detailed explanation of AQE features like converting SMJ to BHJ/SHJ and skew join handling&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Converting%20sort,join">downloads.apache.org</a></p></li><li><p><strong>Databricks Documentation &#8211; Join Hints &amp; Optimizations:</strong> Databricks-specific docs on join strategies, including the <code>SKEW</code> and <code>RANGE</code> hints, and how AQE is used on Databricks&#8203;</p><p><a href="https://docs.databricks.com/gcp/en/transform/join#:~:text=Join%20hints%20on%20Databricks">docs.databricks.com</a></p></li><li><p><strong>&#8220;How Databricks Optimizes Spark SQL Joins&#8221; &#8211; Medium (dezimaldata):</strong> A blog post (Aug 2023) summarizing Databricks&#8217; techniques like CBO, AQE, range join and skew join optimizations&#8203;</p><p><a href="https://dezimaldata.medium.com/how-databricks-optimizes-the-spark-sql-joins-d01b95336d65#:~:text=In%20this%20blog%20post%2C%20we,joins%20and%20subqueries%2C%20such%20as">dezimaldata.medium.com</a></p></li><li><p><strong>&#8220;Top 5 Mistakes That Make Your Databricks Queries Slow&#8221; &#8211; Perficient Blog:</strong> Section 1 and 2 discuss data skew and suboptimal join strategies, with tips on salting and broadcast joins&#8203;</p><p><a href="https://blogs.perficient.com/2025/03/28/top-5-mistakes-that-make-your-databricks-queries-slow-and-how-to-fix-them/#:~:text=1">blogs.perficient.com</a></p></li><li><p><strong>Spark Summit Talks on Joins and AQE:</strong> Videos like <em>&#8220;Optimizing Shuffle Heavy Workloads&#8221;</em> or <em>&#8220;AQE in Spark 3.0&#8221;</em> (by Databricks engineers) for a deeper understanding of the internals of join execution and tuning.</p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints">https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints</a></p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution">https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution</a></p></li><li><p>https://docs.databricks.com/en/sql/language-manual/hints.html</p></li><li><p>https://medium.com/@dezimaldata/how-databricks-optimizes-spark-sql-joins-aqe-cbo-and-more-5ac4c4d53091</p></li><li><p>https://www.perficient.com/insights/blog/2023/01/top-5-mistakes-that-make-your-databricks-queries-slow</p></li><li><p>https://www.databricks.com/resources/whitepapers/optimizing-apache-spark-on-databricks</p></li></ul><p>By consulting these materials, you can deepen your understanding of Spark join mechanisms and keep up to date with the evolving best practices on the Databricks platform.</p>]]></content:encoded></item><item><title><![CDATA[Spark Join Strategies Explained: Shuffle Hash]]></title><description><![CDATA[Everything You Need to Know About Shuffle Hash Join]]></description><link>https://www.canadiandataguy.com/p/spark-join-strategies-explained-shuffle</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/spark-join-strategies-explained-shuffle</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Thu, 10 Apr 2025 14:00:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!4QvA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>1. Introduction</h2><p>Modern big data applications often require joining huge datasets efficiently. Choosing the right join strategy is critical to optimize performance and resource usage. Apache Spark offers several join methods, including broadcast joins, sort-merge joins, and shuffle hash joins. SHJ stands out as a middle-ground approach:</p><ul><li><p>It <strong>shuffles</strong> both tables like sort-merge joins to align data with the same key.</p></li><li><p>Instead of sorting, it builds an <strong>in-memory hash table</strong> for the smaller dataset per partition and probes it with rows from the larger dataset.</p></li></ul><p>This dual approach has the potential to improve execution time by reducing the sorting overhead but demands careful memory management.</p><div><hr></div><h2>2. Understanding Shuffle Hash Join</h2><p><strong>Shuffle Hash Join</strong> is best understood as a hybrid that borrows elements from two traditional join methods:</p><ul><li><p><strong>Sort Merge Join (SMJ)</strong></p><ul><li><p><strong>Mechanism:</strong> Both datasets are sorted by the join key and then merged.</p></li><li><p><strong>Pros:</strong> Reliable for large datasets.</p></li><li><p><strong>Cons:</strong> Sorting is CPU intensive.</p></li></ul></li><li><p><strong>Broadcast Hash Join (BHJ)</strong></p><ul><li><p><strong>Mechanism:</strong> The smaller table is broadcast to all nodes, and each executor performs a local hash join.</p></li><li><p><strong>Pros:</strong> Eliminates shuffling.</p></li><li><p><strong>Cons:</strong> Limited by broadcast size, not suitable when the smaller table exceeds available memory on executors.</p></li></ul></li></ul><p><strong>How SHJ Differentiates Itself:</strong></p><ul><li><p><strong>Key Step:</strong> It shuffles both datasets based on the join key so that every partition contains matching keys.</p></li><li><p><strong>In-Partition Operation:</strong> Instead of sorting the data in each partition, Spark builds a hash table from the smaller dataset's partition and then probes that table with each row from the larger dataset.</p></li><li><p><strong>Memory Sensitivity:</strong> The approach assumes that each partition of the smaller side can be held in memory, which is crucial for performance and avoiding runtime errors.</p></li></ul><blockquote><p><strong>Key Concepts to Remember:</strong></p><ul><li><p><strong>No Sorting:</strong> Eliminates the costly sort phase.</p></li><li><p><strong>Memory Requirement:</strong> High dependency on the ability to fit the hashed partition in memory, risking OOM errors if miscalculated.</p></li></ul></blockquote><div><hr></div><h2>3. When to Use SHJ</h2><h3>Historical Perspective</h3><ul><li><p><strong>Pre-Spark 3.0:</strong><br>Spark defaulted to Sort Merge Join for equality-based joins due to the risk of OOM when building in-memory hash tables.</p></li><li><p><strong>Spark 3.x and Beyond:</strong><br>With enhancements like Adaptive Query Execution (AQE), Spark can dynamically decide to use SHJ when it detects that:</p><ul><li><p>The smaller dataset, after partitioning, is of manageable size.</p></li><li><p>Avoiding the expensive sorting operation is beneficial for performance.</p></li></ul></li></ul><h3>Practical Scenarios</h3><ul><li><p><strong>Moderately Small Datasets:</strong><br>When one dataset is small enough that its partitions are lightweight (e.g., 5 MB per partition out of 5 GB divided across 1000 partitions), yet not small enough for a broadcast join.</p></li><li><p><strong>High Sorting Overhead:</strong><br>When joining a massive fact table (e.g., 1 TB) with a dimension table that is too big to broadcast but small enough per partition, the cost of sorting the entire dataset (as in SMJ) may dominate and thus SHJ becomes more efficient.</p></li></ul><h3>Decision Factors</h3><ul><li><p><strong>Estimated Partition Size:</strong><br>Spark&#8217;s optimizer checks if the estimated per-partition size of the smaller table is below a threshold (set via <code>spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold</code>).</p></li><li><p><strong>Configuration and Hints:</strong><br>Users can guide Spark&#8217;s optimizer using hints like <code>/*+ SHUFFLE_HASH(tab) */</code> or disable sort-merge joins by toggling <code>spark.sql.join.preferSortMergeJoin</code>.</p><p>spark.conf.set("spark.sql.join.preferSortMergeJoin","false")</p></li></ul><div><hr></div><h2>4. How SHJ Works</h2><p>The execution of a Shuffle Hash Join can be understood through two primary phases, with some literature breaking it into a three-phase model for clarity.</p><h3>A. Shuffle Phase</h3><p><strong>Objective:</strong><br>Bring together all rows associated with a given join key within the same partition.</p><p><strong>Process:</strong></p><ul><li><p><strong>Repartitioning:</strong><br>Both datasets are re-distributed (shuffled) using the join key as the partitioning key. Note that <strong>both</strong> sides are shuffled &#8211; so network cost is still incurred for both datasets.</p></li><li><p><strong>Data Co-location:</strong><br>Post-shuffle, each partition will hold all the relevant rows for a specific range of join keys.</p></li><li><p><strong>Network I/O:</strong><br>While shuffling ensures correct join semantics, it incurs the cost of network communication for both datasets.</p></li></ul><p><strong>Example Scenario:</strong></p><p>Imagine two datasets, <code>Person</code> and <code>Address</code>, initially spread across different partitions. In the shuffle phase, rows with the same key (e.g., <code>A001</code>) are sent to the same partition. This guarantees that later join operations will have all matching keys available on the same executor.</p><h3>B. Hash Join Phase</h3><p>After the shuffle phase, the join is executed within each partition through these steps:</p><ol><li><p><strong>Hash Table Creation:</strong></p><ul><li><p><strong>Selection:</strong><br>Spark selects the smaller dataset based on statistics or join hints.</p></li><li><p><strong>Building the Hash Table:</strong><br>For every partition, Spark creates an in-memory hash table that maps join keys to the associated rows.</p></li></ul></li><li><p><strong>Probing the Hash Table:</strong></p><ul><li><p><strong>Streaming Data:</strong><br>The larger dataset&#8217;s rows are processed sequentially within the partition.</p></li><li><p><strong>Lookup and Join:</strong><br>For each row in the larger dataset, the hash table is queried using the join key. If a match exists, Spark produces the joined row as output.</p></li></ul></li></ol><blockquote><p><em>Because no sort is done, if the data per partition is large, the hash table may also be large. Spark assumes the build side will fit in memory. If it doesn&#8217;t, the task can spill partitions of the build side to disk (Spark has some support for spilling hash tables, but it is more complex than spilling a sort). In worst cases, an SHJ can run out of memory if the hash table grows too big, causing the executor to OOM. This is why Spark is conservative in using SHJ unless it&#8217;s confident the partitions are small enough&#8203;</em></p></blockquote><p><strong>Conceptual Diagram:</strong></p><p>Imagine a partition where:</p><ul><li><p>The smaller dataset&#8217;s partition (say, 5 MB worth of data) is fully loaded into a hash table.</p></li><li><p>The larger dataset streams through, and for each key, Spark quickly checks the in-memory hash table for corresponding rows.</p></li></ul><p>This operation is performed concurrently across all partitions on different worker nodes.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4QvA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4QvA!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 424w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 848w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 1272w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4QvA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png" width="744" height="918" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:918,&quot;width&quot;:744,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Phases of the Shuffle Hash join: Scan JSON read data, exchange, ShuffleHashJoin, and Hash Aggregate&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Phases of the Shuffle Hash join: Scan JSON read data, exchange, ShuffleHashJoin, and Hash Aggregate" title="Phases of the Shuffle Hash join: Scan JSON read data, exchange, ShuffleHashJoin, and Hash Aggregate" srcset="https://substackcdn.com/image/fetch/$s_!4QvA!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 424w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 848w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 1272w, https://substackcdn.com/image/fetch/$s_!4QvA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6546e1e4-d463-41d6-8aa1-6e9fbdc4f985_744x918.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h3>Alternative Three-Phase View</h3><p>For some, a detailed three-phase breakdown clarifies the process:</p><ol><li><p><strong>Shuffle:</strong><br>Repartition both datasets so that all rows sharing the same join key are co-located.</p></li><li><p><strong>Hash Table Creation:</strong><br>For each partition, build the in-memory hash table using the smaller dataset.</p></li><li><p><strong>Hash Join:</strong><br>Join the larger dataset&#8217;s partition by probing the hash table.</p></li></ol><p>This view underlines the importance of parallel execution, where each worker node processes its partitions independently, which is key to Spark&#8217;s scalability.</p><div><hr></div><h2>5. Supported Join Types</h2><p>Shuffle Hash Join is designed to work primarily with <strong>equi-joins</strong>. In Apache Spark, it supports:</p><ul><li><p><strong>Inner Joins:</strong><br>Only matching rows are returned.</p></li><li><p><strong>Left, Right, Semi, and Anti Joins:</strong><br>These join types function well as long as the join condition is based on equality.</p></li></ul><p><strong>Additional Notes:</strong></p><ul><li><p><strong>Full Outer Join:</strong><br>Initially, SHJ did not support full outer joins in Spark 3.0 but was later introduced in Spark 3.1+.</p></li><li><p><strong>Non-equi Joins and Cross Joins:</strong><br>SHJ does not naturally handle cross joins or non-equi conditions. In such cases, Spark falls back on other, more suitable join strategies.</p></li></ul><div><hr></div><h2>6. Performance Characteristics &amp; Trade-Offs</h2><p>Understanding the performance implications of SHJ is critical for designing robust, high-performance Spark jobs.</p><h3>Advantages</h3><ul><li><p><strong>No Sorting Required:</strong></p><ul><li><p>By eliminating the sort step used in SMJ, SHJ significantly reduces CPU overhead.</p></li></ul></li><li><p><strong>Efficient CPU Usage:</strong></p><ul><li><p>Hash functions and probing operations are generally less costly than sorting large datasets.</p></li></ul></li><li><p><strong>Parallel Execution:</strong></p><ul><li><p>The join is processed in parallel across partitions, making it scalable across large clusters.</p></li></ul></li></ul><h3>Considerations and Pitfalls</h3><ul><li><p><strong>Memory Sensitivity:</strong></p><ul><li><p><strong>Build Side Dependency:</strong><br>Every partition on the smaller side must fit in memory. If a partition exceeds available memory, it may cause disk spills or even OOM errors.</p></li><li><p><strong>Configuration Challenges:</strong><br>Incorrect estimations or misconfigured thresholds can lead to failures. Monitoring and adjusting Spark&#8217;s parameters is essential.</p></li></ul></li><li><p><strong>Data Skew:</strong></p><ul><li><p><strong>Uneven Distribution:</strong><br>A heavily skewed join key might result in one partition holding a disproportionate amount of data, dramatically increasing memory requirements for that partition.</p></li><li><p><strong>Mitigation Strategies:</strong><br>Use techniques like increasing the number of shuffle partitions (via <code>spark.sql.shuffle.partitions</code>) or applying custom salting techniques.</p></li></ul></li><li><p><strong>Network I/O:</strong></p><ul><li><p>While SHJ saves on CPU cycles, it does not reduce the network cost of shuffling. If your workload is network-bound, the benefits of SHJ may be limited.</p></li></ul></li><li><p><strong>Fallback and Spilling:</strong></p><ul><li><p>If the hash table grows too large, Spark may attempt to spill data to disk. However, disk spilling is less efficient and can severely impact performance.</p></li></ul></li></ul><div><hr></div><h2>7. SHJ Compared to Other Join Strategies</h2><p>A clear comparison can help decide when to use SHJ over other join methods:</p><p><strong>AspectBroadcast Hash Join (BHJ)Sort Merge Join (SMJ)Shuffle Hash Join (SHJ)When to Use</strong>Very small tables (typically &lt;10 MB by default)Large tables where sorting is tolerableModerately small build side that cannot be broadcast; avoid sorting overhead<strong>Sorting Requirement</strong>No sorting; smaller dataset is broadcastedSorting required across partitionsNo sorting within partitions; uses in-memory hash table<strong>Memory Impact</strong>Minimal memory impact on executorsUses more CPU for sortingRequires sufficient memory per partition for hash tables<strong>Network Cost</strong>Minimal network I/O (broadcast eliminates shuffle)High network I/O due to data shufflingSame network cost as SMJ</p><p><strong>Key Takeaways:</strong></p><ul><li><p><strong>BHJ</strong> is best when the smaller table is extremely small.</p></li><li><p><strong>SMJ</strong> is a general-purpose join that is robust for large datasets.</p></li><li><p><strong>SHJ</strong> strikes a balance by avoiding the heavy sorting cost when the per-partition memory size is manageable.</p></li></ul><h4><em>Shuffle hash join over sort-merge join</em></h4><p>In most cases Spark chooses sort-merge join (SMJ) when it can&#8217;t broadcast tables. Sort-merge joins are the most expensive ones. Shuffle-hash join (SHJ) has been found to be faster in some circumstances (but not all) than sort-merge since it does not require an extra sorting step like SMJ. There is a setting that allows you to advise Spark that you would prefer SHJ over SMJ, and with that Spark will try to use SHJ instead of SMJ wherever possible. Please note that this does not mean that Spark will always choose SHJ over SMJ. We are simply defining your preference for this option.</p><pre><code><code>set spark.sql.join.preferSortMergeJoin = false</code></code></pre><p>Databricks <a href="https://docs.databricks.com/runtime/photon.html">Photon</a> engine also replaces sort-merge join with shuffle hash join to boost the query performance.</p><ul><li><p>Setting the <code>preferSortMergeJoin</code> config option to false for each job is not necessary. For the first execution of a concerned job, you can leave this value to default (which is true).</p></li></ul><ul><li><p>If the job in question performs a lot of joins, involving a lot of data shuffling and making it difficult to meet the desired SLA, then you can use this option and change the <code>preferSortMergeJoin</code> value to false</p></li></ul><div><hr></div><h2>8. Configuration and Tuning Best Practices</h2><p>Optimizing SHJ involves careful configuration and continuous monitoring. Below are some best practices.</p><h3>A. Adaptive Query Execution (AQE)</h3><p><strong>What is AQE?</strong><br>Adaptive Query Execution dynamically adapts the physical plan based on runtime statistics. With Spark 3.x, AQE can convert a sort-merge join to a shuffle hash join if it detects that partition sizes are favorable.</p><p><strong>Configuration Example:</strong></p><pre><code>// Set AQE threshold such that if post-shuffle partition size is below 64MB, Spark uses SHJ. spark.conf.set("spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold", "64MB")</code></pre><p>This dynamic adjustment helps balance between CPU use and memory load without manual intervention.</p><h3>B. Join Hints and Configurations</h3><p><strong>Explicit Hints:</strong><br>When you know the data characteristics, you can direct Spark to use SHJ via hints:</p><pre><code>// Using a hint to explicitly request a Shuffle Hash Join val dfJoined = factTable.join(dimensionTable.hint("SHUFFLE_HASH"), "joinKey") dfJoined.explain() // The physical plan should show ShuffledHashJoin</code></pre><p><strong>Disabling SMJ Preference:</strong><br>For cases where SHJ is preferred over SMJ, you can adjust the setting as follows:</p><pre><code>// Tell Spark to favor hash-based join strategies over sort-merge join. spark.conf.set("spark.sql.join.preferSortMergeJoin", "false")</code></pre><h3>C. Monitoring and Debugging</h3><p><strong>Using the Spark UI:</strong></p><ul><li><p><strong>Partition Metrics:</strong><br>Monitor the size and distribution of shuffle partitions to ensure they meet expected thresholds.</p></li><li><p><strong>Task Execution Details:</strong><br>Observe tasks&#8217; memory usage and CPU times. Unexpected OOM errors or high spill metrics may indicate misconfigured thresholds or skewed data.</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!WDjl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!WDjl!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 424w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 848w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 1272w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!WDjl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png" width="728" height="549" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:549,&quot;width&quot;:728,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Shuffle Hash Join Spark Stages&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Shuffle Hash Join Spark Stages" title="Shuffle Hash Join Spark Stages" srcset="https://substackcdn.com/image/fetch/$s_!WDjl!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 424w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 848w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 1272w, https://substackcdn.com/image/fetch/$s_!WDjl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09f871dd-c444-4e2f-a392-5e0509d572ad_728x549.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Log Analysis:</strong></p><ul><li><p><strong>AQE Logs:</strong><br>When AQE is enabled, logs will show if the join strategy was dynamically switched.</p></li><li><p><strong>Executor Logs:</strong><br>Pay attention to memory allocation logs and warnings about data spills.</p></li></ul><div><hr></div><h2>9. Practical Example</h2><p>Let&#8217;s consider a real-world scenario to solidify our understanding. Suppose you are joining a large fact table with a moderately sized dimension table:</p><ul><li><p><strong>Fact Table:</strong> ~1 TB of transactional data.</p></li><li><p><strong>Dimension Table:</strong> ~5 GB of reference data.</p></li></ul><p><strong>Rationale:</strong><br>Broadcasting a 5 GB table is infeasible in this scenario, but if you partition the 5 GB table into 1000 slices, each partition is only about 5 MB. This makes it an ideal candidate for a shuffle hash join.</p><p><strong>Implementation Example in Spark (Scala):</strong></p><pre><code>// Assuming factTable and dimensionTable are pre-defined DataFrames val dfJoined = factTable.join( dimensionTable.hint("SHUFFLE_HASH"), Seq("joinKey") // Using column(s) that define the join condition ) // Explain the plan to verify the join strategy dfJoined.explain(true) // Expected outcome: // The physical plan should display an operator "ShuffledHashJoin" // indicating that Spark is using SHJ for the join.</code></pre><p><strong>What to Look For:</strong></p><ul><li><p><strong>Physical Plan Inspection:</strong><br>Look for the <code>ShuffledHashJoin</code> operator in the explain plan output.</p></li><li><p><strong>Resource Usage:</strong><br>Monitor executor memory usage and check that each partition from the smaller dimension table fits within the allotted memory, avoiding spills or OOM errors.</p></li></ul><pre><code>ShuffledHashJoin [id1#3], [id2#8], Inner, BuildRight
:- Exchange hashpartitioning(id1#3, 200)
:  +- LocalTableScan [id1#3]
+- Exchange hashpartitioning(id2#8, 200)
   +- LocalTableScan [id2#8]</code></pre><h2><strong>10. Databricks platform specific insights</strong></h2><p>Databricks generally relies on BHJ and SMJ under the hood, and uses SHJ in a more limited, adaptive way. Under AQE, Databricks might start a join as a sort-merge join but then <em>convert it to a shuffled hash join</em> at runtime if it finds that each partition&#8217;s size is below a threshold (and thus can fit in memory)&#8203;.</p><p> This is an optimization: Spark saves the cost of sorting when it realizes it wasn&#8217;t needed. By default, this conversion is off (threshold = 0) on vanilla Spark 3.2, but Databricks may enable it or allow setting it. If using hints, you can explicitly ask for a SHJ: e.g., <code>.hint("SHUFFLE_HASH")</code> in DataFrame API or SQL hints. This can be useful if you <em>know</em> one side is moderately small but Spark&#8217;s stats are missing. Always ensure that the hint-targeted side will be small per partition; otherwise, you might get memory errors.</p><p>Databricks&#8217; strong skew mitigation helps SHJ as well &#8211; if one partition is skewed and would OOM an SHJ, AQE&#8217;s skew join handling could split that partition and even fall back to a sort-merge or a replicated join for that partition if necessary&#8203;. Also, note that <strong>Photon</strong> (Databricks&#8217; vectorized engine) has an improved hashed join implementation that can spill gracefully and use multiple threads per join, which makes SHJ more viable for large data in Photon. In standard Spark, SHJ is single-threaded per task for the join itself (just like SMJ merge is single-threaded per task).</p><h2>11. Conclusion</h2><p><strong>Shuffle Hash Join (SHJ)</strong> provides a balanced approach by eliminating the high cost of sorting that is present in Sort Merge Joins, while sidestepping the broadcast size limitations of Broadcast Hash Joins. By shuffling data to co-locate matching join keys and then using an in-memory hash table to perform the join, SHJ offers:</p><ul><li><p><strong>Improved CPU efficiency</strong> due to reduced sorting overhead.</p></li><li><p><strong>Scalability</strong> when the smaller dataset can be effectively partitioned.</p></li><li><p><strong>A flexible mechanism</strong> that can adapt to runtime data sizes through AQE.</p></li></ul><p>However, SHJ requires meticulous tuning and monitoring:</p><ul><li><p><strong>Memory Utilization:</strong><br>Ensure that each partition&#8217;s hash table fits in memory.</p></li><li><p><strong>Data Skew:</strong><br>Address uneven data distributions to prevent performance bottlenecks.</p></li><li><p><strong>Network Costs:</strong><br>Understand that while CPU usage may decrease, shuffling still incurs network overhead.</p></li></ul><p>By leveraging configuration settings, join hints, and adaptive query execution, data engineers can optimize their Spark workloads using SHJ. This detailed understanding equips you with the knowledge to carefully evaluate when SHJ is the right tool for your data joining needs, ensuring robust and efficient Spark application performance.</p><h2>Further Reading</h2><p>For more in-depth information and the latest updates on Spark join optimizations, the following resources are highly recommended:</p><ul><li><p><strong>Apache Spark Official Documentation &#8211; SQL Performance Tuning:</strong> Covers join strategy hints, adaptive execution, etc. (See <em>&#8220;Join Strategy Hints&#8221;</em> and <em>&#8220;Adaptive Query Execution&#8221;</em> in the Spark docs)&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Join%20Strategy%20Hints%20for%20SQL,Queries">downloads.apache.org</a></p></li><li><p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/spark-tuning-glue-emr/using-join-hints-in-spark-sql.html#:~:text=,all%20executors%20within%20the%20cluster">Tuning Spark SQL queries for AWS Glue and Amazon EMR Spark jobs</a></p></li><li><p><strong>Apache Spark Official Documentation &#8211; Adaptive Query Execution (AQE):</strong> Detailed explanation of AQE features like converting SMJ to BHJ/SHJ and skew join handling&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Converting%20sort,join">downloads.apache.org</a></p></li><li><p><strong>Databricks Documentation &#8211; Join Hints &amp; Optimizations:</strong> Databricks-specific docs on join strategies, including the <code>SKEW</code> and <code>RANGE</code> hints, and how AQE is used on Databricks&#8203;</p><p><a href="https://docs.databricks.com/gcp/en/transform/join#:~:text=Join%20hints%20on%20Databricks">docs.databricks.com</a></p></li><li><p><strong>&#8220;How Databricks Optimizes Spark SQL Joins&#8221; &#8211; Medium (dezimaldata):</strong> A blog post (Aug 2023) summarizing Databricks&#8217; techniques like CBO, AQE, range join and skew join optimizations&#8203;</p><p><a href="https://dezimaldata.medium.com/how-databricks-optimizes-the-spark-sql-joins-d01b95336d65#:~:text=In%20this%20blog%20post%2C%20we,joins%20and%20subqueries%2C%20such%20as">dezimaldata.medium.com</a></p></li><li><p><strong>Spark Summit Talks on Joins and AQE:</strong> Videos like <em>&#8220;Optimizing Shuffle Heavy Workloads&#8221;</em> or <em>&#8220;AQE in Spark 3.0&#8221;</em> (by Databricks engineers) for a deeper understanding of the internals of join execution and tuning.</p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints">https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints</a></p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution">https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution</a></p></li><li><p><a href="https://dezimaldata.medium.com/how-databricks-optimizes-the-spark-sql-joins-d01b95336d65">How Databricks Optimizes the Spark SQL Joins</a></p></li><li><p><a href="https://blogs.perficient.com/2025/03/28/top-5-mistakes-that-make-your-databricks-queries-slow-and-how-to-fix-them/">Top 5 Mistakes That Make Your Databricks Queries Slow (and How to Fix Them)</a></p><p></p><p>By consulting these materials, you can deepen your understanding of Spark join mechanisms and keep up to date with the evolving best practices on the Databricks platform.</p></li></ul>]]></content:encoded></item><item><title><![CDATA[Spark Join Strategies Explained: Sort Merge Join]]></title><description><![CDATA[Slow and Steady always wins the race]]></description><link>https://www.canadiandataguy.com/p/spark-join-strategies-explained-sort</link><guid isPermaLink="false">https://www.canadiandataguy.com/p/spark-join-strategies-explained-sort</guid><dc:creator><![CDATA[Canadian Data Guy]]></dc:creator><pubDate>Thu, 10 Apr 2025 05:22:09 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2><strong>What is  it:</strong> </h2><p>Sort-Merge Join is the <strong>default join strategy</strong> in Spark for large datasets that don&#8217;t qualify for a broadcast. It involves shuffling and sorting both sides of the join on the join key, then streaming through the sorted data to merge matching keys&#8203;. SMJ is robust and scalable: it can handle very large tables and all join types (inner, outer, etc.), at the cost of more network and CPU usage.</p><h2><strong>How it works</strong></h2><p>Spark will use a Sort-Merge Join when neither side is small enough to broadcast (or if the join type is not supported by BHJ). The execution has three main phases&#8203;</p><ol><li><p><strong>Shuffle Phase:</strong> In the shuffle phase, both input datasets are repartitioned (shuffled) across the cluster nodes based on the join keys. This operation ensures that matching keys from both datasets reside within the same partitions on executors. The shuffle is an expensive network operation involving data redistribution across nodes. Each executor receives and transmits data based on the key distribution. By default, Spark employs 200 partitions (<code>spark.sql.shuffle.partitions</code>). In the physical plan, this shows up as <code>Exchange hashpartitioning(...)</code> on each side of the join&#8203;</p></li><li><p><strong>Sort Phase:</strong> Within each partition, Spark sorts the records by the join key. Each side is sorted independently. The plan will have local <code>Sort</code> operators after the exchange on each side&#8203;. The output is that in partition <em>i</em>, both datasets are sorted by key. Sorting is an expensive step (<strong>O(n log n) per partition)</strong>. If the data is already partitioned and sorted (e.g. bucketing and sorting on the join key), Spark may skip the shuffle and/or sort &#8211; but this requires specific conditions (like both sides being bucketed by the join key with the same number of partitions).</p></li><li><p><strong>Merge Phase:</strong> Once each partition has sorted data from both sides, Spark performs a <strong>merge join</strong>: it iterates through the two sorted lists and finds matching keys, similar to how one would merge two sorted files&#8203;. Because the data is sorted, Spark can do this efficiently by advancing pointers in each list, without nested loops. This merge join operation is efficient&#8212;linear time complexity per partition&#8212;enabling rapid matching without the need for nested loops. The output of each task is the joined records for that partition&#8217;s key range.</p></li></ol><pre><code>== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
  +- SortMergeJoin [id#320L], [id#335L], Inner
       :- Sort [id#320L ASC NULLS FIRST], false, 0
       :  +- Exchange hashpartitioning(id#320L, 36), ENSURE_REQUIREMENTS, [id=#1018]
       :    +- Filter isnotnull(id#320L)
       :     +- Scan ExistingRDD[id#320L,col#321]
               +- Sort [id#335L ASC NULLS FIRST], false, 0
                +- Exchange hashpartitioning(id#335L, 36), ENSURE_REQUIREMENTS, [id=#1019]
                  +- Filter isnotnull(id#335L)
                   +- Scan ExistingRDD[id#335L,int_col#336L]</code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!XlqS!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!XlqS!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 424w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 848w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 1272w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!XlqS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png" width="464" height="490" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:490,&quot;width&quot;:464,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!XlqS!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 424w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 848w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 1272w, https://substackcdn.com/image/fetch/$s_!XlqS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fca8c93bc-df28-4fc8-b1a2-2018a611bd59_464x490.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2><strong>Execution details</strong></h2><p>Sort-Merge join will span multiple stages in the Spark DAG. Typically, you&#8217;ll have one stage (or stages) to produce the shuffle partitions for side A, another for side B, and then a final stage where the actual join (merge) happens. In Spark UI&#8217;s DAG visualization, you might see something like: both tables read in earlier stages, then a stage where &#8220;Exchange -&gt; Sort -&gt; WholeStageCodegen -&gt; SortMergeJoin&#8221; occurs&#8203;</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!viK8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!viK8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 424w, https://substackcdn.com/image/fetch/$s_!viK8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 848w, https://substackcdn.com/image/fetch/$s_!viK8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!viK8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!viK8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg" width="686" height="386" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:386,&quot;width&quot;:686,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Spark SQL Bucketing at Facebook - Cheng Su (Facebook)&quot;,&quot;title&quot;:&quot;Spark SQL Bucketing at Facebook - Cheng Su (Facebook)&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Spark SQL Bucketing at Facebook - Cheng Su (Facebook)" title="Spark SQL Bucketing at Facebook - Cheng Su (Facebook)" srcset="https://substackcdn.com/image/fetch/$s_!viK8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 424w, https://substackcdn.com/image/fetch/$s_!viK8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 848w, https://substackcdn.com/image/fetch/$s_!viK8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!viK8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6aee5d69-a69c-4f0a-bfc3-2a0a375c5a0b_686x386.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em>A Spark DAG visualization of a Sort-Merge Join.</em> Both tables are read and then <strong>shuffled</strong> (Exchange) so that matching keys co-locate. Each partition then <strong>sorts</strong> its chunk of data on the join key and <strong>merges</strong> the two sorted streams to output joined rows. (Some upstream stages show as &#8220;skipped&#8221; because their output was cached for reuse in this example.)</p><p><strong>Supported join types:</strong> <strong>All join types</strong> are supported by SMJ for equality conditions &#8211; inner, left, right, full outer, semi, anti. It&#8217;s the fallback for any join that can&#8217;t use a more specialized strategy. Even non-equi joins (like inequalities) can be executed with a sort-merge-like approach if one side is small (Spark might use a Broadcast NLJ for those), but typically equi-joins are where SMJ is used. If you have a full outer join or if both sides are huge, SMJ is usually the plan Spark will choose&#8203;. (Full outer join cannot be executed as a pure hash join in Spark 2.x, so SMJ was the only choice; Spark 3.1 introduced a shuffle hash algorithm for full outer, but SMJ is still often used.)</p><h2><em>Why is it the most stable join?</em></h2><p>Sort-Merge Join is <em>network and CPU intensive</em>. It performs a <strong>full shuffle of both datasets</strong> &#8211; which means network I/O proportional to the data size &#8211; and a sort of each partition. The memory usage during the sort phase can be high; Spark uses external sort which will spill to disk if a partition&#8217;s data doesn&#8217;t fit in memory. Unlike SHJ, SMJ is not all-or-nothing in memory: if a task has more data than RAM, it will write sorted runs to disk and merge them (graceful degradation)&#8203;.</p><p>This is why SMJ is considered <strong>stable for large data</strong> &#8211; it won&#8217;t crash for memory reasons, at worst it will spill and slow down. Still, you want to avoid excessive spilling by tuning partition sizes (Databricks often sets the default shuffle partitions to a high number or uses AQE to auto-tune partition counts).</p><p>Because both sides are shuffled, SMJ is symmetric &#8211; both large and small tables incur shuffle cost. The algorithm doesn&#8217;t build big hash tables, so it can handle very large inputs (even beyond memory) as long as you accept the sorting cost. One positive aspect is that SMJ streaming merge has <strong>low overhead per record</strong> once sorted, and if data is somewhat presorted or partitioned, the cost might be less than worst-case.</p><h2><strong>Databricks-specific insights</strong></h2><p>Databricks Runtime by default enables <strong>Adaptive Query Execution (AQE)</strong>, which can optimize sort-merge joins in two major ways:</p><ol><li><p><strong>Dynamic partition coalescing</strong> &#8211; after shuffle, if many partitions are small, Databricks can coalesce them to reduce task overhead</p></li><li><p><strong>Skew handling</strong> &#8211; if some partitions are extremely large (skewed), Databricks can split those into multiple tasks to avoid stragglers&#8203;</p></li></ol><p>We will discuss skew handling separately, but it&#8217;s important that with AQE, SMJ is not as rigid as it once was. Databricks also collects detailed statistics to decide join strategies: if the optimizer has reliable size estimates (via cost-based optimization), it might avoid SMJ in favor of BHJ when appropriate&#8203;. However, when dealing with truly large tables where neither side is small, SMJ will be chosen because it&#8217;s the most general and robust approach.</p><h3>Advanced Performance Tuning Strategies</h3><p>While Spark handles the heavy lifting, you can tune SMJ performance by managing the shuffle and sort behavior:</p><ul><li><p><strong>Partition sizing:</strong> Adjust <code>spark.sql.shuffle.partitions</code> so that each partition after shuffle is a reasonable size (Databricks often aims for ~128 MB per partition as a balance between parallelism and overhead). Too few partitions (huge partitions) mean slow sorts and potential disk spills; too many (tiny partitions) mean excessive task scheduling overhead. AQE can auto-coalesce partitions that are smaller than <code>spark.sql.adaptive.advisoryPartitionSizeInBytes</code> (default 64MB)&#8203;</p><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#:~:text=Converting%20sort,hash%20join">spark.apache.org</a></p></li><li><p><strong>Take advantage of sorting where possible:</strong> If your data is <strong>bucketed</strong> and sorted on the join keys (and both sides have the same number of buckets and join key bucketing), Spark can use a <strong>join without shuffle</strong> (it still sorts each bucket if not sorted, but avoids data movement). On Databricks, Delta Lake can maintain clustering (Z-order or sorting) on keys; while Spark does not automatically detect sort order for skipping the sort stage, having data clustered can improve CPU cache efficiency during the merge.</p></li><li><p><strong>Push down filters and projections:</strong> Reduce data size <em>before</em> the join. SMJ&#8217;s cost is super linear in data volume (due to sorting). If you can filter out unnecessary rows or columns (thus less data to shuffle), do it first. The Catalyst optimizer should push filters, but be mindful when writing queries (e.g., filter as early as possible in the query plan). Also, dropping unused columns means less data is carried through the shuffle.</p></li><li><p><strong>Monitor for skew:</strong> SMJ is particularly vulnerable to skewed keys: if one key accounts for a huge fraction of data, one shuffle partition will be enormous and the merge task for that partition will be a straggler&#8203;. We&#8217;ll discuss skew mitigation soon (Databricks can automatically split skewed partitions&#8203;. If you suspect skew, the Spark UI&#8217;s stage detail can show if one task processed far more data than others.</p></li></ul><h2><strong>When to use SMJ</strong> </h2><p>Typically, you don&#8217;t <em>force</em> a sort-merge join; Spark will use it by default for large data. But you might choose to use an SMJ (or let Spark use it) in cases where both datasets are large and similar in size, or when you&#8217;re doing a full outer join (which BHJ can&#8217;t handle). If one side can be broadcast but you choose not to (perhaps due to risk of OOM or because it&#8217;s just borderline size), SMJ will handle it gracefully. SMJ is also the strategy that can cope with <em>lack of statistics</em>: if Spark isn&#8217;t sure of sizes, it errs on the side of SMJ because it won&#8217;t blow up memory. On Databricks, if you disable adaptive execution or broadcasting, you are essentially forcing SMJ for all joins.</p><h2>Common Pitfalls</h2><ul><li><p>Inadequate shuffle partition tuning, leading to excessive disk spills or overhead from numerous tiny partitions.</p></li><li><p>Failure to minimize shuffle volume by removing unnecessary columns.</p></li><li><p>Ignoring or inadequately handling data skew.</p></li><li><p>Misjudging broadcast opportunities by incorrectly assessing dataset size (rely on in-memory exchange size, not disk size).</p></li></ul><blockquote><p><strong>Pitfalls:</strong> The major downside of SMJ is performance degradation if not tuned. Mistakes include not accounting for data skew (leading to very slow tasks) and leaving the default shuffle partitions at 200 regardless of data scale. For instance, joining two 1 TB tables with 200 partitions would create ~5 GB partitions, likely causing massive spills; increasing partitions (or using AQE) would be necessary. Another common pitfall is forgetting that <em>all columns</em> of both sides are shuffled by default. Projecting out unneeded columns can make a huge difference in shuffle volume. Also, if you have multiple joins in a single query (like joining 3-4 tables), Spark might form a multi-way join plan &#8211; consider breaking a very large join into steps or using broadcasts for some legs to avoid an overly expensive single SMJ of many inputs.</p></blockquote><h3>Conclusion</h3><p>Sort-Merge Join remains a foundational element in Spark's join strategies. Understanding its detailed mechanics&#8212;shuffle, sort, and merge phases. With careful tuning and vigilant analysis, SMJ can transform demanding Spark workloads into highly optimized, reliable operations. On Databricks, always keep AQE enabled for SMJ &#8211; it will automatically <strong>optimize partition counts and handle skew</strong>, making SMJ perform much better in practice than the static execution plans of the past&#8203;.</p><h4>Further Resources</h4><ul><li><p><strong>Apache Spark Official Documentation &#8211; SQL Performance Tuning:</strong> Covers join strategy hints, adaptive execution, etc. (See <em>&#8220;Join Strategy Hints&#8221;</em> and <em>&#8220;Adaptive Query Execution&#8221;</em> in the Spark docs)&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Join%20Strategy%20Hints%20for%20SQL,Queries">downloads.apache.org</a></p></li><li><p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/spark-tuning-glue-emr/using-join-hints-in-spark-sql.html#:~:text=,all%20executors%20within%20the%20cluster">Tuning Spark SQL queries for AWS Glue and Amazon EMR Spark jobs</a></p></li><li><p><strong>Apache Spark Official Documentation &#8211; Adaptive Query Execution (AQE):</strong> Detailed explanation of AQE features like converting SMJ to BHJ/SHJ and skew join handling&#8203;</p><p><a href="https://downloads.apache.org/spark/docs/3.1.2/sql-performance-tuning.html#:~:text=Converting%20sort,join">downloads.apache.org</a></p></li><li><p><strong>Databricks Documentation &#8211; Join Hints &amp; Optimizations:</strong> Databricks-specific docs on join strategies, including the <code>SKEW</code> and <code>RANGE</code> hints, and how AQE is used on Databricks&#8203;</p><p><a href="https://docs.databricks.com/gcp/en/transform/join#:~:text=Join%20hints%20on%20Databricks">docs.databricks.com</a></p></li><li><p><strong>&#8220;How Databricks Optimizes Spark SQL Joins&#8221; &#8211; Medium (dezimaldata):</strong> A blog post (Aug 2023) summarizing Databricks&#8217; techniques like CBO, AQE, range join and skew join optimizations&#8203;</p><p><a href="https://dezimaldata.medium.com/how-databricks-optimizes-the-spark-sql-joins-d01b95336d65#:~:text=In%20this%20blog%20post%2C%20we,joins%20and%20subqueries%2C%20such%20as">dezimaldata.medium.com</a></p></li><li><p><strong>Spark Summit Talks on Joins and AQE:</strong> Videos like <em>&#8220;Optimizing Shuffle Heavy Workloads&#8221;</em> or <em>&#8220;AQE in Spark 3.0&#8221;</em> (by Databricks engineers) for a deeper understanding of the internals of join execution and tuning.</p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints">https://spark.apache.org/docs/latest/sql-performance-tuning.html#join-strategy-hints</a></p></li><li><p><a href="https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution">https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution</a></p></li><li><p><a href="https://dezimaldata.medium.com/how-databricks-optimizes-the-spark-sql-joins-d01b95336d65">How Databricks Optimizes the Spark SQL Joins</a></p></li><li><p><a href="https://blogs.perficient.com/2025/03/28/top-5-mistakes-that-make-your-databricks-queries-slow-and-how-to-fix-them/">Top 5 Mistakes That Make Your Databricks Queries Slow (and How to Fix Them)</a></p></li></ul>]]></content:encoded></item></channel></rss>