<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Perfect-Hash-Join on Opensource internals</title><link>https://articles.9j.nz/tags/perfect-hash-join/</link><description>Posts filed under Perfect-Hash-Join on Opensource internals.</description><generator>Hugo</generator><language>en-US</language><managingEditor>Jay Hubbert</managingEditor><lastBuildDate>Wed, 21 Oct 2026 08:37:00 +0000</lastBuildDate><atom:link href="https://articles.9j.nz/tags/perfect-hash-join/index.xml" rel="self" type="application/rss+xml"/><item><title>The Perfect Hash Join: Turning a Hash Table Into a Plain Array When the Keys Are Dense Integers</title><link>https://articles.9j.nz/posts/how-datafusion-turns-a-hash-join-into-a-plain-array-lookup/</link><pubDate>Wed, 21 Oct 2026 08:37:00 +0000</pubDate><dc:creator>Jay Hubbert</dc:creator><guid isPermaLink="true">https://articles.9j.nz/posts/how-datafusion-turns-a-hash-join-into-a-plain-array-lookup/</guid><category>datafusion</category><category>joins</category><category>perfect-hash-join</category><category>performance</category><category>internals</category><description>When a join's build side has a single dense integer key, DataFusion replaces the hash table with a flat array indexed by key, and the probe becomes a single load.</description><content:encoded>&lt;blockquote&gt;&#10;&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A hash join&amp;rsquo;s probe costs a hash computation, a bucket lookup&#10;and a chain walk. But if the build side&amp;rsquo;s key is an integer in a narrow&#10;range — say &lt;code&gt;customer_id&lt;/code&gt; between 0 and 100,000 — you do not need a hash&#10;table at all. You need an &lt;em&gt;array&lt;/em&gt;. DataFusion detects this case, builds a&#10;flat &lt;code&gt;Vec&amp;lt;u32&amp;gt;&lt;/code&gt; indexed by the key, and turns the probe into a single&#10;array load. The interesting part is the &lt;em&gt;detection&lt;/em&gt;: the decision has to&#10;be made at runtime, from the build side&amp;rsquo;s actual key distribution, and it&#10;has to survive the fact that a partitioned build side may destroy the&#10;density.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Hash joins are the workhorse of analytical query processing, and the&#10;classic implementation is a hash table: hash the key, index a bucket&#10;array, walk a chain of colliding entries. It is a good design, and it&#10;handles arbitrary keys.&lt;/p&gt;&#10;&lt;p&gt;But it is the wrong design for a large class of real joins.&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;sql&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_name&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;facts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- dim_id is an integer 0..100000&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The build side has 100,000 rows with keys drawn from &lt;code&gt;[0, 100000)&lt;/code&gt;. The&#10;keys are &lt;em&gt;dense&lt;/em&gt;. A hash table spends a hash computation, a modulo, a&#10;bucket load, a chain walk and a comparison to find a row that is sitting&#10;at a known offset in an array.&lt;/p&gt;&#10;&lt;p&gt;The right structure here is an array.&lt;/p&gt;&#10;&lt;h2 id="the-array-map"&gt;The array map&lt;/h2&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;ArrayMap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// data[probSideVal-offset] -&amp;gt; valIdxInBuildSide + 1; 0 for absent&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// min val in buildSide&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;: &lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// next[buildSideIdx] -&amp;gt; next matching valIdxInBuildSide + 1; 0 for end of chain.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// If next is empty, it means there are no duplicate keys (no conflicts).&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// It uses the same chain-based conflict resolution as [`JoinHashMapType`].&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;num_of_distinct_key&lt;/span&gt;: &lt;span class="kt"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Three arrays, and each one is doing something specific.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;data&lt;/code&gt;&lt;/strong&gt; is the lookup table. It has one slot per possible key value in&#10;the range &lt;code&gt;[min, max]&lt;/code&gt;, and each slot holds the build-side row index plus&#10;one (so that zero can mean &amp;ldquo;absent&amp;rdquo;). The probe is:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;text&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;slot = probe_key - offset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;if slot out of range: no match&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;row = data[slot]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;if row == 0: no match&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;That is &lt;em&gt;one&lt;/em&gt; memory load and two comparisons. No hash, no modulo, no&#10;chain.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;offset&lt;/code&gt;&lt;/strong&gt; is the minimum key value, so that the array is indexed from&#10;zero even when the keys start at, say, 1,000,000.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;next&lt;/code&gt;&lt;/strong&gt; handles duplicate keys. If the build side has two rows with the&#10;same key, the array can only point at one of them, so the second is&#10;chained. And the comment notes the important optimisation:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;If next is empty, it means there are no duplicate keys (no conflicts).&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;When there are no duplicates — the common case for a dimension table — the&#10;chain array is not allocated at all, and the probe is a single load with&#10;no chain walk.&lt;/p&gt;&#10;&lt;h2 id="when-to-use-it"&gt;When to use it&lt;/h2&gt;&#10;&lt;p&gt;The decision is made in &lt;code&gt;array_map_key_range&lt;/code&gt;&#10;(&lt;code&gt;datafusion/physical-plan/src/joins/hash_join/exec.rs&lt;/code&gt;), and the&#10;conditions are documented on &lt;code&gt;HashJoinExec&lt;/code&gt;:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;For joins with a single integer-based join key, &lt;code&gt;HashJoinExec&lt;/code&gt; may use an&#10;&lt;code&gt;ArrayMap&lt;/code&gt; (also known as a &amp;ldquo;perfect hash join&amp;rdquo;) instead of a&#10;general-purpose hash map. This optimization is used when:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;There is exactly one join key.&lt;/li&gt;&#10;&lt;li&gt;The join key is an integer type up to 64 bits wide that can be&#10;losslessly converted to &lt;code&gt;u64&lt;/code&gt; (128-bit integer types such as &lt;code&gt;i128&lt;/code&gt; and&#10;&lt;code&gt;u128&lt;/code&gt; are not supported).&lt;/li&gt;&#10;&lt;li&gt;The range of keys is small enough (controlled by&#10;&lt;code&gt;perfect_hash_join_small_build_threshold&lt;/code&gt;) OR the keys are sufficiently&#10;dense (controlled by &lt;code&gt;perfect_hash_join_min_key_density&lt;/code&gt;).&lt;/li&gt;&#10;&lt;li&gt;build_side.num_rows() &amp;lt; u32::MAX&lt;/li&gt;&#10;&lt;li&gt;NullEqualsNothing || (NullEqualsNull &amp;amp;&amp;amp; build side doesn&amp;rsquo;t contain null)&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Five conditions. Let&amp;rsquo;s take them one at a time, because each one is a&#10;subtle correctness or resource constraint.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Condition 2 — integer types only.&lt;/strong&gt; The array is indexed by the key, so&#10;the key must be an integer that can be losslessly converted to &lt;code&gt;u64&lt;/code&gt;.&#10;&lt;code&gt;i128&lt;/code&gt; and &lt;code&gt;u128&lt;/code&gt; are excluded because they do not fit.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Condition 4 — &lt;code&gt;num_rows &amp;lt; u32::MAX&lt;/code&gt;.&lt;/strong&gt; The payload index in &lt;code&gt;data&lt;/code&gt; is a&#10;&lt;code&gt;u32&lt;/code&gt;. A build side with more than 4 billion rows cannot be indexed. The&#10;source has a &lt;code&gt;TODO: support create ArrayMap&amp;lt;u64&amp;gt;&lt;/code&gt; — a 64-bit index would&#10;double the memory of the lookup table, which is why it is not done by&#10;default.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Condition 5 — null handling.&lt;/strong&gt; If the join treats NULLs as equal to each&#10;other (&lt;code&gt;NullEqualsNull&lt;/code&gt;), then a build-side NULL key must be findable. The&#10;array has no slot for &amp;ldquo;null&amp;rdquo; (nulls are not a number), so the optimisation&#10;is disabled if the build side contains any NULL key:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;null_equality&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NullEquality&lt;/span&gt;::&lt;span class="n"&gt;NullEqualsNull&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;arrays&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;evaluate_expressions_to_arrays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;arrays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;null_count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;h2 id="the-density-heuristic"&gt;The density heuristic&lt;/h2&gt;&#10;&lt;p&gt;Condition 3 is the interesting one, and it has &lt;em&gt;two&lt;/em&gt; ways to be satisfied,&#10;because there are two different ways the array map can be a bad idea.&lt;/p&gt;&#10;&lt;p&gt;The array map&amp;rsquo;s memory is &lt;code&gt;O(max - min)&lt;/code&gt;, not &lt;code&gt;O(num_rows)&lt;/code&gt;. So a build&#10;side with 10 rows and keys &lt;code&gt;{0, 1_000_000, 2_000_000, ...}&lt;/code&gt; would allocate&#10;a 20-million-element array to store 10 rows. Catastrophic.&lt;/p&gt;&#10;&lt;p&gt;The two escape hatches:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dense_ratio&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;perfect_hash_join_small_build_threshold&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dense_ratio&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;perfect_hash_join_min_key_density&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;&lt;strong&gt;The small-range escape.&lt;/strong&gt; If the &lt;em&gt;range&lt;/em&gt; is small (below&#10;&lt;code&gt;perfect_hash_join_small_build_threshold&lt;/code&gt;, default 1024), the array is&#10;small no matter how sparse the keys are. A 1024-element array is 4 KB —&#10;negligible — so even 3 rows in it is fine. The check is &lt;code&gt;range &amp;gt;= threshold &amp;amp;&amp;amp; density &amp;lt;= limit&lt;/code&gt;, i.e. &lt;em&gt;both&lt;/em&gt; must be bad to reject. If the range is&#10;small, the density does not matter.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;The density escape.&lt;/strong&gt; Otherwise, the keys must be dense enough:&#10;&lt;code&gt;density &amp;gt; perfect_hash_join_min_key_density&lt;/code&gt; (default 0.15). So at least&#10;15% of the slots in the range must be occupied. A build side with 150,000&#10;rows spanning &lt;code&gt;[0, 1_000_000]&lt;/code&gt; has a density of 0.15 — the array is&#10;4 MB and holds 150,000 rows, which is 26 bytes per row of array space&#10;versus a hash table&amp;rsquo;s own overhead. Worth it.&lt;/p&gt;&#10;&lt;p&gt;And the two thresholds are &lt;em&gt;configurable&lt;/em&gt;, because &amp;ldquo;how dense is dense&#10;enough&amp;rdquo; depends on the hardware, the key width, and the workload.&lt;/p&gt;&#10;&lt;h2 id="the-overflow-trap"&gt;The overflow trap&lt;/h2&gt;&#10;&lt;p&gt;There is a bug-shaped detail in the code that is worth pointing out,&#10;because it is the kind of thing that produces a wrong answer rather than&#10;a crash:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// When the key range spans the full integer domain (e.g. i64::MIN to i64::MAX),&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// range is u64::MAX and `range + 1` below would overflow.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;usize&lt;/span&gt;::&lt;span class="no"&gt;MAX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dense_ratio&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;&lt;code&gt;range&lt;/code&gt; is &lt;code&gt;max - min&lt;/code&gt; as a &lt;code&gt;u64&lt;/code&gt;. If the build side contains both&#10;&lt;code&gt;i64::MIN&lt;/code&gt; and &lt;code&gt;i64::MAX&lt;/code&gt;, the range is &lt;code&gt;u64::MAX&lt;/code&gt;. Then &lt;code&gt;range + 1&lt;/code&gt;&#10;overflows — in release mode, &lt;code&gt;u64::MAX + 1&lt;/code&gt; wraps to zero, and the density&#10;becomes &lt;code&gt;num_row / 0&lt;/code&gt; = infinity, which would &lt;em&gt;pass&lt;/em&gt; the density check and&#10;allocate an astronomically large array.&lt;/p&gt;&#10;&lt;p&gt;The guard is placed before the division. This is exactly the kind of&#10;integer-overflow bug that a fuzzer finds and a careful reviewer prevents.&lt;/p&gt;&#10;&lt;h2 id="the-partitioning-trap"&gt;The partitioning trap&lt;/h2&gt;&#10;&lt;p&gt;And then there is the subtlety that makes this optimisation much harder&#10;than it looks. The doc on &lt;code&gt;HashJoinExec&lt;/code&gt; spells it out:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Note that when using &lt;code&gt;PartitionMode::Partitioned&lt;/code&gt;, the build side is split&#10;into multiple partitions. This can cause a dense build side to become&#10;sparse within each partition, potentially disabling this optimization.&lt;/p&gt;&#10;&lt;p&gt;For example, consider:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;sql&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;With 24 partitions, each partition will only receive a subset of the&#10;10,000 rows. The first partition might contain values like &lt;code&gt;3, 10, 18, 39, 43&lt;/code&gt;, which are sparse relative to the original range, even though the&#10;overall data set is dense.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;This is a beautiful example of a &lt;em&gt;global&lt;/em&gt; property becoming a &lt;em&gt;local&lt;/em&gt;&#10;property. The whole build side is dense — keys 0..10,000 with no gaps. But&#10;hash-partitioning by &lt;code&gt;value&lt;/code&gt; gives partition 0 the keys &lt;code&gt;0, 417, 834, ...&lt;/code&gt;, and within &lt;em&gt;that&lt;/em&gt; partition the density is 1/417, not 1.&lt;/p&gt;&#10;&lt;p&gt;So the array map is evaluated &lt;em&gt;per partition&lt;/em&gt;, on that partition&amp;rsquo;s keys,&#10;and the density check uses the partition&amp;rsquo;s own min and max. Partition 0&#10;might have min=0, max=9996, and 10,000/24 = 417 rows — density 0.04,&#10;below the 0.15 threshold. Optimisation disabled.&lt;/p&gt;&#10;&lt;p&gt;Which is the right answer for a &lt;em&gt;local&lt;/em&gt; array map. But it means the&#10;perfect hash join mostly applies to &lt;code&gt;CollectLeft&lt;/code&gt; mode (one build side,&#10;read once) or to cases where the partitioning preserves density.&lt;/p&gt;&#10;&lt;h2 id="the-measurement"&gt;The measurement&lt;/h2&gt;&#10;&lt;p&gt;The code has a dedicated metric for this:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;ARRAY_MAP_CREATED_COUNT_METRIC_NAME&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;array_map_created_count&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;So you can see, in &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, whether the optimisation actually&#10;fired. Without such a metric, an optimisation that fires unpredictably&#10;(because it depends on the runtime key distribution) is impossible to&#10;tune. With it, you can look at a workload and see &amp;ldquo;the array map fired on&#10;12 of 200 joins&amp;rdquo; and know that the other 188 would not have benefited&#10;from a denser key space.&lt;/p&gt;&#10;&lt;h2 id="the-catch"&gt;The catch&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Memory is &lt;code&gt;O(range)&lt;/code&gt;, not &lt;code&gt;O(rows)&lt;/code&gt;.&lt;/strong&gt; The density check bounds this,&#10;but the bound is a heuristic with a configurable threshold.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Only integer keys.&lt;/strong&gt; Strings, dates and decimals do not get the array&#10;map. (Dates could, in principle, since they are integers underneath, but&#10;the implementation does not.)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Single key only.&lt;/strong&gt; A composite key &lt;code&gt;(a, b)&lt;/code&gt; would need a hash — unless&#10;the pair can be packed into one integer, which the implementation does&#10;not do.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The decision is per-partition and therefore unstable.&lt;/strong&gt; The same&#10;query on the same data can take the array-map path on one run and the&#10;hash-table path on another, if the partitioning changes. The metric&#10;exists precisely because of this.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;It is a fast path, not a replacement.&lt;/strong&gt; The hash table is still&#10;there, and the hash table is still needed for the general case. The&#10;array map is an optimisation layered on top.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="the-general-lesson"&gt;The general lesson&lt;/h2&gt;&#10;&lt;p&gt;The perfect hash join is a 300-year-old idea — it is what every&#10;&amp;ldquo;lookup table&amp;rdquo; and every &amp;ldquo;direct addressing&amp;rdquo; data structure is — applied&#10;to a modern setting where the key distribution is only known at runtime.&lt;/p&gt;&#10;&lt;p&gt;The interesting engineering is not the array. It is the &lt;em&gt;decision&#10;procedure&lt;/em&gt;: two thresholds, an overflow guard, a per-partition&#10;re-evaluation, and a metric so you can see what happened. A fast path&#10;that fires based on a runtime property is only useful if you can also&#10;observe whether it fired.&lt;/p&gt;&#10;</content:encoded></item></channel></rss>