From 24398fa73f88a1824813f2d1053f14734c793f85 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Mon, 19 Oct 2026 08:23:04 -0400
Subject: [PATCH v2.1 5/6] Document enable_hashjoin_bloom and the EXPLAIN
 output

Add the enable_hashjoin_bloom entry to config.sgml, describing what the
Bloom filter does, when a hash join builds one, how much memory it may
use, and that it does not affect the plans chosen by the planner.

Extend the EXPLAIN ANALYZE section of perform.sgml. The existing hash
join example gains the "Bloom Filter" line, and a new EXPLAIN (ANALYZE,
VERBOSE) example shows the "Hash Lookups" and "Bloom Filter Probes"
lines, with an explanation of how the numbers come about.
---
 doc/src/sgml/config.sgml  | 33 +++++++++++++++++++++
 doc/src/sgml/perform.sgml | 61 ++++++++++++++++++++++++++++++++++++++-
 2 files changed, 93 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index f7db2995c3e..f1517d9fda8 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -5983,6 +5983,39 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-enable-hashjoin-bloom" xreflabel="enable_hashjoin_bloom">
+      <term><varname>enable_hashjoin_bloom</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>enable_hashjoin_bloom</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables or disables the use of Bloom filters in hash joins.  When
+        enabled, a hash join may build a Bloom filter on the join keys of the
+        rows stored in the hash table, and check the rows from the other side
+        of the join against the filter before looking them up in the hash
+        table.  Rows rejected by the filter cannot have a match, so they skip
+        the lookup entirely, and if the hash table does not fit into memory
+        and the join has to be processed in batches, they are not written to
+        the temporary batch files either.  This can speed up joins in which
+        only a small fraction of the rows has a matching row in the hash table,
+        and has little effect otherwise.
+       </para>
+       <para>
+        The filter is built right away if the hash table is expected to need
+        more than one batch, and otherwise only after the first lookups show
+        that fewer than 90% of the rows have a match; a filter that turns out
+        to reject few rows is mostly bypassed.  The filter needs between 8kB
+        and <xref linkend="guc-work-mem"/> of memory, in addition to the hash
+        table itself.  This parameter does not affect the plans chosen by the
+        query planner.  <command>EXPLAIN ANALYZE</command> shows whether a
+        hash join used a filter; see <xref linkend="using-explain-analyze"/>.
+        The default is <literal>on</literal>.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-enable-incremental-sort" xreflabel="enable_incremental_sort">
       <term><varname>enable_incremental_sort</varname> (<type>boolean</type>)
       <indexterm>
diff --git a/doc/src/sgml/perform.sgml b/doc/src/sgml/perform.sgml
index ea8da01b779..c056957d720 100644
--- a/doc/src/sgml/perform.sgml
+++ b/doc/src/sgml/perform.sgml
@@ -842,6 +842,7 @@ WHERE t1.unique1 &lt; 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
                Buffers: shared hit=345
          -&gt;  Hash  (cost=224.98..224.98 rows=100 width=244) (actual time=0.476..0.477 rows=100.00 loops=1)
                Buckets: 1024  Batches: 1  Memory Usage: 35kB
+               Bloom Filter: Size: 8kB  Hash Functions: 10  False Positive Rate: 0.000%
                Buffers: shared hit=92
                -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=5.06..224.98 rows=100 width=244) (actual time=0.030..0.450 rows=100.00 loops=1)
                      Recheck Cond: (unique1 &lt; 100)
@@ -862,7 +863,65 @@ WHERE t1.unique1 &lt; 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
     The Hash node shows the number of hash buckets and batches as well as the
     peak amount of memory used for the hash table.  (If the number of batches
     exceeds one, there will also be disk space usage involved, but that is not
-    shown.)
+    shown.)  The <quote>Bloom Filter</quote> line appears when the Hash node
+    built a Bloom filter on the join keys of the rows in the hash table, which
+    allows the join to reject rows from the other side without looking them
+    up in the hash table (see <xref linkend="guc-enable-hashjoin-bloom"/>).
+    It shows the size of the filter, the number of hash functions it uses,
+    and its false positive rate, estimated from the fraction of bits set in
+    the filter at the end of the join.  In this example only 100 of the 10000
+    rows of <literal>tenk2</literal> have a match, so the join built the
+    filter once the first lookups showed that most rows have none.
+   </para>
+
+   <para>
+    With the <literal>VERBOSE</literal> option, the Hash node also reports
+    how many rows were looked up in the hash table and probed against the
+    Bloom filter, and how many of those lookups found a match or passed the
+    filter:
+
+<screen>
+EXPLAIN (ANALYZE, VERBOSE, BUFFERS OFF) SELECT t1.unique1, t2.unique2
+FROM tenk1 t1, tenk2 t2
+WHERE t1.unique1 &lt; 1000 AND t1.unique2 = t2.unique2;
+                                                                                                                                           QUERY PLAN
+-------------------------------------------------------------------&zwsp;-------------------------------------------------------------------&zwsp;-----------------
+ Hash Join  (cost=390.32..703.82 rows=1000 width=8) (actual time=0.743..2.481 rows=1000.00 loops=1)
+   Output: t1.unique1, t2.unique2
+   Hash Cond: (t2.unique2 = t1.unique2)
+   -&gt;  Index Only Scan using tenk2_unique2 on public.tenk2 t2  (cost=0.29..266.29 rows=10000 width=4) (actual time=0.021..1.021 rows=10000.00 loops=1)
+         Output: t2.unique2
+         Heap Fetches: 0
+         Index Searches: 1
+   -&gt;  Hash  (cost=377.54..377.54 rows=1000 width=8) (actual time=0.706..0.709 rows=1000.00 loops=1)
+         Output: t1.unique1, t1.unique2
+         Buckets: 1024  Batches: 1  Memory Usage: 48kB
+         Hash Lookups: 1914  Matches: 1000  Match Rate: 52.247%
+         Bloom Filter: Size: 8kB  Hash Functions: 10  False Positive Rate: 0.000%
+         Bloom Filter Probes: 9000  Matches: 914  Match Rate: 10.156%
+         -&gt;  Bitmap Heap Scan on public.tenk1 t1  (cost=20.04..377.54 rows=1000 width=8) (actual time=0.122..0.547 rows=1000.00 loops=1)
+               Output: t1.unique1, t1.unique2
+               Recheck Cond: (t1.unique1 &lt; 1000)
+               Heap Blocks: exact=333
+               -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..19.79 rows=1000 width=0) (actual time=0.087..0.087 rows=1000.00 loops=1)
+                     Index Cond: (t1.unique1 &lt; 1000)
+                     Index Searches: 1
+ Planning Time: 0.919 ms
+ Execution Time: 2.573 ms
+</screen>
+
+    The hash table was expected to fit in memory, so the join started without
+    a filter and looked up the first 1000 rows of <literal>tenk2</literal>
+    directly.  Only about a tenth of them had a match, so the Hash node built
+    a Bloom filter from the 1000 keys in the hash table and checked the
+    remaining 9000 rows against it before looking them up.  The filter
+    rejected all but 914 rows, and those were all true matches, consistent
+    with the negligible false positive rate.  The <quote>Hash Lookups</quote>
+    line counts only the rows that actually reached the hash table, so it
+    does not include the rows the filter rejected.  Conversely, if a filter
+    turns out to reject few rows, most rows skip it and go straight to the
+    hash table, and the number of filter probes falls well below the number
+    of rows the join had to process.
    </para>
 
    <para>
-- 
2.55.0

