Subject: hashjoins vs. Bloom filters (yet again)

From: Tomas Vondra <[email-masked]>
Date: Sat, 30 May 2026 02:55:43 +0200

Hi,

A random discussion at pgconf.dev made me revisit one of my ancient
patches, attempting to use Bloom filters to hash joins. I did work on
that twice in the past - first in 2015/6 [1], then in 2018 [2]. So let
me briefly revisit that, before I get to the new patch.

old patches
-----------

Those old patches tried to do a fairly small thing during a hash join,
and that's building a Bloom filter on the inner relation (the one that
gets hashed), and then use that filter before probing the hash table.

The benefits come from Bloom filters being (fairly) cheap, and a
negative answer (hash is not in the filter) may allows us to skip a much
more expensive operation.

The old threads patches focused especially at two hash join cases:

(a) A very selective join, i.e. a significant fraction of outer tuples
does not have a match in the hash table.

(b) A selective hash join forced to do batching because the hash table
is too large, and thus forced to spill outer tuples to temporary files.

For (a), the benefit comes from Bloom filters being much cheaper to
probe than a hash table. The exact cost depends on the implementation,
sizes, etc. We're in the ballpark of 50 vs. 500 cycles, maybe. But if
the filter discards 90% of tuples, it can be a big win.

For (b), the filter (for all the batches at once) allows us to discard
some of the outer tuples without writing them to temporary files. Which
is way more expensive than probing a hash table.

The patches got stuck mostly because deciding if it makes sense to
build/use the Bloom filter is somewhat hard. For cases where 100% of the
tuples have a match it's pointless - it's just pure cost, no benefit.
The regressions are relatively small, though (<10%).

For (b) it's much less sensitive to this kind of issues, of course. The
cost of writing outer tuples to temporary files is much higher than
building/probing a Bloom filter.

Clearly, a filter that discards 99% of tuples is great. And a filter
that keeps 99% of tuples is not great. But where exactly are the
thresholds is not quite clear.

There's also a related question of sizing the filter. Bloom filters are
usually sized by specifying the number of distinct values and the
desired false positive rate. And we could try doing that - pick a
standard false positive rate (e.g. the built-in bloom_filter aims for
1-2%), estimate the ndistinct, and get the size of the Bloom filter.

However, chances are the filter is too big. We can't get work_mem, the
join is already using that for the hash table etc. We can maybe use a
fraction of it, and that may not be enough to fit the "perfect" filter.
We could bail out and not use any Bloom filter at all, but that seems a
bit silly. Maybe we can't fit the 2% filter, but 5% of 10% would be OK?

Surely if the join selectivity is 1% (i.e. it discards 99% tuples), then
using a "worse" Bloom filter with 10% false positives would be a win?
It'd still discard ~89% of tuples.

Yet another angle leading to this kind of questions is inaccurate
ndistinct estimates (and we all know those estimates can be quite
unreliable). Let's say we size the filter for 1M distinct values (and it
just about fits into the memory budget), but then during execution we
find there are 2M distinct values. Well, now we may have ~10% false
positive rate. Or maybe we got 5M, and it's 30%. Or 10M / 50%.

At some point the filter stops being worth it, and we should either not
build it, or we should stop probing it. But when is that?

I think we'd need some sort of cost model to make judgments about this.

Anyway, this was just me summarizing the old threads, and what I think
got them stuck. Most of these questions are still open, although I think
we may be able to solve them better than we could ~10 years ago. We have
extended stats, we know about FK constraints during planning, ...

new patch
---------

Now let's talk about the new experimental/PoC patch that came from the
pgconf.dev discussions. It doesn't really solve the issues I just went
through, it's more of an attempt to take it one step further.

One of the things mentioned in the 2018 thread was the possibility to
push the filter much deeper, instead of using it just in the hash join
node itself. It was merely discussed, but there was no code written, or
anything like that. But it's the thing I decided to take a stab at after
getting back from Vancouver.

Consider a starjoin query

  SELECT + FROM f JOIN d1 (f.id1 = d1.id)
                  JOIN d2 (f.id2 = d2.id)
                  JOIN d2 (f.id3 = d3.id)
   WHERE d1.x = 1
     AND d2.y = 2
     AND d3.z = 3;

which will be planned using a left-deep plan like this one:

        HJ
      /    \
    D3     HJ
         /    \
        D2    HJ
            /    \
           D1     F

With hashes on "D" tables, and a scan on "F". With the "old" patches,
each HJ node would use a Bloom filter internally. But there's an
interesting opportunity to "push down" the filters to the scan on "F",
and evaluate them right there, a bit as if the scan had a local qual.

The attached patch implements a PoC of this, and it's pretty effective.

Of course, it depends on the selectivity of the joins (and thus how many
tuples get discarded by the filters). But because it moves all the
"cheap" filter probes *before* probing any of the hash tables, it has a
multiplication effect for the benefits.

Yes, it still has most of the open issues discussed earlier, and those
will need to be addressed. But this "multiplication" may also make it
somewhat less sensitive to the regressions.

In the example above, if each of the 3 joins has 20% selectivity (i.e.
20% tuples go through), then the total selectivity is ~1%. So the "F"
scan produces only 1/100 of tuples. Maybe we got one of the joins wrong,
and it does not eliminate any tuples? That still means the overall
selectivity is only ~4%.

Of course, this only works for larger joins, and maybe the joins are
correlated in some weird way, etc. Also, what does 4% selectivity mean
for the overall query duration?

Attached is a PDF with results from a simple benchmark using joins like
the one above - fact + 1-3 dimensions. The scripts (in the .tgz) set a
couple GUCs to eliminate variations in the plan. The dimension joins are
independent and match a variable fraction of the fact (1% - 100%).

The columns are for three branches - master, and "patched" with the
push-down disabled and enabled, for joins with 1-3 dimensions.

The last two column groups are comparing the "patched" results to
master. With "off" there's no difference (other than random noise), just
as expected. But with the push-down enabled, there are fairly
significant speedups (up to ~3x). Of course, this is just a benchmark,
practical queries may do other stuff, making the gains smaller. OTOH, it
may also be much better, if there are expensive nodes in between.

The PoC patch is not very big or complex. 280KB seems like a lot, but
like 99% of that is changes in test output, because the patch adds some
info about the Bloom filters to EXPLAIN. The actual .c changes are only
~1000 lines, and a half of that is comments.

The most interesting stuff happens in create_hashjoin_plan(), where we
attempt to push-down the filter to a scan in the outer subtree. If that
succeeds, then ExecInitHashJoin initializes the filter so that the scan
can find it, and Hash builds the filter along with the hash table. And
then the scan nodes probe the pushed-down filter in ExecScanExtended().

There's bunch of boilerplate so that setrefs does the right thing with
expressions, etc. But it's a couple lines here and there. I'm actually
surprised how little code this is.

There's one detail I haven't mentioned yet - there's a simple adaptive
behavior, to deal with filters that are not selective enough. Per some
initial tests there's little benefit when the filter keeps >75% tuples,
and for >90% there were measurable regressions (~50%). This was very
consistent for different data types, etc.

So the patch tracks number of matching tuples per 1000 probes, when it
exceeds 90% it switches to sampling. Only 1% of tuples gets probed in
the filter, and if the fraction drops <80%, all the tuples get probed
again. This is very simple, needs more thought. But for the purpose of
the testing it worked quite well. There still is a small regression
(~3%), which I assume is due to building the filter.

Aside from the issues with deciding if to use a filter at all, sizing
it, etc. - which are still valid (even with the adaptive thing), and
need to be solved, there's one more annoying issue specific to this new
push-down stuff.

Earlier, I mentioned the push-down happens in create_hashjoin_plan().
Which means it happens *after* planning and costing. There are reasons
for that, but it has some unfortunate & annoying consequences.

Ideally, we'd know about the filters when constructing the scan nodes,
so we'd have a chance to estimate how many tuples will be eliminated by
probing the filters (which is about the same thing as estimating the
join sizes). But we can't do that, because our planner works bottom-up.
When constructing the scan nodes we know which tables we'll join with,
but we have no idea which of the join algorithms we'll pick.

We'll consider all three join types, and the scan node has no say which
of those will win. But the Bloom filter push-down is specific to hash
joins. So what should the scan node do? Either it can assume it's under
hash join (and set rows/cost as if there's a Bloom filter), or it can
set costs in a join-agnostic way (like now).

The only "correct" way I can think of dealing with this in the bottom-up
world is having two sets of paths - one set for a hash join, one set for
other joins. But that's not just for scans. We'd need that for all
paths, and for different combinations of joins. For the query with 3
joins, we'd end up with 2^3 combinations. That seems not great.

So I tend to see this as an opportunistic optimization. We do the
planning assuming there's no Bloom filter push-down, and then after the
fact we see if there's an opportunity after all. Which means we may not
pick a plan with hash joins, not realizing it might be made faster.

But in my mind that's somewhat acceptable / defensible.

The bigger issue for me is that it may make the EXPLAIN ANALYZE output
way harder to understand. The estimated "rows" are calculated before the
filter push-down happens, while the actual "rows" are with the filter
probing, of course. But it seems pretty easy to get confused by this,
and think it's just an incorrect estimate.

summary
-------

I like the idea of pushing filters down to the scan nodes (or perhaps
even to some other intermediate nodes). But maybe it's too incompatible
with our bottom-up planning, and the issues with costing and/or EXPLAIN
output may be impossible to solve. I wonder what others think.

Now that I revisited the older threads, I think it probably makes sense
with using Bloom filters in the hash join, at least in the two cases
mentioned in the first section. It doesn't have the issues with
bottom-up planning/costing, because it happens in the hash join. And the
issues with that (deciding what fractions are OK, sizing the filter,
...) apply to both that simpler case, and to the push-down.

regards

[1] https://www.postgresql.org/message-id/5670946E.8070705%402ndquadrant.com

[2]
https://www.postgresql.org/message-id/c902844d-837f-5f63-ced3-9f7fd222f175%402ndquadrant.com

-- 
Tomas Vondra
From 661830be323389e1be427269e718544cfea486c6 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <[email-masked]>
Date: Fri, 29 May 2026 16:29:07 +0200
Subject: [PATCH v1] PoC: hashjoin bloom filter pushdown

When construction hashjoin plans, try to pushdown a Bloom filter built
on the hashtable to a scan node in the outer side of the join. This has
multiple significant benefits:

a) Probing a bloom filter is cheaper than probing a hash table, so if
   a tuple gets eliminated using the bloom filter, we save cycles.

b) The Bloom filter is more compact, and so more cache efficient. The
   hash table may not even split into memory, and the hashjoin has to
   spill data to files. The Bloom filter may still fit into memory,
   and eliminate many tuples on the outer side (which reduces the
   amount of data spilled to disk).

c) The Bloom filter is pushed to a scan node, which may be multiple
   steps before the join. This increases the benefit, because the
   eliminated tuples don't need to pass through any of the nodes in
   between. This futehr amplifies the difference between probing a
   filter and probing the hashtable.

d) If a table joins with multiple other tables (e.g. in a starjoin), the
   scan node may receive multiple Bloom filters. The selectivity of the
   filters multiply, once again amplifying the benefits. If a scan gets
   two filters, each discarding 90% of tuples, the scan will discard 99%
   of tuples, i.e. 2 orders of magnitude fewer tuples.

This patch performs Bloom filter pushdown when constructing the plan,
after path selection. That means the filters are not considered when
estimating and costing the paths, and we only have a chance to do the
pushdown if we happen to a pick a plan with a hashjoin. If the pushdown
is what makes the plan fast (faster than plans without hashjoins), we
may not pick it. With our bottom-up planning it's hard to do better.

The decision which Bloom filters to build (and which scan nodes should
evaluate them) happens in create_hashjoin_plan. This registers the
filters in both the hashjoin and the recipient scan node, etc.

Then at execution time, the Hash node builds the filter with the
hashtable, and the scan node probes the Bloom filter similarly to
evaluating the regular quals.

How effective this is depends on how many tuples the filter eliminates.
A highly selective filter (e.g. discarding >90% tuples) is going to be a
win no matter what. But even a "poor" filter (e.g. discarding only 10%
tuples) may still be a win, if the hashjoin has to perform batching, and
thus spill data to disk.

It's hard to know in advance which filters are selective. The patch has
a simple adaptive logic, that disables filters that are not selective
enough (too many probes find a match), and the enables the filter when
the filter gets more selective.

There's a number of open questions to solve:

- Pushdown after path construction means we can't consider Bloom
  filters when costing the paths, and the cost are as if no tuples were
  eliminated by the scan node. Solving this with the bottom-up planning
  is unlikely, or would have disadvantages (e.g. would increase the
  number of paths we have to condsider).

- The EXPLAIN ANALYZE output can be somewhat confusing/misleading. The
  path estimates don't consider how many rows may be eliminated by the
  filter. This may lead to huge differences between estimated and actual
  "rows" in the EXPLAIN output, even with perfect estimates. The EXPLAIN
  now includes information about Bloom filters (number of probes and
  number of discarded rows), but it's still hard to interpret.

- We have little control over the Bloom filter parameters. The library
  picks most of the attributes on our behalf. Works reasonably well, but
  we may need to know e.g. false positive rate (if it gets too high, the
  filter becomes useless, and we should stop using it).

- We only push filters to scan nodes, and only through some other nodes
  (e.g. through joins, sort, ...). We could expand this to also pushdown
  through aggregations, etc.

- The patch does not support parallel queries. This can be addressed
  later, it's certainly doable.

- Similarly, there's no support for partitioned tables (fixing this
  should be simpler than supporting parallel queries).

- It might be interesting to allow the scan nodes to use the Bloom
  filters in other ways. E.g. it might push the filter to storage, or
  perhaps to remote node (with a ForeignScan), and let it do smart things
  with it. The storage might prefilter data, foreign server could filter
  data on the remote end. That'd require using some well defined and
  portable library for the filter.

- The cost model determining which filters are effective is a bit crude
  and based on empirical observations. For example the thresholds used
  in the adaptive logic are somewhat arbitrary and need more thought.

- We could push filters into other nodes, not just scans. Might be
  useful for more complex joins.
---
 .../pg_plan_advice/expected/join_order.out    |  52 +-
 .../pg_plan_advice/expected/join_strategy.out |  20 +-
 .../pg_plan_advice/expected/partitionwise.out |  40 +-
 contrib/pg_plan_advice/expected/semijoin.out  |  24 +-
 .../expected/pg_stash_advice.out              |  54 ++-
 .../expected/level_tracking.out               |  24 +-
 .../postgres_fdw/expected/postgres_fdw.out    |   8 +-
 src/backend/commands/explain.c                | 189 ++++++++
 src/backend/executor/execUtils.c              |   2 +
 src/backend/executor/nodeBitmapHeapscan.c     |   3 +
 src/backend/executor/nodeHash.c               |  60 +++
 src/backend/executor/nodeHashjoin.c           | 455 ++++++++++++++++++
 src/backend/executor/nodeIndexonlyscan.c      |   3 +
 src/backend/executor/nodeIndexscan.c          |   3 +
 src/backend/executor/nodeSamplescan.c         |   3 +
 src/backend/executor/nodeSeqscan.c            |   3 +
 src/backend/executor/nodeTidrangescan.c       |   3 +
 src/backend/executor/nodeTidscan.c            |   3 +
 src/backend/lib/bloomfilter.c                 |  19 +
 src/backend/optimizer/path/costsize.c         |   1 +
 src/backend/optimizer/plan/createplan.c       | 299 ++++++++++++
 src/backend/optimizer/plan/planner.c          |   1 +
 src/backend/optimizer/plan/setrefs.c          |  63 +++
 src/backend/utils/misc/guc_parameters.dat     |   7 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/executor/execScan.h               |  22 +-
 src/include/executor/executor.h               |  12 +
 src/include/executor/nodeHashjoin.h           |   9 +
 src/include/lib/bloomfilter.h                 |   2 +
 src/include/nodes/execnodes.h                 |  91 ++++
 src/include/nodes/pathnodes.h                 |   3 +
 src/include/nodes/plannodes.h                 |  51 ++
 src/include/optimizer/cost.h                  |   1 +
 src/test/regress/expected/aggregates.out      |   8 +-
 src/test/regress/expected/eager_aggregate.out | 166 ++++++-
 src/test/regress/expected/join.out            | 176 +++++--
 src/test/regress/expected/join_hash.out       |  28 +-
 src/test/regress/expected/merge.out           |  20 +-
 src/test/regress/expected/misc_functions.out  |   4 +-
 .../regress/expected/partition_aggregate.out  |  34 +-
 src/test/regress/expected/partition_join.out  | 452 ++++++++++++++---
 src/test/regress/expected/predicate.out       |   8 +-
 src/test/regress/expected/privileges.out      |   4 +-
 src/test/regress/expected/returning.out       |  10 +-
 src/test/regress/expected/rowsecurity.out     |   2 +
 src/test/regress/expected/select_views.out    |   2 +
 src/test/regress/expected/stats_ext.out       |   4 +-
 src/test/regress/expected/subselect.out       |  64 ++-
 src/test/regress/expected/sysviews.out        |   3 +-
 src/test/regress/expected/updatable_views.out |  12 +-
 src/test/regress/expected/window.out          |   4 +-
 src/test/regress/expected/with.out            |  16 +-
 src/test/regress/sql/rowsecurity.sql          |   3 +
 src/test/regress/sql/select_views.sql         |   3 +
 54 files changed, 2313 insertions(+), 241 deletions(-)

diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
index a5a9728e3fd..0e5f93a046f 100644
--- a/contrib/pg_plan_advice/expected/join_order.out
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -27,17 +27,21 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on jo_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on jo_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on jo_dim1 d1
                Filter: (val1 = 1)
  Generated Plan Advice:
@@ -45,7 +49,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d2 d1)
    SEQ_SCAN(f d2 d1)
    NO_GATHER(f d1 d2)
-(16 rows)
+(20 rows)
 
 -- Force a few different join orders. Some of these are very inefficient,
 -- but the planner considers them all viable.
@@ -56,17 +60,21 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim2_id = d2.id)
    ->  Hash Join
          Hash Cond: (f.dim1_id = d1.id)
          ->  Seq Scan on jo_fact f
+               Bloom Filter 1: keys=(dim1_id)
+               Bloom Filter 2: keys=(dim2_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on jo_dim1 d1
                      Filter: (val1 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on jo_dim2 d2
                Filter: (val2 = 1)
  Supplied Plan Advice:
@@ -76,7 +84,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d1 d2)
    SEQ_SCAN(f d1 d2)
    NO_GATHER(f d1 d2)
-(18 rows)
+(22 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -84,17 +92,21 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on jo_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on jo_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on jo_dim1 d1
                Filter: (val1 = 1)
  Supplied Plan Advice:
@@ -104,7 +116,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d2 d1)
    SEQ_SCAN(f d2 d1)
    NO_GATHER(f d1 d2)
-(18 rows)
+(22 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -120,7 +132,9 @@ SELECT * FROM jo_fact f
          Hash Cond: (d1.id = f.dim1_id)
          ->  Seq Scan on jo_dim1 d1
                Filter: (val1 = 1)
+               Bloom Filter 1: keys=(id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on jo_fact f
    ->  Hash
          ->  Seq Scan on jo_dim2 d2
@@ -132,7 +146,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(f d2)
    SEQ_SCAN(d1 f d2)
    NO_GATHER(f d1 d2)
-(18 rows)
+(20 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -145,7 +159,9 @@ SELECT * FROM jo_fact f
  Hash Join
    Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
    ->  Seq Scan on jo_fact f
+         Bloom Filter 1: keys=(dim1_id, dim2_id)
    ->  Hash
+         Bloom Filter 1
          ->  Nested Loop
                ->  Seq Scan on jo_dim1 d1
                      Filter: (val1 = 1)
@@ -160,7 +176,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN((d1 d2))
    SEQ_SCAN(f d1 d2)
    NO_GATHER(f d1 d2)
-(18 rows)
+(20 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(f {d1 d2})';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -173,7 +189,9 @@ SELECT * FROM jo_fact f
  Hash Join
    Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
    ->  Seq Scan on jo_fact f
+         Bloom Filter 1: keys=(dim1_id, dim2_id)
    ->  Hash
+         Bloom Filter 1
          ->  Nested Loop
                ->  Seq Scan on jo_dim1 d1
                      Filter: (val1 = 1)
@@ -188,7 +206,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN((d1 d2))
    SEQ_SCAN(f d1 d2)
    NO_GATHER(f d1 d2)
-(18 rows)
+(20 rows)
 
 COMMIT;
 -- Force a join order by mentioning just a prefix of the join list.
@@ -199,17 +217,21 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                   QUERY PLAN                   
-------------------------------------------------
+                     QUERY PLAN                     
+----------------------------------------------------
  Hash Join
    Hash Cond: (d2.id = f.dim2_id)
    ->  Seq Scan on jo_dim2 d2
          Filter: (val2 = 1)
+         Bloom Filter 2: keys=(id)
    ->  Hash
+         Bloom Filter 2
          ->  Hash Join
                Hash Cond: (f.dim1_id = d1.id)
                ->  Seq Scan on jo_fact f
+                     Bloom Filter 1: keys=(dim1_id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on jo_dim1 d1
                            Filter: (val1 = 1)
  Supplied Plan Advice:
@@ -219,7 +241,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d1 (f d1))
    SEQ_SCAN(d2 f d1)
    NO_GATHER(f d1 d2)
-(18 rows)
+(22 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(d2 d1)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
index 0f9db692190..ce105856fda 100644
--- a/contrib/pg_plan_advice/expected/join_strategy.out
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -15,19 +15,21 @@ VACUUM ANALYZE join_fact;
 -- We expect a hash join by default.
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
-             QUERY PLAN             
-------------------------------------
+              QUERY PLAN               
+---------------------------------------
  Hash Join
    Hash Cond: (f.dim_id = d.id)
    ->  Seq Scan on join_fact f
+         Bloom Filter 1: keys=(dim_id)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on join_dim d
  Generated Plan Advice:
    JOIN_ORDER(f d)
    HASH_JOIN(d)
    SEQ_SCAN(f d)
    NO_GATHER(f d)
-(10 rows)
+(12 rows)
 
 -- Try forcing each join method in turn with join_dim as the inner table.
 -- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
@@ -37,12 +39,14 @@ BEGIN;
 SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
-             QUERY PLAN             
-------------------------------------
+              QUERY PLAN               
+---------------------------------------
  Hash Join
    Hash Cond: (f.dim_id = d.id)
    ->  Seq Scan on join_fact f
+         Bloom Filter 1: keys=(dim_id)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on join_dim d
  Supplied Plan Advice:
    HASH_JOIN(d) /* matched */
@@ -51,7 +55,7 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
    HASH_JOIN(d)
    SEQ_SCAN(f d)
    NO_GATHER(f d)
-(12 rows)
+(14 rows)
 
 SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -162,7 +166,9 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
  Hash Join
    Hash Cond: (d.id = f.dim_id)
    ->  Seq Scan on join_dim d
+         Bloom Filter 1: keys=(id)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on join_fact f
  Supplied Plan Advice:
    HASH_JOIN(f) /* matched */
@@ -171,7 +177,7 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
    HASH_JOIN(f)
    SEQ_SCAN(d f)
    NO_GATHER(f d)
-(12 rows)
+(14 rows)
 
 SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
index 2b3d0a82443..3b003a927ac 100644
--- a/contrib/pg_plan_advice/expected/partitionwise.out
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -60,7 +60,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_1.id = pt3_1.id)
                ->  Seq Scan on pt2a pt2_1
                      Filter: (val2 = 1)
+                     Bloom Filter 1: keys=(id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pt3a pt3_1
                            Filter: (val3 = 1)
          ->  Index Scan using pt1a_pkey on pt1a pt1_1
@@ -71,7 +73,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_2.id = pt3_2.id)
                ->  Seq Scan on pt2b pt2_2
                      Filter: (val2 = 1)
+                     Bloom Filter 2: keys=(id)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pt3b pt3_2
                            Filter: (val3 = 1)
          ->  Index Scan using pt1b_pkey on pt1b pt1_2
@@ -82,7 +86,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_3.id = pt3_3.id)
                ->  Seq Scan on pt2c pt2_3
                      Filter: (val2 = 1)
+                     Bloom Filter 3: keys=(id)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pt3c pt3_3
                            Filter: (val3 = 1)
          ->  Index Scan using pt1c_pkey on pt1c pt1_3
@@ -101,7 +107,7 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
    PARTITIONWISE((pt1 pt2 pt3))
    NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
     pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
-(47 rows)
+(53 rows)
 
 -- Suppress partitionwise join, or do it just partially.
 BEGIN;
@@ -169,21 +175,27 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt1_1.id = pt2_1.id)
                ->  Seq Scan on pt1a pt1_1
                      Filter: (val1 = 1)
+                     Bloom Filter 1: keys=(id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pt2a pt2_1
                            Filter: (val2 = 1)
          ->  Hash Join
                Hash Cond: (pt1_2.id = pt2_2.id)
                ->  Seq Scan on pt1b pt1_2
                      Filter: (val1 = 1)
+                     Bloom Filter 2: keys=(id)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pt2b pt2_2
                            Filter: (val2 = 1)
          ->  Hash Join
                Hash Cond: (pt1_3.id = pt2_3.id)
                ->  Seq Scan on pt1c pt1_3
                      Filter: (val1 = 1)
+                     Bloom Filter 3: keys=(id)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pt2c pt2_3
                            Filter: (val2 = 1)
    ->  Hash
@@ -209,7 +221,7 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
    PARTITIONWISE((pt1 pt2) pt3)
    NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
     pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
-(47 rows)
+(53 rows)
 
 COMMIT;
 -- Test conflicting advice.
@@ -227,7 +239,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_1.id = pt3_1.id)
                ->  Seq Scan on pt2a pt2_1
                      Filter: (val2 = 1)
+                     Bloom Filter 1: keys=(id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pt3a pt3_1
                            Filter: (val3 = 1)
          ->  Index Scan using pt1a_pkey on pt1a pt1_1
@@ -238,7 +252,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_2.id = pt3_2.id)
                ->  Seq Scan on pt2b pt2_2
                      Filter: (val2 = 1)
+                     Bloom Filter 2: keys=(id)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pt3b pt3_2
                            Filter: (val3 = 1)
          ->  Index Scan using pt1b_pkey on pt1b pt1_2
@@ -249,7 +265,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_3.id = pt3_3.id)
                ->  Seq Scan on pt2c pt2_3
                      Filter: (val2 = 1)
+                     Bloom Filter 3: keys=(id)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pt3c pt3_3
                            Filter: (val3 = 1)
          ->  Index Scan using pt1c_pkey on pt1c pt1_3
@@ -271,7 +289,7 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
    PARTITIONWISE((pt1 pt2 pt3))
    NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
     pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
-(51 rows)
+(57 rows)
 
 COMMIT;
 -- Can't force a partitionwise join with a mismatched table.
@@ -321,7 +339,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt3_1.id = pt2_1.id)
                ->  Seq Scan on pt3a pt3_1
                      Filter: (val3 = 1)
+                     Bloom Filter 1: keys=(id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pt2a pt2_1
                            Filter: (val2 = 1)
          ->  Index Scan using pt1a_pkey on pt1a pt1_1
@@ -332,7 +352,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_2.id = pt3_2.id)
                ->  Seq Scan on pt2b pt2_2
                      Filter: (val2 = 1)
+                     Bloom Filter 2: keys=(id)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pt3b pt3_2
                            Filter: (val3 = 1)
          ->  Index Scan using pt1b_pkey on pt1b pt1_2
@@ -343,7 +365,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_3.id = pt3_3.id)
                ->  Seq Scan on pt2c pt2_3
                      Filter: (val2 = 1)
+                     Bloom Filter 3: keys=(id)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pt3c pt3_3
                            Filter: (val3 = 1)
          ->  Index Scan using pt1c_pkey on pt1c pt1_3
@@ -364,7 +388,7 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
    PARTITIONWISE((pt1 pt2 pt3))
    NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
     pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
-(49 rows)
+(55 rows)
 
 SET LOCAL pg_plan_advice.advice = 'JOIN_ORDER(pt3/pt3a pt2/pt2a pt1/pt1a)';
 EXPLAIN (PLAN_ADVICE, COSTS OFF)
@@ -378,7 +402,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt3_1.id = pt2_1.id)
                ->  Seq Scan on pt3a pt3_1
                      Filter: (val3 = 1)
+                     Bloom Filter 1: keys=(id)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pt2a pt2_1
                            Filter: (val2 = 1)
          ->  Index Scan using pt1a_pkey on pt1a pt1_1
@@ -389,7 +415,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_2.id = pt3_2.id)
                ->  Seq Scan on pt2b pt2_2
                      Filter: (val2 = 1)
+                     Bloom Filter 2: keys=(id)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pt3b pt3_2
                            Filter: (val3 = 1)
          ->  Index Scan using pt1b_pkey on pt1b pt1_2
@@ -400,7 +428,9 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
                Hash Cond: (pt2_3.id = pt3_3.id)
                ->  Seq Scan on pt2c pt2_3
                      Filter: (val2 = 1)
+                     Bloom Filter 3: keys=(id)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pt3c pt3_3
                            Filter: (val3 = 1)
          ->  Index Scan using pt1c_pkey on pt1c pt1_3
@@ -421,6 +451,6 @@ SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
    PARTITIONWISE((pt1 pt2 pt3))
    NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
     pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
-(49 rows)
+(55 rows)
 
 COMMIT;
diff --git a/contrib/pg_plan_advice/expected/semijoin.out b/contrib/pg_plan_advice/expected/semijoin.out
index db6b069ec8e..f60778d0d38 100644
--- a/contrib/pg_plan_advice/expected/semijoin.out
+++ b/contrib/pg_plan_advice/expected/semijoin.out
@@ -75,7 +75,9 @@ SELECT * FROM sj_wide
  Hash Semi Join
    Hash Cond: ((sj_wide.id = "*VALUES*".column1) AND (sj_wide.val1 = "*VALUES*".column2))
    ->  Seq Scan on sj_wide
+         Bloom Filter 1: keys=(id, val1)
    ->  Hash
+         Bloom Filter 1
          ->  Values Scan on "*VALUES*"
  Supplied Plan Advice:
    SEMIJOIN_NON_UNIQUE("*VALUES*") /* matched */
@@ -85,7 +87,7 @@ SELECT * FROM sj_wide
    SEQ_SCAN(sj_wide)
    SEMIJOIN_NON_UNIQUE("*VALUES*")
    NO_GATHER(sj_wide "*VALUES*")
-(13 rows)
+(15 rows)
 
 COMMIT;
 -- Because this table is narrower than the previous one, a sequential scan
@@ -100,7 +102,9 @@ SELECT * FROM sj_narrow
  Hash Semi Join
    Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
    ->  Seq Scan on sj_narrow
+         Bloom Filter 1: keys=(id, val1)
    ->  Hash
+         Bloom Filter 1
          ->  Values Scan on "*VALUES*"
  Generated Plan Advice:
    JOIN_ORDER(sj_narrow "*VALUES*")
@@ -108,7 +112,7 @@ SELECT * FROM sj_narrow
    SEQ_SCAN(sj_narrow)
    SEMIJOIN_NON_UNIQUE("*VALUES*")
    NO_GATHER(sj_narrow "*VALUES*")
-(11 rows)
+(13 rows)
 
 -- Here, we expect advising a unique semijoin to swith to the same plan that
 -- we got with sj_wide, and advising a non-unique semijoin should not change
@@ -123,7 +127,9 @@ SELECT * FROM sj_narrow
  Hash Join
    Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
    ->  Seq Scan on sj_narrow
+         Bloom Filter 1: keys=(id, val1)
    ->  Hash
+         Bloom Filter 1
          ->  HashAggregate
                Group Key: "*VALUES*".column1, "*VALUES*".column2
                ->  Values Scan on "*VALUES*"
@@ -135,7 +141,7 @@ SELECT * FROM sj_narrow
    SEQ_SCAN(sj_narrow)
    SEMIJOIN_UNIQUE("*VALUES*")
    NO_GATHER(sj_narrow "*VALUES*")
-(15 rows)
+(17 rows)
 
 SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique("*VALUES*")';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -146,7 +152,9 @@ SELECT * FROM sj_narrow
  Hash Semi Join
    Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
    ->  Seq Scan on sj_narrow
+         Bloom Filter 1: keys=(id, val1)
    ->  Hash
+         Bloom Filter 1
          ->  Values Scan on "*VALUES*"
  Supplied Plan Advice:
    SEMIJOIN_NON_UNIQUE("*VALUES*") /* matched */
@@ -156,7 +164,7 @@ SELECT * FROM sj_narrow
    SEQ_SCAN(sj_narrow)
    SEMIJOIN_NON_UNIQUE("*VALUES*")
    NO_GATHER(sj_narrow "*VALUES*")
-(13 rows)
+(15 rows)
 
 COMMIT;
 -- In the above example, we made the outer side of the join unique, but here,
@@ -261,7 +269,9 @@ SELECT * FROM generate_series(1,1000) g
  Hash Right Semi Join
    Hash Cond: (sj_narrow.val1 = g.g)
    ->  Seq Scan on sj_narrow
+         Bloom Filter 1: keys=(val1)
    ->  Hash
+         Bloom Filter 1
          ->  Function Scan on generate_series g
  Supplied Plan Advice:
    SEMIJOIN_NON_UNIQUE(sj_narrow) /* matched */
@@ -272,7 +282,7 @@ SELECT * FROM generate_series(1,1000) g
    SEQ_SCAN(sj_narrow)
    SEMIJOIN_NON_UNIQUE(sj_narrow)
    NO_GATHER(g sj_narrow)
-(14 rows)
+(16 rows)
 
 COMMIT;
 -- However, mentioning the wrong side of the join should result in an advice
@@ -407,11 +417,13 @@ SELECT 1 FROM generate_series(1, 1000) g WHERE EXISTS
    ->  Unique
          ->  Nested Loop
                ->  Index Only Scan using sj_narrow_pkey on sj_narrow t2
+                     Bloom Filter 1: keys=(id)
                ->  Materialize
                      ->  Nested Loop Left Join
                            ->  Result
                            ->  Seq Scan on sj_narrow
    ->  Hash
+         Bloom Filter 1
          ->  Function Scan on generate_series g
  Generated Plan Advice:
    JOIN_ORDER(t2 ("*RESULT*" sj_narrow) g)
@@ -422,5 +434,5 @@ SELECT 1 FROM generate_series(1, 1000) g WHERE EXISTS
    INDEX_ONLY_SCAN(t2 public.sj_narrow_pkey)
    SEMIJOIN_UNIQUE((t2 sj_narrow "*RESULT*"))
    NO_GATHER(g t2 sj_narrow "*RESULT*")
-(20 rows)
+(22 rows)
 
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
index 788da854aa7..d62afaa6651 100644
--- a/contrib/pg_stash_advice/expected/pg_stash_advice.out
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -57,20 +57,24 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on aa_dim1 d1
                Filter: (val1 = 1)
-(11 rows)
+(15 rows)
 
 -- Force an index scan on dim1
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -91,15 +95,19 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
                Filter: (val1 = 1)
  Supplied Plan Advice:
    INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
-(13 rows)
+(17 rows)
 
 -- Force an alternative join order
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -113,22 +121,26 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim2_id = d2.id)
    ->  Hash Join
          Hash Cond: (f.dim1_id = d1.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim1_id)
+               Bloom Filter 2: keys=(dim2_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim1 d1
                      Filter: (val1 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on aa_dim2 d2
                Filter: (val2 = 1)
  Supplied Plan Advice:
    JOIN_ORDER(f d1 d2) /* matched */
-(13 rows)
+(17 rows)
 
 -- Force an alternative join strategy
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -148,7 +160,9 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
@@ -156,7 +170,7 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
          Filter: (val1 = 1)
  Supplied Plan Advice:
    NESTED_LOOP_PLAIN(d1) /* matched */
-(12 rows)
+(14 rows)
 
 -- Add a useless extra entry to our test stash. Shouldn't change the result
 -- from the previous test.
@@ -178,7 +192,9 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
@@ -186,7 +202,7 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
          Filter: (val1 = 1)
  Supplied Plan Advice:
    NESTED_LOOP_PLAIN(d1) /* matched */
-(12 rows)
+(14 rows)
 
 -- Try an empty stash to be sure it does nothing
 SELECT pg_create_advice_stash('regress_empty_stash');
@@ -200,20 +216,24 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on aa_dim1 d1
                Filter: (val1 = 1)
-(11 rows)
+(15 rows)
 
 -- Test that we can list each stash individually and all of them together,
 -- but not a nonexistent stash.
@@ -263,20 +283,24 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                QUERY PLAN                
-------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
+               Bloom Filter 1: keys=(dim2_id)
+               Bloom Filter 2: keys=(dim1_id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on aa_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on aa_dim1 d1
                Filter: (val1 = 1)
-(11 rows)
+(15 rows)
 
 SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
      stash_name      | num_entries 
diff --git a/contrib/pg_stat_statements/expected/level_tracking.out b/contrib/pg_stat_statements/expected/level_tracking.out
index 832d65e97ca..db84cc6af01 100644
--- a/contrib/pg_stat_statements/expected/level_tracking.out
+++ b/contrib/pg_stat_statements/expected/level_tracking.out
@@ -189,9 +189,11 @@ EXPLAIN (COSTS OFF) MERGE INTO stats_track_tab
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF) SELECT 1 UNION SELECT 2;
         QUERY PLAN        
@@ -315,9 +317,11 @@ EXPLAIN (COSTS OFF) MERGE INTO stats_track_tab
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF) SELECT 1 UNION SELECT 2;
         QUERY PLAN        
@@ -536,9 +540,11 @@ EXPLAIN (COSTS OFF) MERGE INTO stats_track_tab
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
  QUERY PLAN 
 ------------
@@ -664,9 +670,11 @@ EXPLAIN (COSTS OFF) MERGE INTO stats_track_tab USING (SELECT id FROM generate_se
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
  QUERY PLAN 
 ------------
@@ -772,9 +780,11 @@ EXPLAIN (COSTS OFF) WITH a AS (SELECT 4) MERGE INTO stats_track_tab
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF) WITH a AS (select 4) SELECT 1 UNION SELECT 2;
         QUERY PLAN        
@@ -866,9 +876,11 @@ EXPLAIN (COSTS OFF) WITH a AS (SELECT 4) MERGE INTO stats_track_tab
    ->  Hash Right Join
          Hash Cond: (stats_track_tab.x = id.id)
          ->  Seq Scan on stats_track_tab
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series id
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF) WITH a AS (select 4) SELECT 1 UNION SELECT 2;
         QUERY PLAN        
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index e90289e4ab1..d24b3a7c375 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -11723,12 +11723,14 @@ INSERT INTO join_tbl SELECT * FROM async_pt t1, async_pt t2 WHERE t1.a = t2.a AN
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.b = t1_3.b))
                ->  Seq Scan on public.async_p3 t2_3
                      Output: t2_3.a, t2_3.b, t2_3.c
+                     Bloom Filter 1: keys=(t2_3.a, t2_3.b)
                ->  Hash
                      Output: t1_3.a, t1_3.b, t1_3.c
+                     Bloom Filter 1
                      ->  Seq Scan on public.async_p3 t1_3
                            Output: t1_3.a, t1_3.b, t1_3.c
                            Filter: ((t1_3.b % 100) = 0)
-(20 rows)
+(22 rows)
 
 INSERT INTO join_tbl SELECT * FROM async_pt t1, async_pt t2 WHERE t1.a = t2.a AND t1.b = t2.b AND t1.b % 100 = 0;
 SELECT * FROM join_tbl ORDER BY a1;
@@ -11786,12 +11788,14 @@ INSERT INTO join_tbl SELECT t1.a, t1.b, 'AAA' || t1.c, t2.a, t2.b, 'AAA' || t2.c
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.b = t1_3.b))
                ->  Seq Scan on public.async_p3 t2_3
                      Output: t2_3.a, t2_3.b, t2_3.c
+                     Bloom Filter 1: keys=(t2_3.a, t2_3.b)
                ->  Hash
                      Output: t1_3.a, t1_3.b, t1_3.c
+                     Bloom Filter 1
                      ->  Seq Scan on public.async_p3 t1_3
                            Output: t1_3.a, t1_3.b, t1_3.c
                            Filter: ((t1_3.b % 100) = 0)
-(20 rows)
+(22 rows)
 
 INSERT INTO join_tbl SELECT t1.a, t1.b, 'AAA' || t1.c, t2.a, t2.b, 'AAA' || t2.c FROM async_pt t1, async_pt t2 WHERE t1.a = t2.a AND t1.b = t2.b AND t1.b % 100 = 0;
 SELECT * FROM join_tbl ORDER BY a1;
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 112c17b0d64..5ae6de5b6fa 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -25,6 +25,7 @@
 #include "commands/prepare.h"
 #include "foreign/fdwapi.h"
 #include "jit/jit.h"
+#include "lib/bloomfilter.h"
 #include "libpq/pqformat.h"
 #include "libpq/protocol.h"
 #include "nodes/extensible.h"
@@ -92,6 +93,8 @@ static void show_scan_qual(List *qual, const char *qlabel,
 static void show_upper_qual(List *qual, const char *qlabel,
 							PlanState *planstate, List *ancestors,
 							ExplainState *es);
+static void show_bloom_filter_info(PlanState *planstate, List *ancestors,
+								   ExplainState *es);
 static void show_sort_keys(SortState *sortstate, List *ancestors,
 						   ExplainState *es);
 static void show_incremental_sort_keys(IncrementalSortState *incrsortstate,
@@ -1978,6 +1981,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
+			show_bloom_filter_info(planstate, ancestors, es);
 			show_indexsearches_info(planstate, es);
 			break;
 		case T_IndexOnlyScan:
@@ -1995,6 +1999,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (es->analyze)
 				ExplainPropertyFloat("Heap Fetches", NULL,
 									 planstate->instrument->ntuples2, 0, es);
+			show_bloom_filter_info(planstate, ancestors, es);
 			show_indexsearches_info(planstate, es);
 			break;
 		case T_BitmapIndexScan:
@@ -2012,6 +2017,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
+			show_bloom_filter_info(planstate, ancestors, es);
 			show_tidbitmap_info((BitmapHeapScanState *) planstate, es);
 			show_scan_io_usage((ScanState *) planstate, es);
 			break;
@@ -2030,6 +2036,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
+			show_bloom_filter_info(planstate, ancestors, es);
 			if (IsA(plan, CteScan))
 				show_ctescan_info(castNode(CteScanState, planstate), es);
 			show_scan_io_usage((ScanState *) planstate, es);
@@ -2132,6 +2139,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				if (plan->qual)
 					show_instrumentation_count("Rows Removed by Filter", 1,
 											   planstate, es);
+				show_bloom_filter_info(planstate, ancestors, es);
 			}
 			break;
 		case T_TidRangeScan:
@@ -2149,6 +2157,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				if (plan->qual)
 					show_instrumentation_count("Rows Removed by Filter", 1,
 											   planstate, es);
+				show_bloom_filter_info(planstate, ancestors, es);
 				show_scan_io_usage((ScanState *) planstate, es);
 			}
 			break;
@@ -2578,6 +2587,120 @@ show_upper_qual(List *qual, const char *qlabel,
 	show_qual(qual, qlabel, planstate, ancestors, useprefix, es);
 }
 
+/*
+ * show_bloom_filter_info
+ *		Show info about every bloom filter pushed down to a scan node.
+ *
+ * In TEXT format each filter is rendered on a single line, e.g.
+ *
+ *   Bloom Filter N: (a, b) producer=3 checked=99999 rejected=99990
+ *
+ * The checked/rejected fields are omitted outside of ANALYZE). In
+ * structured formats we emit a group per filter with the same fields
+ * broken out as properties.
+ *
+ * Called from the per-recipient cases in ExplainNode; the recipient is
+ * expected to be a scan node in the current PoC, but nothing here
+ * depends on that.
+ */
+static void
+show_bloom_filter_info(PlanState *planstate, List *ancestors,
+					   ExplainState *es)
+{
+	Plan	   *plan = planstate->plan;
+	ListCell   *lc1,
+			   *lc2;
+	List	   *deparse_cxt = NIL;
+	bool		useprefix = false;
+
+	if (plan->bloom_filters == NIL)
+		return;
+
+	deparse_cxt = set_deparse_context_plan(es->deparse_cxt,
+										   plan, ancestors);
+	useprefix = (IsA(plan, SubqueryScan) || es->verbose);
+
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+		ExplainOpenGroup("Bloom Filters", "Bloom Filters", false, es);
+
+	/* print info about all the bloom filters */
+	forboth(lc1, plan->bloom_filters,
+			lc2, planstate->bloom_filters)
+	{
+		BloomFilter *bf = lfirst_node(BloomFilter, lc1);
+		BloomFilterState *bfs = (BloomFilterState *) lfirst(lc2);
+		ListCell   *lc;
+		StringInfoData keys;
+		bool		first = true;
+
+		initStringInfo(&keys);
+
+		/* deparse the filter expressions */
+		appendStringInfoChar(&keys, '(');
+		foreach(lc, bf->filter_exprs)
+		{
+			char 	   *key;
+			Node	   *expr = (Node *) lfirst(lc);
+
+			if (!first)
+				appendStringInfoString(&keys, ", ");
+			key = deparse_expression(expr, deparse_cxt,
+									 useprefix, false);
+			appendStringInfoString(&keys, key);
+			first = false;
+		}
+		appendStringInfoChar(&keys, ')');
+
+		if (es->format == EXPLAIN_FORMAT_TEXT)
+		{
+			StringInfoData	buf;
+
+			initStringInfo(&buf);
+
+			/* show the filter ID only when there are more filters */
+			appendStringInfo(&buf, "Bloom Filter %d: keys=%s",
+							 bf->producer_id, keys.data);
+
+			/* include the counts only during ANALYZE */
+			if (es->analyze && bfs != NULL)
+			{
+				/* rejected fraction */
+				double frac = 100.0 * bfs->rejected / Max(1, bfs->checked);
+
+				appendStringInfo(&buf,
+							 " checked=" UINT64_FORMAT
+							 " rejected=" UINT64_FORMAT
+							 " (%.1f%%)",
+							 bfs->checked, bfs->rejected, frac);
+			}
+
+			ExplainIndentText(es);
+			appendStringInfoString(es->str, buf.data);
+			appendStringInfoChar(es->str, '\n');
+			pfree(buf.data);
+		}
+		else	/* non-text format */
+		{
+			ExplainOpenGroup("Bloom Filter", NULL, true, es);
+			ExplainPropertyText("Keys", keys.data, es);
+			ExplainPropertyInteger("ID", NULL, bf->producer_id, es);
+			if (es->analyze && bfs != NULL)
+			{
+				ExplainPropertyFloat("Checked", NULL,
+									 (double) bfs->checked, 0, es);
+				ExplainPropertyFloat("Rejected", NULL,
+									 (double) bfs->rejected, 0, es);
+			}
+			ExplainCloseGroup("Bloom Filter", NULL, true, es);
+		}
+
+		pfree(keys.data);
+	}
+
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+		ExplainCloseGroup("Bloom Filters", "Bloom Filters", false, es);
+}
+
 /*
  * Show the sort keys for a Sort node.
  */
@@ -3474,6 +3597,72 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 							 spacePeakKb);
 		}
 	}
+
+	/*
+	 * Show infromation about the bloom filter produced by this Hash node
+	 * (if any). For plain EXPLAIN, the filter is not initialized / built,
+	 * but we still show available plan-time metadata (at least the ID).
+	 */
+	if (hashstate->bloom_filter_id > 0)
+	{
+		int			producer_id = hashstate->bloom_filter_id;
+		uint64		nbits = 0;
+		int			nhashfns = 0;
+		uint64		bytes = 0;
+
+		if (hashstate->bloom_filter != NULL)
+		{
+			nbits = bloom_total_bits(hashstate->bloom_filter);
+			nhashfns = bloom_hash_funcs(hashstate->bloom_filter);
+			bytes = nbits / 8;
+		}
+
+		if (es->format == EXPLAIN_FORMAT_TEXT)
+		{
+			ExplainIndentText(es);
+			if (hashstate->bloom_filter != NULL)
+				appendStringInfo(es->str,
+								 "Bloom Filter %d: bits=" UINT64_FORMAT
+								 " hashes=%d memory=" UINT64_FORMAT "kB"
+								 " checked=" UINT64_FORMAT " rejected=" UINT64_FORMAT "\n",
+								 producer_id,
+								 nbits,
+								 nhashfns,
+								 BYTES_TO_KILOBYTES(bytes),
+								 hashstate->bloomFilterChecked,
+								 hashstate->bloomFilterRejected);
+			else if (es->analyze)
+				appendStringInfo(es->str,
+								 "Bloom Filter %d: (not initialized)\n",
+								 producer_id);
+			else
+				appendStringInfo(es->str,
+								 "Bloom Filter %d\n",
+								 producer_id);
+		}
+		else
+		{
+			/* there can be just one bloom filter per fproducer (for now) */
+			ExplainOpenGroup("Bloom Filter", "Bloom Filter", true, es);
+
+			ExplainPropertyInteger("Producer", NULL, producer_id, es);
+			ExplainPropertyBool("Initialized",
+								(hashstate->bloom_filter != NULL), es);
+
+			if (hashstate->bloom_filter != NULL)
+			{
+				ExplainPropertyUInteger("Bits", NULL, nbits, es);
+				ExplainPropertyInteger("Hash Functions", NULL, nhashfns, es);
+				ExplainPropertyUInteger("Memory Usage", "kB",
+										BYTES_TO_KILOBYTES(bytes), es);
+				ExplainPropertyFloat("Checked", NULL,
+									 (double) hashstate->bloomFilterChecked, 0, es);
+				ExplainPropertyFloat("Rejected", NULL,
+									 (double) hashstate->bloomFilterRejected, 0, es);
+			}
+			ExplainCloseGroup("Bloom Filter", "Bloom Filter", true, es);
+		}
+	}
 }
 
 /*
diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..3c32a61dddd 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -159,6 +159,8 @@ CreateExecutorState(void)
 
 	estate->es_auxmodifytables = NIL;
 
+	estate->es_bloom_producers = NIL;
+
 	estate->es_per_tuple_exprcontext = NULL;
 
 	estate->es_sourceText = NULL;
diff --git a/src/backend/executor/nodeBitmapHeapscan.c b/src/backend/executor/nodeBitmapHeapscan.c
index 83d6478bc2b..e71a47b6205 100644
--- a/src/backend/executor/nodeBitmapHeapscan.c
+++ b/src/backend/executor/nodeBitmapHeapscan.c
@@ -456,6 +456,9 @@ ExecInitBitmapHeapScan(BitmapHeapScan *node, EState *estate, int eflags)
 	scanstate->bitmapqualorig =
 		ExecInitQual(node->bitmapqualorig, (PlanState *) scanstate);
 
+	ExecInitBloomFilters((PlanState *) scanstate,
+						 scanstate->ss.ss_ScanTupleSlot);
+
 	scanstate->ss.ss_currentRelation = currentRelation;
 
 	/*
diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 8825bb6fa23..37224324bce 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -35,6 +35,7 @@
 #include "executor/instrument.h"
 #include "executor/nodeHash.h"
 #include "executor/nodeHashjoin.h"
+#include "lib/bloomfilter.h"
 #include "miscadmin.h"
 #include "port/pg_bitutils.h"
 #include "utils/lsyscache.h"
@@ -184,6 +185,18 @@ MultiExecPrivateHash(HashState *node)
 			uint32		hashvalue = DatumGetUInt32(hashdatum);
 			int			bucketNumber;
 
+			/*
+			 * Add the tuple to the pushed-down bloom filter (if any). Do
+			 * it here (rather than in ExecHashTableInsert) so that each
+			 * tuple is added exactly once, even if it later gets shuffled
+			 * between batches by ExecHashIncreaseNumBatches. The filter
+			 * would still produce the same matches, but it costs CPU.
+			 */
+			if (node->bloom_filter != NULL)
+				bloom_add_element(node->bloom_filter,
+								  (unsigned char *) &hashvalue,
+								  sizeof(hashvalue));
+
 			bucketNumber = ExecHashGetSkewBucket(hashtable, hashvalue);
 			if (bucketNumber != INVALID_SKEW_BUCKET_NO)
 			{
@@ -665,6 +678,53 @@ ExecHashTableCreate(HashState *state)
 		MemoryContextSwitchTo(oldcxt);
 	}
 
+	/*
+	 * If we managed to push down a bloom filter to the outer side of the
+	 * hash join, allocate it with the hash table.
+	 *
+	 * Whether we build the filter is decided by try_push_bloom_filter at
+	 * plan time. If there's no recipient node, or when the GUC is set to
+	 * off, state->want_bloom_filter is false.
+	 *
+	 * XXX We don't do this for parallel hash joins, to keep the PoC simple.
+	 * The filter would need to live in shared memory, and the workers would
+	 * need to coordinate to build it. But it's doable.
+	 *
+	 * The filter lives in the HashState, in the hashCtx memory context.
+	 * That means it gets destroyed along with the hashtable, and it follows
+	 * the same lifecycle (during rescans, etc.).
+	 *
+	 * The size of the filter is bounded by both the estimated inner row
+	 * count and a fixed fraction of work_mem.  bloom_create() will round
+	 * down to the next power-of-two bitset and enforces a 1MB minimum.
+	 *
+	 * XXX This may need more thought. If we limit bloom_work_mem too much,
+	 * the false positive rate will get too bad, and we won't filter enough
+	 * tuples for the filter to pay for itself. The adaptive behavior will
+	 * eventually skip the filter, but we could just not build it at all?
+	 * Or do we want to take the chance, sometimes?
+	 */
+	if (state->want_bloom_filter)
+	{
+		MemoryContext oldctx;
+		int			bloom_work_mem;
+
+		/* only serial hashjoins for now, init only once */
+		Assert(hashtable->parallel_state == NULL);
+		Assert(state->bloom_filter == NULL);
+
+		state->bloomFilterChecked = 0;
+		state->bloomFilterRejected = 0;
+
+		/* Cap bloom filter at ~1/8 of work_mem, but not less than 1MB. */
+		bloom_work_mem = Max(1024, work_mem / 8);
+
+		oldctx = MemoryContextSwitchTo(hashtable->hashCxt);
+		state->bloom_filter = bloom_create((int64) Max(rows, 1.0),
+										   bloom_work_mem, 0);
+		MemoryContextSwitchTo(oldctx);
+	}
+
 	return hashtable;
 }
 
diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 0b365d5b475..8fa7af4cfef 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -169,7 +169,9 @@
 #include "executor/instrument.h"
 #include "executor/nodeHash.h"
 #include "executor/nodeHashjoin.h"
+#include "lib/bloomfilter.h"
 #include "miscadmin.h"
+#include "port/pg_bitutils.h"
 #include "utils/lsyscache.h"
 #include "utils/sharedtuplestore.h"
 #include "utils/tuplestore.h"
@@ -834,6 +836,7 @@ HashJoinState *
 ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 {
 	HashJoinState *hjstate;
+	HashState  *hashState;
 	Plan	   *outerNode;
 	Hash	   *hashNode;
 	TupleDesc	outerDesc,
@@ -875,11 +878,37 @@ ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 	outerNode = outerPlan(node);
 	hashNode = (Hash *) innerPlan(node);
 
+	/*
+	 * Register ourselves as a bloom-filter producer in the EState before
+	 * recursing into the outer subtree, so the scan node (we pushed the
+	 * filter to) can find us. We do this only if we actually managed to
+	 * push down the filter to a scan node.
+	 */
+	if (node->bloom_consumer_count > 0)
+		ExecRegisterBloomFilterProducer(hjstate);
+
 	outerPlanState(hjstate) = ExecInitNode(outerNode, estate, eflags);
 	outerDesc = ExecGetResultType(outerPlanState(hjstate));
 	innerPlanState(hjstate) = ExecInitNode((Plan *) hashNode, estate, eflags);
 	innerDesc = ExecGetResultType(innerPlanState(hjstate));
 
+	/*
+	 * Tell the Hash child to actually build the bloom filter, and the
+	 * ID assigned to the filter.
+	 *
+	 * XXX Seems a bit ugly to manipulate the inner plan state like this.
+	 * Surely there's a better way. OTOH the two nodes are pretty tightly
+	 * coupled already, so maybe it's fine.
+	 *
+	 * XXX Also, this assumes the hash table is not built by ExecInitNode(),
+	 * which is true for now. But maybe we will relax that in the future
+	 * (e.g. so that the scan can push the filter to storage / to remote FDW
+	 * node / ...)?
+	 */
+	hashState = castNode(HashState, innerPlanState(hjstate));
+	hashState->want_bloom_filter = (node->bloom_consumer_count > 0);
+	hashState->bloom_filter_id = node->bloom_filter_id;
+
 	/*
 	 * Initialize result slot, type and projection.
 	 */
@@ -1080,11 +1109,15 @@ ExecEndHashJoin(HashJoinState *node)
 
 	/*
 	 * Free hash table
+	 *
+	 * Clear the bloom_filter pointer. It lives in hashCxt, so it gets freed by
+	 * the ExecHashTableDestroy call.
 	 */
 	if (node->hj_HashTable)
 	{
 		ExecHashTableDestroy(node->hj_HashTable);
 		node->hj_HashTable = NULL;
+		hashNode->bloom_filter = NULL;
 	}
 
 	/*
@@ -1737,6 +1770,12 @@ ExecReScanHashJoin(HashJoinState *node)
 			node->hj_HashTable = NULL;
 			node->hj_JoinState = HJ_BUILD_HASHTABLE;
 
+			/*
+			 * Clear the bloom_filter pointer. It lives in hashCxt, so it gets
+			 * freed by the ExecHashTableDestroy call.
+			 */
+			hashNode->bloom_filter = NULL;
+
 			/*
 			 * if chgParam of subnode is not null then plan will be re-scanned
 			 * by first ExecProcNode.
@@ -1975,3 +2014,419 @@ ExecHashJoinInitializeWorker(HashJoinState *state,
 
 	ExecSetExecProcNode(&state->js.ps, ExecParallelHashJoin);
 }
+
+/*
+ * BLOOM FILTER PUSHDOWN
+ *
+ * The pushdown decision is done in try_push_bloom_filter, when constructing
+ * the plan from the selected paths (see createplan.c). It decides which scan
+ * node should receive the bloom filter (if any), and what expressions it
+ * should use to calculate the hash value.
+ *
+ * Then at execution time:
+ *
+ *   - ExecInitHashJoin registers itself in EState.es_bloom_producers
+ *     before recursing into child plans, so by the time a recipient's
+ *     ExecInit runs, the producer is already discoverable by plan_node_id.
+ *     This registration only happens when there's at least one consumer.
+ *     It also sets want_bloom_filter for the Hash node.
+ *
+ *   - ExecHashTableCreate (in nodeHash.c) builds the actual bloom_filter
+ *     when HashState.want_bloom_filter is set (so no work happens when
+ *     nobody will probe).
+ *
+ *   - Nodes with non-NIL plan->bloom_filters (and supporting bloom
+ *     filters) call ExecInitBloomFilters() during its own ExecInit,
+ *     which looks up the producer node (in the EState), compiles
+ *     ExprStates for the hash expressions, etc. The filter state
+ *     (BloomFilterState) gets added to ps->bloom_filters (a node may
+ *     have multiple bloom filters from different hash joins).
+ *
+ *   - The per-tuple loop of the scan node calls ExecBloomFilters() (much
+ *     like ExecQual) to test the tuple against every attached filter,
+ *     dropping it on the first filter that excludes it. For scan nodes
+ *     this call happens in ExecScanExtended.
+ *
+ * The scan nodes reach the bloom filter via the HashJoinState pointer
+ * added to EState.es_bloom_producers, so that the rescans etc. (filter
+ * freed + recreated when the hash table is destroyed and rebuilt) are
+ * transparent to the consumer. The bloom filter gets reallocated after
+ * a rescan, so the pointer to it may change.
+ *
+ * XXX It's possible the bloom filter gets pushed down to a node that
+ * fails to initialize/use it. It'll be added to the bloom_filters list,
+ * but if the node does not call ExecInitBloomFilters, the filter will
+ * be unused.
+ * ----------------------------------------------------------------
+ */
+
+/*
+ * Lookup the HashJoinState for a producer by plan_node_id in the
+ * EState's es_bloom_producers list.  Returns NULL if no matching
+ * producer has registered yet (which can happen for filters attached to
+ * recipients in trees where the producer hasn't ExecInit'd yet -- in
+ * normal execution we always register first).
+ */
+static HashJoinState *
+LookupBloomFilterProducer(EState *estate, int bloom_filter_id)
+{
+	ListCell   *lc;
+
+	foreach(lc, estate->es_bloom_producers)
+	{
+		HashJoinState *hjstate = (HashJoinState *) lfirst(lc);
+		HashJoin   *plan = (HashJoin *) hjstate->js.ps.plan;
+
+		if (plan->bloom_filter_id == bloom_filter_id)
+			return hjstate;
+	}
+	return NULL;
+}
+
+/*
+ * ExecBloomFilterHash
+ *		Calculate the hash value for a tuple in the recipient node.
+ *
+ * Uses the per-key ExprStates initialized by ExecInitBloomFilters. Mirrors the
+ * scheme used by the Hash node, so that it matches what was inserted into the
+ * hashtable (and filter).
+ *
+ * Returns false if a strict key is NULL: such a tuple can't match anything in
+ * the bloom filter, but we still must let it pass through to the upstream join
+ * so the join (rather than us) decides what to do with it (e.g. emit
+ * NULL-extended for an outer join).
+ *
+ * XXX I'm not sure about this strict/NULL business.
+ */
+static inline bool
+ExecBloomFilterHash(BloomFilterState *bfs, ExprContext *econtext,
+					uint32 *hashvalue)
+{
+	bool	isnull;
+	uint32	hash;
+
+	hash = DatumGetUInt32(ExecEvalExpr(bfs->keys, econtext, &isnull));
+
+	if (isnull)
+		return 0;	/* XXX correct? do we care about NULL values?*/
+
+	*hashvalue = hash;
+	return true;
+}
+
+/*
+ * ADAPTIVE BEHAVIOR
+ *
+ * If the bloom filter lets through most (or all) tuples, it becomes somewhat
+ * useless - we're just wasting CPU cycles, getting nothing in return. We could
+ * simply stop using such filter. But we've already paid quite a bit to build
+ * it, and maybe the data set is not uniform and we'll get into a part where
+ * fewer tuples pass.
+ *
+ * So we're evaluating the match rate for windows of 1000 probes. If more than
+ * 90% match, we start sampling 1% of the probes (i.e. 99% it treated as a
+ * match without looking at the filter). And if the match rate drops below 80%,
+ * we stop the sampling and all probes go to the filter.
+ *
+ * XXX These are empirical values, picked based on experiments. "Perfect"
+ * values depend on hardware, number of keys, data types, ... and maybe even
+ * on how many hash joins / pushed-down filters there are, and how deep (the
+ * deeper the bigger the benefit).
+ *
+ * XXX Maybe we should sample more probes, or maybe the window should be a bit
+ * smaller? With 1% and 1000 probes per window, it'll take 100k probes to
+ * enable the filter again. That seems like a lot.
+ *
+ * XXX We should probably track the number of times we "disabled" the filter,
+ * and what fraction of entries were "let through" during sampling periods.
+ *
+ * XXX There's an intentional gap between low/high thresholds, to add a bit
+ * of hysteresis into the behavior, so it does not flap all the time.
+ */
+#define BLOOM_ADAPTIVE_WINDOW_SIZE			1000
+#define BLOOM_ADAPTIVE_HIGH_MATCH_PERCENT	90
+#define BLOOM_ADAPTIVE_LOW_MATCH_PERCENT	80
+#define BLOOM_ADAPTIVE_SAMPLE_RATE			100
+
+/*
+ * ExecBloomFilterShouldProbe
+ *		Decide if the next tuple should probe the bloom filter.
+ *
+ * Returns true if the next value should actually probe the bloom filter
+ * We sample 1/100 (1/BLOOM_ADAPTIVE_SAMPLE_RATE) probes, i.e. 1%.
+ */
+static inline bool
+ExecBloomFilterShouldProbe(BloomFilterState *bfs)
+{
+	if (!bfs->adaptiveSampling)
+		return true;
+
+	bfs->adaptiveSampleCounter++;
+
+	if (bfs->adaptiveSampleCounter >= BLOOM_ADAPTIVE_SAMPLE_RATE)
+	{
+		bfs->adaptiveSampleCounter = 0;
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * ExecBloomFilterUpdateAdaptiveState
+ *		Update the adaptive state for sampling the probes.
+ *
+ * Adjust the adaptive behavior every 1000 probes. If too many probes match,
+ * stop using the filter (and just sample 1% of probes instead). If we were
+ * sampling, and the fraction of matches drops enough, stop the sampling.
+ */
+static inline void
+ExecBloomFilterUpdateAdaptiveState(BloomFilterState *bfs, bool match)
+{
+	bfs->adaptiveWindowProbes++;
+	if (match)
+		bfs->adaptiveWindowMatches++;
+
+	/* have we done enough probes in this window? */
+	if (bfs->adaptiveWindowProbes >= BLOOM_ADAPTIVE_WINDOW_SIZE)
+	{
+		uint64		match_percent;
+
+		/* fraction of matches */
+		match_percent = (bfs->adaptiveWindowMatches * 100) /
+			bfs->adaptiveWindowProbes;
+
+		if (!bfs->adaptiveSampling &&
+			match_percent > BLOOM_ADAPTIVE_HIGH_MATCH_PERCENT)
+		{
+			/* Too many matches - start sampling. */
+			bfs->adaptiveSampling = true;
+			bfs->adaptiveSampleCounter = 0;
+		}
+		else if (bfs->adaptiveSampling &&
+				 match_percent < BLOOM_ADAPTIVE_LOW_MATCH_PERCENT)
+		{
+			/* Stop sampling if the match fraction got low enough. */
+			bfs->adaptiveSampling = false;
+			bfs->adaptiveSampleCounter = 0;
+		}
+
+		/* in any case, start a new window of probes */
+		bfs->adaptiveWindowProbes = 0;
+		bfs->adaptiveWindowMatches = 0;
+	}
+}
+
+/*
+ * ExecBloomFilters
+ *		Probe bloom filters for the current slot.
+ *
+ * Test the slot in the expression context (set by the scan node) against
+ * every bloom filter attached to the node. Returns true if the tuple matches
+ * all filters (some of which may have been skipped, because the hash table
+ * isn't built yet); false if at least one filter conclusively excludes it.
+ *
+ * 'filters' is a list of BloomFilterState for each filter, pushed to the
+ * node (stored in planstate->bloom_filters). It may be NIL, which means
+ * are no filters, and the function simply returns NULL.
+ *
+ * The caller is responsible for having set econtext->ecxt_scantuple to
+ * 'slot' first. We do not reset the per-tuple context here (it's up to the
+ * scan node).
+ *
+ * Designed to be called like ExecQual from the recipient's per-tuple
+ * loop. See ExecScanExtended for the scan-node integration point.
+ *
+ * XXX We're pushing filters to scan nodes, which set the scan slot. And
+ * setrefs.c is currently wired to do fix_scan_bloom_filters, called from
+ * set_plan_refs. If we decide to push filters to other nodes (e.g. joins),
+ * this may need some rework.
+ */
+bool
+ExecBloomFilters(List *filters, ExprContext *econtext)
+{
+	ListCell   *lc;
+
+	/* bail out if no filters */
+	if (filters == NIL)
+		return true;
+
+	foreach(lc, filters)
+	{
+		BloomFilterState *bfs = (BloomFilterState *) lfirst(lc);
+		HashJoinState *producer = bfs->producer;
+		HashState  *hashNode;
+		bloom_filter *bf;
+		uint32		hashvalue;
+
+		/* Producer should always exist (resolved at init time). */
+		Assert(producer != NULL);
+
+		/*
+		 * The hashtable (and the bloom filter) is built lazily the first
+		 * time it needs to do a lookup. Until then, assume everything
+		 * matches everything through. Once the filter is in place, start
+		 * probing it.
+		 *
+		 * XXX It should only take a couple tuples (maybe just a single one)
+		 * from the scan node before the filter is available.
+		 */
+		hashNode = castNode(HashState, innerPlanState(&producer->js.ps));
+		bf = hashNode->bloom_filter;
+		if (bf == NULL)
+			continue;
+
+		/*
+		 * When recent bloom probes mostly pass through, probe only a sample of
+		 * values to avoid spending work on an ineffective filter. Sampled
+		 * probes keep updating the recent match fraction, so filtering resumes
+		 * for every value once the filter becomes selective again.
+		 */
+		if (!ExecBloomFilterShouldProbe(bfs))
+			continue;
+
+		/* NULL strict key: tuple cannot be in the filter, pass through. */
+		if (!ExecBloomFilterHash(bfs, econtext, &hashvalue))
+			continue;
+
+		/*
+		 * XXX It's a bit silly the counters are in two places. We should
+		 * keep just the hashNode counters, and get rid of bfs counters.
+		 */
+		bfs->checked++;
+		hashNode->bloomFilterChecked++;
+
+		/* If not matching, we're done - reject the tuple. */
+		if (bloom_lacks_element(bf,
+								(unsigned char *) &hashvalue,
+								sizeof(hashvalue)))
+		{
+			bfs->rejected++;
+			hashNode->bloomFilterRejected++;
+			ExecBloomFilterUpdateAdaptiveState(bfs, false);
+			return false;
+		}
+
+		ExecBloomFilterUpdateAdaptiveState(bfs, true);
+	}
+
+	return true;
+}
+
+/*
+ * ExecInitBloomFilters
+ *		Initialize state for pushed-down bloom filters.
+ *
+ * Called by nodes that want to act as a recipient of pushed-down filters,
+ * after the node's projection / scan-tuple slot are set up, just like
+ * for regular quals.
+ *
+ * Walks the plan's bloom_filters list and produces a list of BloomFilterState
+ * nodes, stored in planstate->bloom_filters. The producer HashJoinState node
+ * is resolved here, once, via EState.es_bloom_producers; so that no lookup is
+ * needed at probe time (the bloom_filter pointer may change on rescan, but
+ * that's not what we store).
+ *
+ * 'output_slot' is the slot whose values the filter expressions will be
+ * evaluated against (i.e. the same slot the surrounding qual evaluates
+ * against, post-setrefs).
+ *
+ * XXX For now this has to be the scan slot. See the comment about setrefs
+ * a bit earlier. Could be relaxed later, if we support to pushdown to
+ * other node types.
+ *
+ * XXX The filter states are initialized in es_query_cxt, but maybe that's
+ * too long-lived. The states live only as long as the recipient node.
+ */
+void
+ExecInitBloomFilters(PlanState *planstate, TupleTableSlot *output_slot)
+{
+	Plan	   *plan = planstate->plan;
+	EState	   *estate = planstate->state;
+	List	   *result = NIL;
+	ListCell   *lc;
+	MemoryContext oldctx;
+
+	/* bail out if there are no pushed-down filters */
+	if (plan->bloom_filters == NIL)
+		return;
+
+	oldctx = MemoryContextSwitchTo(estate->es_query_cxt);
+
+	foreach(lc, plan->bloom_filters)
+	{
+		BloomFilter *bf = lfirst_node(BloomFilter, lc);
+		BloomFilterState *bfs;
+		int			nkeys;
+
+		nkeys = list_length(bf->filter_exprs);
+		Assert(nkeys > 0);
+		Assert(nkeys == list_length(bf->hashops));
+		Assert(nkeys == list_length(bf->hashcollations));
+
+		bfs = makeNode(BloomFilterState);
+
+		/* XXX some of this is redundant */
+		bfs->filter = bf;
+		bfs->producer_id = bf->producer_id;
+		bfs->producer = LookupBloomFilterProducer(estate, bf->producer_id);
+
+		/* initialize the expression state for the hashvalue calculation */
+		{
+			Oid		   *outer_hashfuncid = palloc_array(Oid, nkeys);
+			Oid		   *inner_hashfuncid = palloc_array(Oid, nkeys);
+			bool	   *hash_strict = palloc_array(bool, nkeys);
+			ListCell   *lc2;
+
+			/*
+			 * Determine the hash function for each side of the join for the given
+			 * join operator, and detect whether the join operator is strict.
+			 */
+			foreach(lc2, bf->hashops)
+			{
+				Oid			hashop = lfirst_oid(lc2);
+				int			i = foreach_current_index(lc2);
+
+				if (!get_op_hash_functions(hashop,
+										   &outer_hashfuncid[i],
+										   &inner_hashfuncid[i]))
+					elog(ERROR,
+						 "could not find hash function for hash operator %u",
+						 hashop);
+				hash_strict[i] = op_strict(hashop);
+			}
+
+			/* state for the hash value calculation */
+			bfs->keys = ExecBuildHash32Expr(output_slot->tts_tupleDescriptor,
+											output_slot->tts_ops,
+											outer_hashfuncid,
+											bf->hashcollations,
+											bf->filter_exprs,
+											hash_strict,
+											planstate,
+											0);
+		}
+
+		result = lappend(result, bfs);
+	}
+
+	planstate->bloom_filters = result;
+
+	MemoryContextSwitchTo(oldctx);
+}
+
+/*
+ * ExecRegisterBloomFilterProducer
+ *		Register the pushed downn bloom filter.
+ *
+ * Called by ExecInitHashJoin (before recursing into the outer subtree)
+ * to register this HashJoinState as a producer for the pushed-down filter.
+ * Recipients in the outer subtree will look us up here by plan_node_id.
+ */
+void
+ExecRegisterBloomFilterProducer(HashJoinState *hjstate)
+{
+	EState	   *estate = hjstate->js.ps.state;
+
+	estate->es_bloom_producers = lappend(estate->es_bloom_producers, hjstate);
+}
diff --git a/src/backend/executor/nodeIndexonlyscan.c b/src/backend/executor/nodeIndexonlyscan.c
index d52012e8a69..bd7e5966017 100644
--- a/src/backend/executor/nodeIndexonlyscan.c
+++ b/src/backend/executor/nodeIndexonlyscan.c
@@ -600,6 +600,9 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 	indexstate->recheckqual =
 		ExecInitQual(node->recheckqual, (PlanState *) indexstate);
 
+	ExecInitBloomFilters((PlanState *) indexstate,
+						 indexstate->ss.ss_ScanTupleSlot);
+
 	/*
 	 * If we are just doing EXPLAIN (ie, aren't going to run the plan), stop
 	 * here.  This allows an index-advisor plugin to EXPLAIN a plan containing
diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 39f6691ee35..ef56522fbc5 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -970,6 +970,9 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags)
 	indexstate->indexorderbyorig =
 		ExecInitExprList(node->indexorderbyorig, (PlanState *) indexstate);
 
+	ExecInitBloomFilters((PlanState *) indexstate,
+						 indexstate->ss.ss_ScanTupleSlot);
+
 	/*
 	 * If we are just doing EXPLAIN (ie, aren't going to run the plan), stop
 	 * here.  This allows an index-advisor plugin to EXPLAIN a plan containing
diff --git a/src/backend/executor/nodeSamplescan.c b/src/backend/executor/nodeSamplescan.c
index f3d273e1c5e..738502433b4 100644
--- a/src/backend/executor/nodeSamplescan.c
+++ b/src/backend/executor/nodeSamplescan.c
@@ -147,6 +147,9 @@ ExecInitSampleScan(SampleScan *node, EState *estate, int eflags)
 	scanstate->repeatable =
 		ExecInitExpr(tsc->repeatable, (PlanState *) scanstate);
 
+	ExecInitBloomFilters((PlanState *) scanstate,
+						 scanstate->ss.ss_ScanTupleSlot);
+
 	/*
 	 * If we don't have a REPEATABLE clause, select a random seed.  We want to
 	 * do this just once, since the seed shouldn't change over rescans.
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 5bcb0a861d7..4d3d7ba10a9 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -268,6 +268,9 @@ ExecInitSeqScan(SeqScan *node, EState *estate, int eflags)
 	scanstate->ss.ps.qual =
 		ExecInitQual(node->scan.plan.qual, (PlanState *) scanstate);
 
+	ExecInitBloomFilters((PlanState *) scanstate,
+						 scanstate->ss.ss_ScanTupleSlot);
+
 	/*
 	 * When EvalPlanQual() is not in use, assign ExecProcNode for this node
 	 * based on the presence of qual and projection. Each ExecSeqScan*()
diff --git a/src/backend/executor/nodeTidrangescan.c b/src/backend/executor/nodeTidrangescan.c
index b387ed6c308..b00a3736b99 100644
--- a/src/backend/executor/nodeTidrangescan.c
+++ b/src/backend/executor/nodeTidrangescan.c
@@ -434,6 +434,9 @@ ExecInitTidRangeScan(TidRangeScan *node, EState *estate, int eflags)
 	tidrangestate->ss.ps.qual =
 		ExecInitQual(node->scan.plan.qual, (PlanState *) tidrangestate);
 
+	ExecInitBloomFilters((PlanState *) tidrangestate,
+						 tidrangestate->ss.ss_ScanTupleSlot);
+
 	TidExprListCreate(tidrangestate);
 
 	/*
diff --git a/src/backend/executor/nodeTidscan.c b/src/backend/executor/nodeTidscan.c
index 6641df10999..f7ba78a63fc 100644
--- a/src/backend/executor/nodeTidscan.c
+++ b/src/backend/executor/nodeTidscan.c
@@ -551,6 +551,9 @@ ExecInitTidScan(TidScan *node, EState *estate, int eflags)
 	tidstate->ss.ps.qual =
 		ExecInitQual(node->scan.plan.qual, (PlanState *) tidstate);
 
+	ExecInitBloomFilters((PlanState *) tidstate,
+						 tidstate->ss.ss_ScanTupleSlot);
+
 	TidExprListCreate(tidstate);
 
 	/*
diff --git a/src/backend/lib/bloomfilter.c b/src/backend/lib/bloomfilter.c
index 73b3768a172..fbcf788e271 100644
--- a/src/backend/lib/bloomfilter.c
+++ b/src/backend/lib/bloomfilter.c
@@ -192,6 +192,25 @@ bloom_prop_bits_set(bloom_filter *filter)
 	return bits_set / (double) filter->m;
 }
 
+/*
+ * Total bitset size, in bits.  Useful for EXPLAIN instrumentation:
+ * divide by 8 to get the bitset's memory footprint in bytes.
+ */
+uint64
+bloom_total_bits(bloom_filter *filter)
+{
+	return filter->m;
+}
+
+/*
+ * Number of hash functions in use.
+ */
+int
+bloom_hash_funcs(bloom_filter *filter)
+{
+	return filter->k_hash_funcs;
+}
+
 /*
  * Which element in the sequence of powers of two is less than or equal to
  * target_bitset_bits?
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 1c575e56ff6..c3072a29ccc 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -156,6 +156,7 @@ bool		enable_material = true;
 bool		enable_memoize = true;
 bool		enable_mergejoin = true;
 bool		enable_hashjoin = true;
+bool		enable_hashjoin_bloom = true;
 bool		enable_gathermerge = true;
 bool		enable_partitionwise_join = false;
 bool		enable_partitionwise_aggregate = false;
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index de6a183da79..7ecb551aae6 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4689,6 +4689,298 @@ create_mergejoin_plan(PlannerInfo *root,
 	return join_plan;
 }
 
+/*
+ * BLOOM FILTER PUSHDOWN
+ *
+ * When creating a hash join plan, consider building a bloom filter and
+ * pushing it down to the outer subtree. For now we only push filters to
+ * scan nodes containing all the join keys. When we find such scan node,
+ * we append the BloomFilter ID to the node's bloom_filters list, and
+ * increment the bloom_consumer_count for the hashjoin.
+ *
+ * As setrefs hashn't run yet, the join keys are still the raw Vars.
+ * So it's safe to compare var->varno against the scanrelid, and copy
+ * the keys verbatim onto the recipient. setrefs will rewrite the Vars
+ * later as usual, just like for the recipient's qual.
+ *
+ * XXX In most cases there'll be only a single consumer node. To get
+ * multiple consumers, we'd need either joins on the same keys, or
+ * ability to produce filters for subsets of the join keys (for cases
+ * where the join is more complex, and does not map to a single scan
+ * node directly). Seems like a possible future improvement.
+ *
+ * XXX Actually, we could have multiple consumer nodes for partitioned
+ * tables, where each partition gets a separate scan. Which seems like
+ * something we should support.
+ *
+ * XXX For simplicity, all outer join keys have to be bare Vars (from
+ * the same RTE). We could relax this later, and allow joins on more
+ * complex expressions. Not sure if that'll erase some of the benefits,
+ * which relies on filter probes being much cheaper hashtable probes.
+ * It also doesn't seem like a very common case.
+ *
+ * XXX The recipient node must be one of a small set of scan nodes. We
+ * could relax this, and allow pushing to other nodes (e.g. joins or
+ * aggregates). Future improvement.
+ *
+ * XXX We don't currently push the same HashJoin to multiple recipients,
+ * but multiple HashJoins may attach a filter to the same scan node.
+ * --------------------------------------------------------------------------
+ */
+
+/*
+ * bloom_join_side_preserved
+ *		Decide if we can push filter to inner/outer side of a join.
+ *
+ * Outer joins that emit unmatched outer tuples (LEFT/ANTI/FULL) are
+ * skipped: dropping outer tuples there would be incorrect.
+ */
+static bool
+bloom_join_side_preserved(JoinType jointype, bool to_outer)
+{
+	switch (jointype)
+	{
+		case JOIN_INNER:
+			return true;
+		case JOIN_LEFT:
+		case JOIN_SEMI:
+		case JOIN_ANTI:
+			return to_outer;
+		case JOIN_RIGHT:
+		case JOIN_RIGHT_SEMI:
+		case JOIN_RIGHT_ANTI:
+			return !to_outer;
+		case JOIN_FULL:
+			return false;
+		default:
+			return false;
+	}
+}
+
+/*
+ * find_bloom_filter_recipient
+ *		Try to find a scan node to push filter to.
+ *
+ * We support pushing filter to a subset of scan nodes (could be extended
+ * later). We support pushing filters through intermediate nodes (joins,
+ * sorts, ...). See bloom_join_side_preserved for joins.
+ *
+ * XXX We could push filters through more nodes - e.g. aggregates if the
+ * hash keys match GROUP BY keys (are a subset of).
+ *
+ * XXX We could do pushdown to parallel parts of a query. But we'd need
+ * a different way to communicate if a filter is built etc. (the worker
+ * won't have access to the hashjoin state).
+ */
+static Plan *
+find_bloom_filter_recipient(Plan *plan, Index target_relid)
+{
+	/* XXX shouldn't really happen, I think */
+	if (plan == NULL)
+		return NULL;
+
+	/* XXX no pushdown to parallel parts of a query */
+	if (plan->parallel_aware)
+		return NULL;
+
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+			{
+				Scan	   *scan = (Scan *) plan;
+
+				if (scan->scanrelid == target_relid)
+					return plan;
+				return NULL;
+			}
+		case T_Sort:
+		case T_IncrementalSort:
+		case T_Material:
+		case T_Memoize:
+		case T_Unique:
+		case T_Limit:
+			return find_bloom_filter_recipient(outerPlan(plan), target_relid);
+		case T_HashJoin:
+		case T_NestLoop:
+		case T_MergeJoin:
+			{
+				JoinType	jt = ((Join *) plan)->jointype;
+				Plan	   *res;
+
+				/*
+				 * We can only push to one node, but we don't know on which
+				 * side of the join it is (e.g. for inner joins it can be on
+				 * both sides). So make sure to check both, if compatible
+				 * with the join type (per bloom_join_side_preserved).
+				 */
+				if (bloom_join_side_preserved(jt, true))
+				{
+					res = find_bloom_filter_recipient(outerPlan(plan),
+													  target_relid);
+					if (res != NULL)
+						return res;
+				}
+				if (bloom_join_side_preserved(jt, false))
+				{
+					res = find_bloom_filter_recipient(innerPlan(plan),
+													  target_relid);
+					if (res != NULL)
+						return res;
+				}
+				return NULL;
+			}
+		default:
+			return NULL;
+	}
+}
+
+/*
+ * try_push_bloom_filter
+ *		Attempt to pushdown a bloom filter for the current hashjoin.
+ *
+ * The filter pushdown happens during plan creation, i.e. after the plan was
+ * already selected. That is not entirely optimal, and it has a couple of
+ * annoying consequences.
+ *
+ * The main disadvantage is that injecting the filter to a scan node may
+ * significantly alter the number of tuples produced by that scan node. If a
+ * filter eliminates 99% of the rows, the scan produces 1/100 of the rows it
+ * was planned with. It would not affect the scan itself, but if there are
+ * other nodes (between the scan and the join), maybe we'd have planned them
+ * differently if we knew about the lower cardinality?
+ *
+ * Similarly, it's confusing in the explain. That is, we'll get "rows=N"
+ * with the planner cardinality (before the filter was pushed down), but
+ * then in EXPLAIN ANALYZE it'll get much lower values. It'd be easy to
+ * confuse with inaccurate estimates.
+ *
+ * It'd be better to know about the filter earlier, when constructing the scan
+ * path. But that's not quite feasible with our bottom-up planner. When planing
+ * the scan, we don't know which of the joins above it will be hashjoins, or
+ * if it can pushdown the filter. We'd have to speculate, or maybe build more
+ * paths with/without expectation of the bloom filter pushdown. But that seems
+ * not great, as it'd add overhead for everyone.
+ */
+static void
+try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan)
+{
+	List	   *hashkeys = hj->hashkeys;
+	List	   *hashops = hj->hashoperators;
+	List	   *hashcolls = hj->hashcollations;
+	ListCell   *lc;
+	Index		target_relid = 0;
+	Plan	   *recipient;
+	BloomFilter *bf;
+
+	/* bail out if feature disabled. */
+	if (!enable_hashjoin_bloom)
+		return;
+
+	/* XXX shouldn't really happen, I think */
+	if (hashkeys == NIL)
+		return;
+
+	Assert(list_length(hashkeys) == list_length(hashops));
+	Assert(list_length(hashkeys) == list_length(hashcolls));
+
+	/*
+	 * Pushdown is unsafe for join types that emit unmatched outer tuples
+	 * (LEFT/ANTI/FULL): we'd risk dropping outer tuples the join would
+	 * otherwise have emitted (possibly NULL-extended).
+	 */
+	switch (hj->join.jointype)
+	{
+		case JOIN_INNER:
+		case JOIN_RIGHT:
+		case JOIN_SEMI:
+		case JOIN_RIGHT_SEMI:
+		case JOIN_RIGHT_ANTI:
+			/* these join types are OK */
+			break;
+		default:
+			return;
+	}
+
+	/*
+	 * All hashkeys must be bare Vars referencing the same base RTE.
+	 *
+	 * XXX We could be a bit less strict, and check that at least some of the
+	 * hashkeys are bare Vars, not all of them. So with joins on multiple
+	 * expressions we'd have better chance to push a filter down. Doesn't
+	 * seem worth it, at least for now.
+	 */
+	foreach(lc, hashkeys)
+	{
+		Node	   *k = (Node *) lfirst(lc);
+		Var		   *var;
+
+		/* not a plain Var */
+		if (!IsA(k, Var))
+			return;
+
+		var = (Var *) k;
+
+		/*
+		 * Reject outer references, whole-row or system columns, and
+		 * special varnos (not sure we can get them here, though).
+		 */
+		if ((var->varlevelsup != 0) ||
+			(var->varattno <= 0) ||
+			IS_SPECIAL_VARNO(var->varno))
+			return;
+
+		/* make sure all the vars are for the same relid */
+		if (target_relid == 0)
+			target_relid = var->varno;
+		else if (var->varno != target_relid)
+			return;
+	}
+
+	/* should have found at least one var */
+	Assert(target_relid != 0);
+
+	/*
+	 * See if we can find the scan node for target_relid. It certainly is
+	 * in the plan somewhere, but it may not be able to pushdown the filter
+	 * to it (because of a join or so).
+	 */
+	recipient = find_bloom_filter_recipient(outer_plan, target_relid);
+	if (recipient == NULL)
+		return;
+
+	/*
+	 * If we found a recipient, assign the filter an ID. We'll use it to
+	 * register the filter in ExecRegisterBloomFilterProducer, and then
+	 * for lookups in LookupBloomFilterProducer during execution.
+	 *
+	 * XXX We can't use plan_node_id, as it's not assigned yet, that only
+	 * happens in set_plan_refs. Also, if we ever allow multiple filters
+	 * per hashtable (e.g. for different subsets of keys), it's not work.
+	 */
+	hj->bloom_filter_id = ++root->glob->lastBloomFilterId;
+
+	bf = makeNode(BloomFilter);
+	bf->filter_exprs = (List *) copyObject(hashkeys);
+	bf->hashops = list_copy(hashops);
+	bf->hashcollations = list_copy(hashcolls);
+	bf->producer_id = hj->bloom_filter_id;
+
+	recipient->bloom_filters = lappend(recipient->bloom_filters, bf);
+
+	/*
+	 * XXX We've manged to push the filter to the scan node, but maybe
+	 * we should wait with updating bloom_consumer_count when it actually
+	 * initializes the filters in ExecInit()?
+	 */
+	hj->bloom_consumer_count++;
+}
+
 static HashJoin *
 create_hashjoin_plan(PlannerInfo *root,
 					 HashPath *best_path)
@@ -4859,6 +5151,13 @@ create_hashjoin_plan(PlannerInfo *root,
 
 	copy_generic_path_info(&join_plan->join.plan, &best_path->jpath.path);
 
+	/*
+	 * Try to push the bloom filter for the hashtable down to nodes in the outer
+	 * subtree. If a suitable scan node exists, add the filter to bloom_filters,
+	 * and bump our bloom_consumer_count.
+	 */
+	try_push_bloom_filter(root, join_plan, outer_plan);
+
 	return join_plan;
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index f4689e7c9f8..60bd853bba7 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -389,6 +389,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	glob->lastPHId = 0;
 	glob->lastRowMarkId = 0;
 	glob->lastPlanNodeId = 0;
+	glob->lastBloomFilterId = 0;
 	glob->transientPlan = false;
 	glob->dependsOnRole = false;
 	glob->partition_directory = NULL;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ff0e875f2a2..0059acfccbe 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -161,6 +161,7 @@ static Node *fix_scan_expr(PlannerInfo *root, Node *node,
 						   int rtoffset, double num_exec);
 static Node *fix_scan_expr_mutator(Node *node, fix_scan_expr_context *context);
 static bool fix_scan_expr_walker(Node *node, fix_scan_expr_context *context);
+static void fix_scan_bloom_filters(PlannerInfo *root, Plan *plan, int rtoffset);
 static void set_join_references(PlannerInfo *root, Join *join, int rtoffset);
 static void set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset);
 static void set_param_references(PlannerInfo *root, Plan *plan);
@@ -665,6 +666,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->scan.plan.qual =
 					fix_scan_list(root, splan->scan.plan.qual,
 								  rtoffset, NUM_EXEC_QUAL(plan));
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_SampleScan:
@@ -681,6 +685,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->tablesample = (TableSampleClause *)
 					fix_scan_expr(root, (Node *) splan->tablesample,
 								  rtoffset, 1);
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_IndexScan:
@@ -706,6 +713,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->indexorderbyorig =
 					fix_scan_list(root, splan->indexorderbyorig,
 								  rtoffset, NUM_EXEC_QUAL(plan));
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_IndexOnlyScan:
@@ -744,6 +754,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->bitmapqualorig =
 					fix_scan_list(root, splan->bitmapqualorig,
 								  rtoffset, NUM_EXEC_QUAL(plan));
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_TidScan:
@@ -760,6 +773,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->tidquals =
 					fix_scan_list(root, splan->tidquals,
 								  rtoffset, 1);
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_TidRangeScan:
@@ -776,6 +792,9 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->tidrangequals =
 					fix_scan_list(root, splan->tidrangequals,
 								  rtoffset, 1);
+
+				/* pushed-down bloom filters */
+				fix_scan_bloom_filters(root, plan, rtoffset);
 			}
 			break;
 		case T_SubqueryScan:
@@ -1426,6 +1445,30 @@ set_indexonlyscan_references(PlannerInfo *root,
 					   rtoffset,
 					   NRM_EQUAL,
 					   NUM_EXEC_QUAL((Plan *) plan));
+	/*
+	 * Bloom filter pushdown: any BloomFilter recipient lists also need
+	 * their key expressions rewritten to reference the index tuple, since
+	 * that's what the recipient scan returns and what ExecBloomFilters
+	 * will evaluate against.
+	 */
+	if (plan->scan.plan.bloom_filters != NIL)
+	{
+		ListCell   *lc2;
+
+		foreach(lc2, plan->scan.plan.bloom_filters)
+		{
+			BloomFilter *bf = lfirst_node(BloomFilter, lc2);
+
+			bf->filter_exprs = (List *)
+				fix_upper_expr(root,
+							   (Node *) bf->filter_exprs,
+							   index_itlist,
+							   INDEX_VAR,
+							   rtoffset,
+							   NRM_EQUAL,
+							   NUM_EXEC_QUAL((Plan *) plan));
+		}
+	}
 	/* indexqual is already transformed to reference index columns */
 	plan->indexqual = fix_scan_list(root, plan->indexqual,
 									rtoffset, 1);
@@ -2398,6 +2441,26 @@ fix_scan_expr_walker(Node *node, fix_scan_expr_context *context)
 	return expression_tree_walker(node, fix_scan_expr_walker, context);
 }
 
+/*
+ * Fix references in hashkey expressions of bloom filters pushed down.
+ */
+static void
+fix_scan_bloom_filters(PlannerInfo *root, Plan *plan, int rtoffset)
+{
+	ListCell   *lc;
+
+	if (plan->bloom_filters == NIL)
+		return;
+
+	foreach(lc, plan->bloom_filters)
+	{
+		BloomFilter *bf = lfirst_node(BloomFilter, lc);
+
+		bf->filter_exprs = fix_scan_list(root, bf->filter_exprs, rtoffset,
+										 NUM_EXEC_QUAL(plan));
+	}
+}
+
 /*
  * set_join_references
  *	  Modify the target list and quals of a join node to reference its
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b046..1134b5187ac 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -920,6 +920,13 @@
   boot_val => 'true',
 },
 
+{ name => 'enable_hashjoin_bloom', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
+  short_desc => 'Enables hash join bloom filter pushdown to the outer side.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'enable_hashjoin_bloom',
+  boot_val => 'true',
+},
+
 { name => 'enable_incremental_sort', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
   short_desc => 'Enables the planner\'s use of incremental sort steps.',
   flags => 'GUC_EXPLAIN',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..f8020170506 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -448,6 +448,7 @@
 #enable_distinct_reordering = on
 #enable_self_join_elimination = on
 #enable_eager_aggregate = on
+#enable_hashjoin_bloom = on
 
 # - Planner Cost Constants -
 
diff --git a/src/include/executor/execScan.h b/src/include/executor/execScan.h
index 18b03235c3c..8cfe9b03c3e 100644
--- a/src/include/executor/execScan.h
+++ b/src/include/executor/execScan.h
@@ -170,10 +170,11 @@ ExecScanExtended(ScanState *node,
 	/* interrupt checks are in ExecScanFetch */
 
 	/*
-	 * If we have neither a qual to check nor a projection to do, just skip
-	 * all the overhead and return the raw scan tuple.
+	 * If we have neither a qual to check nor a projection to do nor any
+	 * pushed-down bloom filter to probe, just skip all the overhead and
+	 * return the raw scan tuple.
 	 */
-	if (!qual && !projInfo)
+	if (!qual && !projInfo && node->ps.bloom_filters == NIL)
 	{
 		ResetExprContext(econtext);
 		return ExecScanFetch(node, epqstate, accessMtd, recheckMtd);
@@ -214,6 +215,21 @@ ExecScanExtended(ScanState *node,
 		 */
 		econtext->ecxt_scantuple = slot;
 
+		/*
+		 * If runtime bloom filters have been pushed down to this scan,
+		 * check them first. A rejected tuple is dropped silently (no
+		 * "Rows Removed by Filter" instrumentation -- the per-filter
+		 * counters in BloomFilterState already capture this).
+		 *
+		 * XXX Maybe we should include this in "Rows Removed by Filter"?
+		 */
+		if (node->ps.bloom_filters != NIL &&
+			!ExecBloomFilters(node->ps.bloom_filters, econtext))
+		{
+			ResetExprContext(econtext);
+			continue;
+		}
+
 		/*
 		 * check that the current tuple satisfies the qual-clause
 		 *
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 33bbdbfeffb..01010a20cd6 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -516,6 +516,18 @@ ExecProject(ProjectionInfo *projInfo)
 }
 #endif
 
+/*
+ * Bloom filter pushdown (see nodeHashjoin.c). Declared here so that
+ * scan nodes that act as recipients don't need to pull in hashjoin
+ * internals just to call these helpers from their ExecInit.
+ *
+ * XXX There's probably a better place for this. It should live in
+ * the executor somewhere, not in nodeHashjoin.c?
+ */
+extern bool ExecBloomFilters(List *filters, ExprContext *econtext);
+extern void ExecInitBloomFilters(PlanState *planstate,
+								 TupleTableSlot *output_slot);
+
 /*
  * ExecQual - evaluate a qual prepared with ExecInitQual (possibly via
  * ExecPrepareQual).  Returns true if qual is satisfied, else false.
diff --git a/src/include/executor/nodeHashjoin.h b/src/include/executor/nodeHashjoin.h
index aebd39be8b5..08efefae209 100644
--- a/src/include/executor/nodeHashjoin.h
+++ b/src/include/executor/nodeHashjoin.h
@@ -31,4 +31,13 @@ extern void ExecHashJoinInitializeWorker(HashJoinState *state,
 extern void ExecHashJoinSaveTuple(MinimalTuple tuple, uint32 hashvalue,
 								  BufFile **fileptr, HashJoinTable hashtable);
 
+/*
+ * Bloom filter pushdown producer-side helper (see nodeHashjoin.c).
+ *
+ * ExecBloomFilters and ExecInitBloomFilters live in executor.h so that
+ * scan nodes can call them from ExecInit without having to pull in
+ * hashjoin internals.
+ */
+extern void ExecRegisterBloomFilterProducer(HashJoinState *hjstate);
+
 #endif							/* NODEHASHJOIN_H */
diff --git a/src/include/lib/bloomfilter.h b/src/include/lib/bloomfilter.h
index 860ee9bdc72..6b0026c8c45 100644
--- a/src/include/lib/bloomfilter.h
+++ b/src/include/lib/bloomfilter.h
@@ -23,5 +23,7 @@ extern void bloom_add_element(bloom_filter *filter, unsigned char *elem,
 extern bool bloom_lacks_element(bloom_filter *filter, unsigned char *elem,
 								size_t len);
 extern double bloom_prop_bits_set(bloom_filter *filter);
+extern uint64 bloom_total_bits(bloom_filter *filter);
+extern int bloom_hash_funcs(bloom_filter *filter);
 
 #endif							/* BLOOMFILTER_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..04333f1a4d0 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -762,6 +762,14 @@ typedef struct EState
 
 	List	   *es_auxmodifytables; /* List of secondary ModifyTableStates */
 
+	/*
+	 * List of nodes producing pushed-down bloom filters. Each list element
+	 * is a HashJoinState with at least one filter pushed down to a scan node.
+	 * Scans look up their producer in this list (by plan_node_id) lazily on
+	 * first probe (and cache the result).
+	 */
+	List	   *es_bloom_producers;
+
 	/*
 	 * this ExprContext is for per-output-tuple operations, such as constraint
 	 * checks and index-value computations.  It will be reset for each output
@@ -1286,8 +1294,58 @@ typedef struct PlanState
 	bool		outeropsset;
 	bool		inneropsset;
 	bool		resultopsset;
+
+	/*
+	 * Bloom filters (BloomFilterState) pushed to this node (NIL if no
+	 * filters were pushed down to this node). Right now only some scan
+	 * nodes expect filters, but the list is in PlanState so that we can
+	 * expand the feature to more nodes in the future.
+	 */
+	List	   *bloom_filters;
 } PlanState;
 
+/*
+ * BloomFilterState
+ *
+ * State for executing (evaluating) a BloomFilter, pushed to a scan node.
+ *
+ * The producer is the HashJoinState this bloom filter is for. It's resolved
+ * at executor-init time using EState.es_bloom_producers (the producer always
+ * runs ExecInit before its recipients).
+ *
+ * 'checked' and 'rejected' are per-recipient counters reported by EXPLAIN
+ * ANALYZE. It's a bit redundant with the fields we have in the producer
+ * (see HashState). But if we ever end up with multiple consumers per filter,
+ * it'd be useful.
+ *
+ * The adaptive fields track the match fraction in recently checked probes.
+ * When most checks are passing through, the executor starts probing less
+ * frequently (and sampling probes instead), until the fraction of matches
+ * drops below some threshold.
+ */
+typedef struct BloomFilterState
+{
+	NodeTag		type;
+
+	/* producer HJ node and the filter */
+	int			producer_id;
+	struct HashJoinState *producer;
+	BloomFilter *filter;
+
+	int			nkeys;		/* number of hash keys */
+	ExprState  *keys;		/* ExprState for the hash calculation */
+
+	/* probe counters */
+	uint64		checked;
+	uint64		rejected;
+
+	/* adaptive probing */
+	uint64		adaptiveWindowProbes;
+	uint64		adaptiveWindowMatches;
+	uint64		adaptiveSampleCounter;
+	bool		adaptiveSampling;
+} BloomFilterState;
+
 /* ----------------
  *	these are defined to avoid confusion problems with "left"
  *	and "right" and "inner" and "outer".  The convention is that
@@ -2700,6 +2758,39 @@ typedef struct HashState
 	Tuplestorestate *null_tuple_store;	/* where to put null-keyed tuples */
 	bool		keep_null_tuples;	/* do we need to save such tuples? */
 
+	/*
+	 * True iff at we managed to push down the bloom filter to at least one
+	 * node on the HashJoin's outer side. It tells the Hash node to also build
+	 * a bloom filter next to the hash table.
+	 */
+	bool		want_bloom_filter;
+
+	/*
+	 * Mirror of the parent HashJoin's bloom_filter_id, copied here at
+	 * ExecInitHashJoin time so EXPLAIN's show_hash_info can label the
+	 * filter without traversing back up the plan-state tree (which is
+	 * not easy, as there's no parent in PlanState).
+	 */
+	int			bloom_filter_id;
+
+	/*
+	 * Bloom filter on hash values during the build phase. Probed by recipient
+	 * nodes (typically scans in the outer subtree).  NULL when pushdown is
+	 * disabled or no recipient was identified, and (transiently) before
+	 * ExecHashTableCreate runs (on the first outer tuple).
+	 *
+	 * Allocated in hashCxt, just like the hashtable itself. Reset on rescans.
+	 */
+	struct bloom_filter *bloom_filter;
+
+	/*
+	 * Counters with total per-filter instrumentation. Separate from the
+	 * per-recipient counters in BloomFilterState. Redundant, but will be
+	 * needed if we end up allowing multiple recipients.
+	 */
+	uint64		bloomFilterChecked;
+	uint64		bloomFilterRejected;
+
 	/*
 	 * In a parallelized hash join, the leader retains a pointer to the
 	 * shared-memory stats area in its shared_info field, and then copies the
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27a2c6815b7..69f9ad2d5e3 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -244,6 +244,9 @@ typedef struct PlannerGlobal
 	/* highest plan node ID assigned */
 	int			lastPlanNodeId;
 
+	/* highest bloom-filter-producer ID assigned (see plannodes.h) */
+	int			lastBloomFilterId;
+
 	/* redo plan when TransactionXmin changes? */
 	bool		transientPlan;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 14a1dfed2b9..4e35d77cc49 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -254,8 +254,40 @@ typedef struct Plan
 	 */
 	Bitmapset  *extParam;
 	Bitmapset  *allParam;
+
+	/*
+	 * List of BloomFilter nodes (or NIL).  When non-empty, this plan node is
+	 * a recipient of one or more runtime bloom filters pushed down at plan
+	 * time by some HashJoin ancestor; see nodeHashjoin.c.  Living on Plan
+	 * (rather than on a specific subtype like Scan) allows pushdown to any
+	 * node type that's prepared to call ExecBloomFilters on its output.
+	 */
+	List	   *bloom_filters;
 } Plan;
 
+/*
+ *	 BloomFilter -
+ *		One pushed-down bloom filter, attached to a recipient Plan node.
+ *
+ * 'filter_exprs', 'hashops' and 'hashcollations' are parallel lists, one
+ * entry per join key: the expression to hash, its hash operator (OID),
+ * and its input collation (OID).
+ *
+ * 'producer_id' is the bloom_filter_id of the producing HashJoin (resolved at
+ * executor init time via EState.es_bloom_producers).
+ * ----------------
+ */
+typedef struct BloomFilter
+{
+	pg_node_attr(no_query_jumble)
+
+	NodeTag		type;
+	List	   *filter_exprs;
+	List	   *hashops;
+	List	   *hashcollations;
+	int			producer_id;
+} BloomFilter;
+
 /* ----------------
  *	these are defined to avoid confusion problems with "left"
  *	and "right" and "inner" and "outer".  The convention is that
@@ -1073,6 +1105,25 @@ typedef struct HashJoin
 	 * perform lookups in the hashtable over the inner plan.
 	 */
 	List	   *hashkeys;
+
+	/*
+	 * Number of plan nodes that consume this HashJoin's bloom filter via
+	 * pushdown.  Set at plan time by the bloom filter pushdown pass.
+	 *
+	 * At execution time, the HashJoin builds the bloom filter only when this
+	 * is non-zero (and the enable_hashjoin_bloom GUC is on).
+	 */
+	int			bloom_consumer_count;
+
+	/*
+	 * Identifier used by recipient nodes to find this producer at execution
+	 * time, via EState.es_bloom_producers. Assigned during create_hashjoin_plan
+	 * from PlannerGlobal.lastBloomFilterId. Each BloomFilter on a recipient stores
+	 * a copy in its producer_id field for convenience.
+	 *
+	 * Zero when this HashJoin has no consumers.
+	 */
+	int			bloom_filter_id;
 } HashJoin;
 
 /* ----------------
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index f2fd5d31507..7339979c008 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -62,6 +62,7 @@ extern PGDLLIMPORT bool enable_material;
 extern PGDLLIMPORT bool enable_memoize;
 extern PGDLLIMPORT bool enable_mergejoin;
 extern PGDLLIMPORT bool enable_hashjoin;
+extern PGDLLIMPORT bool enable_hashjoin_bloom;
 extern PGDLLIMPORT bool enable_gathermerge;
 extern PGDLLIMPORT bool enable_partitionwise_join;
 extern PGDLLIMPORT bool enable_partitionwise_aggregate;
diff --git a/src/test/regress/expected/aggregates.out b/src/test/regress/expected/aggregates.out
index fbda0e3bbc2..a419799a64e 100644
--- a/src/test/regress/expected/aggregates.out
+++ b/src/test/regress/expected/aggregates.out
@@ -1509,9 +1509,11 @@ group by t1.a,t1.b,t1.c,t1.d,t2.x,t2.y,t2.z;
    ->  Hash Join
          Hash Cond: ((t2.x = t1.a) AND (t2.y = t1.b))
          ->  Seq Scan on t2
+               Bloom Filter 1: keys=(x, y)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on t1
-(7 rows)
+(9 rows)
 
 -- Test case where t1 can be optimized but not t2
 explain (costs off) select t1.*,t2.x,t2.z
@@ -1524,9 +1526,11 @@ group by t1.a,t1.b,t1.c,t1.d,t2.x,t2.z;
    ->  Hash Join
          Hash Cond: ((t2.x = t1.a) AND (t2.y = t1.b))
          ->  Seq Scan on t2
+               Bloom Filter 1: keys=(x, y)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on t1
-(7 rows)
+(9 rows)
 
 -- Cannot optimize when PK is deferrable
 explain (costs off) select * from t3 group by a,b,c;
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index 456d32eb13d..df748f0592a 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -34,14 +34,16 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -80,8 +82,10 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial GroupAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
@@ -90,7 +94,7 @@ GROUP BY t1.a ORDER BY t1.a;
                                  Sort Key: t2.b
                                  ->  Seq Scan on public.eager_agg_t2 t2
                                        Output: t2.c, t2.b
-(21 rows)
+(23 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -134,8 +138,10 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 2: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg((t2.c + t3.c)))
+                     Bloom Filter 2
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg((t2.c + t3.c))
                            Group Key: t2.b
@@ -144,11 +150,13 @@ GROUP BY t1.a ORDER BY t1.a;
                                  Hash Cond: (t3.a = t2.a)
                                  ->  Seq Scan on public.eager_agg_t3 t3
                                        Output: t3.a, t3.b, t3.c
+                                       Bloom Filter 1: keys=(t3.a)
                                  ->  Hash
                                        Output: t2.c, t2.b, t2.a
+                                       Bloom Filter 1
                                        ->  Seq Scan on public.eager_agg_t2 t2
                                              Output: t2.c, t2.b, t2.a
-(25 rows)
+(29 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
   FROM eager_agg_t1 t1
@@ -189,8 +197,10 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 2: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg((t2.c + t3.c)))
+                     Bloom Filter 2
                      ->  Partial GroupAggregate
                            Output: t2.b, PARTIAL avg((t2.c + t3.c))
                            Group Key: t2.b
@@ -202,11 +212,13 @@ GROUP BY t1.a ORDER BY t1.a;
                                        Hash Cond: (t3.a = t2.a)
                                        ->  Seq Scan on public.eager_agg_t3 t3
                                              Output: t3.a, t3.b, t3.c
+                                             Bloom Filter 1: keys=(t3.a)
                                        ->  Hash
                                              Output: t2.c, t2.b, t2.a
+                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_t2 t2
                                                    Output: t2.c, t2.b, t2.a
-(28 rows)
+(32 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
   FROM eager_agg_t1 t1
@@ -249,14 +261,16 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -295,11 +309,13 @@ GROUP BY t2.b ORDER BY t2.b;
                Hash Cond: (t2.b = t1.b)
                ->  Seq Scan on public.eager_agg_t2 t2
                      Output: t2.a, t2.b, t2.c
+                     Bloom Filter 1: keys=(t2.b)
                ->  Hash
                      Output: t1.b
+                     Bloom Filter 1
                      ->  Seq Scan on public.eager_agg_t1 t1
                            Output: t1.b
-(15 rows)
+(17 rows)
 
 SELECT t2.b, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -400,14 +416,16 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -444,9 +462,11 @@ GROUP BY t1.a ORDER BY t1.a;
          ->  Hash Join
                Hash Cond: (t2.b = t1.b)
                ->  Seq Scan on eager_agg_t2 t2
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on eager_agg_t1 t1
-(9 rows)
+(11 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, avg(t2.c) FILTER (WHERE random() > 0.5)
@@ -462,9 +482,11 @@ GROUP BY t1.a ORDER BY t1.a;
          ->  Hash Join
                Hash Cond: (t2.b = t1.b)
                ->  Seq Scan on eager_agg_t2 t2
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on eager_agg_t1 t1
-(9 rows)
+(11 rows)
 
 DROP TABLE eager_agg_t1;
 DROP TABLE eager_agg_t2;
@@ -509,8 +531,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
+                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -524,8 +548,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
+                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -539,14 +565,16 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
+                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x, t1_2.y
-(49 rows)
+(55 rows)
 
 SELECT t1.x, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -591,8 +619,10 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
+                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -606,8 +636,10 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
+                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -621,14 +653,16 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
+                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.y, t1_2.x
-(49 rows)
+(55 rows)
 
 SELECT t2.y, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -675,8 +709,10 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.x, t2.y
+                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.x)), (PARTIAL count(*)), (PARTIAL avg(t1.x))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.x), PARTIAL count(*), PARTIAL avg(t1.x)
                                  Group Key: t1.x
@@ -687,8 +723,10 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.x, t2_1.y
+                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.x)), (PARTIAL count(*)), (PARTIAL avg(t1_1.x))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.x), PARTIAL count(*), PARTIAL avg(t1_1.x)
                                  Group Key: t1_1.x
@@ -699,14 +737,16 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.x, t2_2.y
+                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.x)), (PARTIAL count(*)), (PARTIAL avg(t1_2.x))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.x), PARTIAL count(*), PARTIAL avg(t1_2.x)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x
-(44 rows)
+(50 rows)
 
 SELECT t2.x, sum(t1.x), count(*)
   FROM eager_agg_tab1 t1
@@ -748,8 +788,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab1_p1 t1
                            Output: t1.x
+                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.x, (PARTIAL sum((t2.y + t3.y)))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.x, PARTIAL sum((t2.y + t3.y))
                                  Group Key: t2.x
@@ -758,8 +800,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p1 t2
                                              Output: t2.y, t2.x
+                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
+                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab1_p1 t3
                                                    Output: t3.y, t3.x
          ->  Finalize HashAggregate
@@ -770,8 +814,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab1_p2 t1_1
                            Output: t1_1.x
+                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y)))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y))
                                  Group Key: t2_1.x
@@ -780,8 +826,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p2 t2_1
                                              Output: t2_1.y, t2_1.x
+                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
+                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab1_p2 t3_1
                                                    Output: t3_1.y, t3_1.x
          ->  Finalize HashAggregate
@@ -792,8 +840,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                            Output: t1_2.x
+                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y)))
+                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y))
                                  Group Key: t2_2.x
@@ -802,11 +852,13 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p3 t2_2
                                              Output: t2_2.y, t2_2.x
+                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
+                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab1_p3 t3_2
                                                    Output: t3_2.y, t3_2.x
-(70 rows)
+(82 rows)
 
 SELECT t1.x, sum(t2.y + t3.y)
   FROM eager_agg_tab1 t1
@@ -976,8 +1028,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
+                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -991,8 +1045,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
+                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -1006,14 +1062,16 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
+                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x, t1_2.y
-(49 rows)
+(55 rows)
 
 SELECT t1.x, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -1076,8 +1134,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
+                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1091,8 +1151,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
+                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1106,8 +1168,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
+                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1121,8 +1185,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
+                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1136,14 +1202,16 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
+                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
+                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(79 rows)
+(89 rows)
 
 SELECT t1.x, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1204,8 +1272,10 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.y, t1.x
+                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1216,8 +1286,10 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.y, t1_1.x
+                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1228,8 +1300,10 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.y, t1_2.x
+                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1240,8 +1314,10 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.y, t1_3.x
+                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1252,14 +1328,16 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.y, t1_4.x
+                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
+                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(67 rows)
+(77 rows)
 
 SELECT t1.y, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1321,8 +1399,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
+                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.x, (PARTIAL sum((t2.y + t3.y))), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.x, PARTIAL sum((t2.y + t3.y)), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1331,8 +1411,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p1 t2
                                              Output: t2.y, t2.x
+                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
+                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab_ml_p1 t3
                                                    Output: t3.y, t3.x
          ->  Finalize HashAggregate
@@ -1343,8 +1425,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
+                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y))), (PARTIAL count(*))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y)), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1353,8 +1437,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t2_1
                                              Output: t2_1.y, t2_1.x
+                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
+                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t3_1
                                                    Output: t3_1.y, t3_1.x
          ->  Finalize HashAggregate
@@ -1365,8 +1451,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
+                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y))), (PARTIAL count(*))
+                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y)), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1375,8 +1463,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t2_2
                                              Output: t2_2.y, t2_2.x
+                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
+                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t3_2
                                                    Output: t3_2.y, t3_2.x
          ->  Finalize HashAggregate
@@ -1387,8 +1477,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
+                           Bloom Filter 8: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, t3_3.x, (PARTIAL sum((t2_3.y + t3_3.y))), (PARTIAL count(*))
+                           Bloom Filter 8
                            ->  Partial HashAggregate
                                  Output: t2_3.x, t3_3.x, PARTIAL sum((t2_3.y + t3_3.y)), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1397,8 +1489,10 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_3.x = t3_3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t2_3
                                              Output: t2_3.y, t2_3.x
+                                             Bloom Filter 7: keys=(t2_3.x)
                                        ->  Hash
                                              Output: t3_3.y, t3_3.x
+                                             Bloom Filter 7
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t3_3
                                                    Output: t3_3.y, t3_3.x
          ->  Finalize HashAggregate
@@ -1409,8 +1503,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
+                           Bloom Filter 10: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, t3_4.x, (PARTIAL sum((t2_4.y + t3_4.y))), (PARTIAL count(*))
+                           Bloom Filter 10
                            ->  Partial HashAggregate
                                  Output: t2_4.x, t3_4.x, PARTIAL sum((t2_4.y + t3_4.y)), PARTIAL count(*)
                                  Group Key: t2_4.x
@@ -1419,11 +1515,13 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_4.x = t3_4.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                              Output: t2_4.y, t2_4.x
+                                             Bloom Filter 9: keys=(t2_4.x)
                                        ->  Hash
                                              Output: t3_4.y, t3_4.x
+                                             Bloom Filter 9
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t3_4
                                                    Output: t3_4.y, t3_4.x
-(114 rows)
+(134 rows)
 
 SELECT t1.x, sum(t2.y + t3.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1485,8 +1583,10 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
+                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.y, t3.x, (PARTIAL sum((t2.y + t3.y))), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.y, t3.x, PARTIAL sum((t2.y + t3.y)), PARTIAL count(*)
                                  Group Key: t2.x, t3.y, t3.x
@@ -1495,8 +1595,10 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p1 t2
                                              Output: t2.y, t2.x
+                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
+                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab_ml_p1 t3
                                                    Output: t3.y, t3.x
                ->  Hash Join
@@ -1504,8 +1606,10 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
+                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.y, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y))), (PARTIAL count(*))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.y, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y)), PARTIAL count(*)
                                  Group Key: t2_1.x, t3_1.y, t3_1.x
@@ -1514,8 +1618,10 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t2_1
                                              Output: t2_1.y, t2_1.x
+                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
+                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t3_1
                                                    Output: t3_1.y, t3_1.x
                ->  Hash Join
@@ -1523,8 +1629,10 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
+                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.y, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y))), (PARTIAL count(*))
+                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.y, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y)), PARTIAL count(*)
                                  Group Key: t2_2.x, t3_2.y, t3_2.x
@@ -1533,8 +1641,10 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t2_2
                                              Output: t2_2.y, t2_2.x
+                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
+                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t3_2
                                                    Output: t3_2.y, t3_2.x
                ->  Hash Join
@@ -1542,8 +1652,10 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
+                           Bloom Filter 8: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, t3_3.y, t3_3.x, (PARTIAL sum((t2_3.y + t3_3.y))), (PARTIAL count(*))
+                           Bloom Filter 8
                            ->  Partial HashAggregate
                                  Output: t2_3.x, t3_3.y, t3_3.x, PARTIAL sum((t2_3.y + t3_3.y)), PARTIAL count(*)
                                  Group Key: t2_3.x, t3_3.y, t3_3.x
@@ -1552,8 +1664,10 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_3.x = t3_3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t2_3
                                              Output: t2_3.y, t2_3.x
+                                             Bloom Filter 7: keys=(t2_3.x)
                                        ->  Hash
                                              Output: t3_3.y, t3_3.x
+                                             Bloom Filter 7
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t3_3
                                                    Output: t3_3.y, t3_3.x
                ->  Hash Join
@@ -1561,8 +1675,10 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
+                           Bloom Filter 10: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, t3_4.y, t3_4.x, (PARTIAL sum((t2_4.y + t3_4.y))), (PARTIAL count(*))
+                           Bloom Filter 10
                            ->  Partial HashAggregate
                                  Output: t2_4.x, t3_4.y, t3_4.x, PARTIAL sum((t2_4.y + t3_4.y)), PARTIAL count(*)
                                  Group Key: t2_4.x, t3_4.y, t3_4.x
@@ -1571,11 +1687,13 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_4.x = t3_4.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                              Output: t2_4.y, t2_4.x
+                                             Bloom Filter 9: keys=(t2_4.x)
                                        ->  Hash
                                              Output: t3_4.y, t3_4.x
+                                             Bloom Filter 9
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t3_4
                                                    Output: t3_4.y, t3_4.x
-(102 rows)
+(122 rows)
 
 SELECT t3.y, sum(t2.y + t3.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1638,8 +1756,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
+                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1653,8 +1773,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
+                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1668,8 +1790,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
+                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1683,8 +1807,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
+                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
+                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1698,14 +1824,16 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
+                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
+                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(79 rows)
+(89 rows)
 
 SELECT t1.x, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 78bf022f7b4..5cbf2b554ab 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -1909,20 +1909,24 @@ select * from tenk1 a, tenk1 b
 where exists(select * from tenk1 c
              where b.twothousand = c.twothousand and b.fivethous <> c.fivethous)
       and a.tenthous = b.tenthous and a.tenthous < 5000;
-                  QUERY PLAN                   
------------------------------------------------
+                    QUERY PLAN                    
+--------------------------------------------------
  Hash Semi Join
    Hash Cond: (b.twothousand = c.twothousand)
    Join Filter: (b.fivethous <> c.fivethous)
    ->  Hash Join
          Hash Cond: (b.tenthous = a.tenthous)
          ->  Seq Scan on tenk1 b
+               Bloom Filter 1: keys=(tenthous)
+               Bloom Filter 2: keys=(twothousand)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on tenk1 a
                      Filter: (tenthous < 5000)
    ->  Hash
+         Bloom Filter 2
          ->  Seq Scan on tenk1 c
-(11 rows)
+(15 rows)
 
 --
 -- More complicated constructs
@@ -2378,9 +2382,11 @@ order by t1.unique1;
          Hash Cond: ((t1.two = t2.two) AND (t1.unique1 = (SubPlan expr_1)))
          ->  Bitmap Heap Scan on tenk1 t1
                Recheck Cond: (unique1 < 10)
+               Bloom Filter 1: keys=(two, unique1)
                ->  Bitmap Index Scan on tenk1_unique1
                      Index Cond: (unique1 < 10)
          ->  Hash
+               Bloom Filter 1
                ->  Bitmap Heap Scan on tenk1 t2
                      Recheck Cond: (unique1 < 10)
                      ->  Bitmap Index Scan on tenk1_unique1
@@ -2392,7 +2398,7 @@ order by t1.unique1;
                          ->  Limit
                                ->  Index Only Scan using tenk1_unique1 on tenk1
                                      Index Cond: ((unique1 IS NOT NULL) AND (unique1 = t2.unique1))
-(20 rows)
+(22 rows)
 
 -- Ensure we get the expected result
 select t1.unique1,t2.unique1 from tenk1 t1
@@ -2598,12 +2604,14 @@ select * from int4_tbl t1
                Join Filter: (t2.f1 > 0)
                Filter: (t3.f1 IS NULL)
                ->  Seq Scan on int4_tbl t2
+                     Bloom Filter 1: keys=(f1)
                ->  Materialize
                      ->  Seq Scan on int4_tbl t3
          ->  Seq Scan on tenk1 t4
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on int4_tbl t1
-(13 rows)
+(15 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -2622,13 +2630,15 @@ select * from int4_tbl t1
                Join Filter: (t2.f1 > 0)
                Filter: (t2.f1 <> COALESCE(t3.f1, '-1'::integer))
                ->  Seq Scan on int4_tbl t2
+                     Bloom Filter 1: keys=(f1)
                ->  Materialize
                      ->  Seq Scan on int4_tbl t3
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on int4_tbl t1
    ->  Materialize
          ->  Seq Scan on tenk1 t4
-(14 rows)
+(16 rows)
 
 explain (costs off)
 select * from onek t1
@@ -3122,10 +3132,12 @@ select count(*) from tenk1 a, tenk1 b
    ->  Hash Join
          Hash Cond: (a.hundred = b.thousand)
          ->  Index Only Scan using tenk1_hundred on tenk1 a
+               Bloom Filter 1: keys=(hundred)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on tenk1 b
                      Filter: ((fivethous % 10) < 10)
-(7 rows)
+(9 rows)
 
 select count(*) from tenk1 a, tenk1 b
   where a.hundred = b.thousand and (b.fivethous % 10) < 10;
@@ -3168,11 +3180,13 @@ ORDER BY 1;
                Hash Cond: (b.f1 = c.f1)
                Filter: (COALESCE(c.f1, 0) = 0)
                ->  Seq Scan on tt3 b
+                     Bloom Filter 1: keys=(f1)
                ->  Hash
                      ->  Seq Scan on tt3 c
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on tt4 a
-(13 rows)
+(15 rows)
 
 SELECT a.f1
 FROM tt4 a
@@ -3210,10 +3224,12 @@ where t1.filt = 5;
  Hash Join
    Hash Cond: (t2.val = t1.val)
    ->  Seq Scan on skewedtable t2
+         Bloom Filter 1: keys=(val)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on skewedtable t1
                Filter: (filt = 5)
-(6 rows)
+(8 rows)
 
 drop table skewedtable;
 --
@@ -3227,9 +3243,11 @@ where unique1 in (select unique2 from tenk1 b);
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
+         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(5 rows)
+(7 rows)
 
 -- sadly, this is not an antijoin
 explain (costs off)
@@ -3251,9 +3269,11 @@ where exists (select 1 from tenk1 b where a.unique1 = b.unique2);
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
+         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(5 rows)
+(7 rows)
 
 explain (costs off)
 select a.* from tenk1 a
@@ -3290,11 +3310,13 @@ where (hundred, thousand) in (select twothousand, twothousand from onek);
    Hash Cond: (tenk1.hundred = onek.twothousand)
    ->  Seq Scan on tenk1
          Filter: (hundred = thousand)
+         Bloom Filter 1: keys=(hundred)
    ->  Hash
+         Bloom Filter 1
          ->  HashAggregate
                Group Key: onek.twothousand
                ->  Seq Scan on onek
-(8 rows)
+(10 rows)
 
 reset enable_memoize;
 --
@@ -3311,17 +3333,19 @@ where t2.a is null;
  Hash Right Anti Join
    Hash Cond: (t2.b = t1.unique1)
    ->  Seq Scan on tbl_anti t2
+         Bloom Filter 1: keys=(b)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(5 rows)
+(7 rows)
 
 -- this is an antijoin, as t2.a is non-null for any matching row
 explain (costs off)
 select * from tenk1 t1 left join
   (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
 where t2.a is null;
-                QUERY PLAN                 
--------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Right Anti Join
    Hash Cond: (t2.b = t1.unique1)
    ->  Merge Left Join
@@ -3329,20 +3353,22 @@ where t2.a is null;
          ->  Sort
                Sort Key: t2.c
                ->  Seq Scan on tbl_anti t2
+                     Bloom Filter 1: keys=(b)
          ->  Sort
                Sort Key: t3.c
                ->  Seq Scan on tbl_anti t3
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(12 rows)
+(14 rows)
 
 -- this is not an antijoin, as t3.a can be nulled by t2/t3 join
 explain (costs off)
 select * from tenk1 t1 left join
   (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
 where t3.a is null;
-                QUERY PLAN                 
--------------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Right Join
    Hash Cond: (t2.b = t1.unique1)
    Filter: (t3.a IS NULL)
@@ -3351,12 +3377,14 @@ where t3.a is null;
          ->  Sort
                Sort Key: t2.c
                ->  Seq Scan on tbl_anti t2
+                     Bloom Filter 1: keys=(b)
          ->  Sort
                Sort Key: t3.c
                ->  Seq Scan on tbl_anti t3
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(13 rows)
+(15 rows)
 
 rollback;
 --
@@ -3370,9 +3398,11 @@ where exists (select 1 from tenk1 b where a.unique1 = b.unique2 group by b.uniqu
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
+         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(5 rows)
+(7 rows)
 
 --
 -- regression test for proper handling of outer joins within antijoins
@@ -3989,11 +4019,13 @@ where q1 = thousand or q2 = thousand;
                ->  Seq Scan on q2
          ->  Bitmap Heap Scan on tenk1
                Recheck Cond: ((q1.q1 = thousand) OR (q2.q2 = thousand))
+               Bloom Filter 1: keys=(twothousand)
                ->  Bitmap Index Scan on tenk1_thous_tenthous
                      Index Cond: (thousand = ANY (ARRAY[q1.q1, q2.q2]))
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on int4_tbl
-(12 rows)
+(14 rows)
 
 explain (costs off)
 select * from
@@ -4010,11 +4042,13 @@ where thousand = (q1 + q2);
                ->  Seq Scan on q2
          ->  Bitmap Heap Scan on tenk1
                Recheck Cond: (thousand = (q1.q1 + q2.q2))
+               Bloom Filter 1: keys=(twothousand)
                ->  Bitmap Index Scan on tenk1_thous_tenthous
                      Index Cond: (thousand = (q1.q1 + q2.q2))
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on int4_tbl
-(12 rows)
+(14 rows)
 
 --
 -- test ability to generate a suitable plan for a star-schema query
@@ -4120,8 +4154,10 @@ where t1.unique1 < i4.f1;
                      Hash Cond: (t2.ten = t1.tenthous)
                      ->  Seq Scan on public.tenk1 t2
                            Output: t2.unique1, t2.unique2, t2.two, t2.four, t2.ten, t2.twenty, t2.hundred, t2.thousand, t2.twothousand, t2.fivethous, t2.tenthous, t2.odd, t2.even, t2.stringu1, t2.stringu2, t2.string4
+                           Bloom Filter 1: keys=(t2.ten)
                      ->  Hash
                            Output: t1.tenthous, t1.unique1
+                           Bloom Filter 1
                            ->  Nested Loop
                                  Output: t1.tenthous, t1.unique1
                                  ->  Subquery Scan on ss0
@@ -4137,7 +4173,7 @@ where t1.unique1 < i4.f1;
          ->  Seq Scan on public.int8_tbl i8
                Output: i8.q1, i8.q2
                Filter: (i8.q1 = ((64)::information_schema.cardinal_number)::integer)
-(33 rows)
+(35 rows)
 
 select ss1.d1 from
   tenk1 as t1
@@ -5234,6 +5270,7 @@ order by i0.f1, x;
                Output: i1.f1, i2.q1, i2.q2, '123'::bigint
                ->  Seq Scan on public.int4_tbl i1
                      Output: i1.f1
+                     Bloom Filter 1: keys=(i1.f1)
                ->  Materialize
                      Output: i2.q1, i2.q2
                      ->  Seq Scan on public.int8_tbl i2
@@ -5241,9 +5278,10 @@ order by i0.f1, x;
                            Filter: (123 = i2.q2)
          ->  Hash
                Output: i0.f1
+               Bloom Filter 1
                ->  Seq Scan on public.int4_tbl i0
                      Output: i0.f1
-(19 rows)
+(21 rows)
 
 select * from
 int4_tbl i0 left join
@@ -5297,8 +5335,10 @@ select t1.* from
                            Hash Cond: (i8.q1 = i8b2.q1)
                            ->  Seq Scan on public.int8_tbl i8
                                  Output: i8.q1, i8.q2
+                                 Bloom Filter 1: keys=(i8.q1)
                            ->  Hash
                                  Output: i8b2.q1, (NULL::integer)
+                                 Bloom Filter 1
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, NULL::integer
                      ->  Hash
@@ -5309,7 +5349,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(30 rows)
+(32 rows)
 
 select t1.* from
   text_tbl t1
@@ -5360,10 +5400,12 @@ select t1.* from
                                  Output: i8b2.q1, NULL::integer
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, i8b2.q2
+                                       Bloom Filter 1: keys=(i8b2.q1)
                                  ->  Materialize
                                        ->  Seq Scan on public.int4_tbl i4b2
                            ->  Hash
                                  Output: i8.q1, i8.q2
+                                 Bloom Filter 1
                                  ->  Seq Scan on public.int8_tbl i8
                                        Output: i8.q1, i8.q2
                      ->  Hash
@@ -5374,7 +5416,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(34 rows)
+(36 rows)
 
 select t1.* from
   text_tbl t1
@@ -5427,12 +5469,16 @@ select t1.* from
                                  Hash Cond: (i8b2.q1 = i4b2.f1)
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, i8b2.q2
+                                       Bloom Filter 1: keys=(i8b2.q1)
+                                       Bloom Filter 2: keys=(i8b2.q1)
                                  ->  Hash
                                        Output: i4b2.f1
+                                       Bloom Filter 1
                                        ->  Seq Scan on public.int4_tbl i4b2
                                              Output: i4b2.f1
                            ->  Hash
                                  Output: i8.q1, i8.q2
+                                 Bloom Filter 2
                                  ->  Seq Scan on public.int8_tbl i8
                                        Output: i8.q1, i8.q2
                      ->  Hash
@@ -5443,7 +5489,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(37 rows)
+(41 rows)
 
 select t1.* from
   text_tbl t1
@@ -5794,15 +5840,17 @@ where ss1.c2 = 0;
                            Filter: (i43.f1 = 0)
                ->  Seq Scan on public.int4_tbl i41
                      Output: i41.f1
+                     Bloom Filter 1: keys=(i41.f1)
          ->  Hash
                Output: i42.f1
+               Bloom Filter 1
                ->  Seq Scan on public.int4_tbl i42
                      Output: i42.f1
    ->  Limit
          Output: (i41.f1), (i8.q1), (i8.q2), (i42.f1), (i43.f1), ((42))
          ->  Seq Scan on public.text_tbl
                Output: i41.f1, i8.q1, i8.q2, i42.f1, i43.f1, (42)
-(25 rows)
+(27 rows)
 
 select ss2.* from
   int4_tbl i41
@@ -5934,12 +5982,14 @@ select a.unique1, b.unique2
    Hash Cond: (b.unique2 = a.unique1)
    ->  Seq Scan on onek b
          Filter: (ANY ((unique2 = (SubPlan any_1).col1) AND ((random() > '0'::double precision) = (SubPlan any_1).col2)))
+         Bloom Filter 1: keys=(unique2)
          SubPlan any_1
            ->  Seq Scan on int8_tbl c
                  Filter: (q1 < b.unique1)
    ->  Hash
+         Bloom Filter 1
          ->  Index Only Scan using onek_unique1 on onek a
-(9 rows)
+(11 rows)
 
 select a.unique1, b.unique2
   from onek a left join onek b on a.unique1 = b.unique2
@@ -6092,14 +6142,16 @@ explain (costs off)
 select id from a where id in (
 	select b.id from b left join c on b.id = c.id
 );
-         QUERY PLAN         
-----------------------------
+            QUERY PLAN             
+-----------------------------------
  Hash Join
    Hash Cond: (a.id = b.id)
    ->  Seq Scan on a
+         Bloom Filter 1: keys=(id)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on b
-(5 rows)
+(7 rows)
 
 -- check optimization with oddly-nested outer joins
 explain (costs off)
@@ -6522,16 +6574,18 @@ explain (costs off)
 select c.id, ss.a from c
   left join (select d.a from onerow, d left join b on d.a = b.id) ss
   on c.id = ss.a;
-           QUERY PLAN           
---------------------------------
+               QUERY PLAN               
+----------------------------------------
  Hash Right Join
    Hash Cond: (d.a = c.id)
    ->  Nested Loop
          ->  Seq Scan on onerow
          ->  Seq Scan on d
+               Bloom Filter 1: keys=(a)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on c
-(7 rows)
+(9 rows)
 
 -- check the case when the placeholder relates to an outer join and its
 -- inner in the press field but actually uses only the outer side of the join
@@ -6861,23 +6915,27 @@ where exists (select 1 from t t4
                      Hash Cond: (t6.b = t4.b)
                      ->  Seq Scan on pg_temp.t t6
                            Output: t6.a, t6.b
+                           Bloom Filter 2: keys=(t6.b)
                      ->  Hash
                            Output: t4.b, t5.b, t5.a
+                           Bloom Filter 2
                            ->  Hash Join
                                  Output: t4.b, t5.b, t5.a
                                  Inner Unique: true
                                  Hash Cond: (t5.b = t4.b)
                                  ->  Seq Scan on pg_temp.t t5
                                        Output: t5.a, t5.b
+                                       Bloom Filter 1: keys=(t5.b)
                                  ->  Hash
                                        Output: t4.b, t4.a
+                                       Bloom Filter 1
                                        ->  Index Scan using t_a_key on pg_temp.t t4
                                              Output: t4.b, t4.a
                                              Index Cond: (t4.a = 1)
    ->  Index Only Scan using t_a_key on pg_temp.t t3
          Output: t3.a
          Index Cond: (t3.a = t5.a)
-(32 rows)
+(36 rows)
 
 select t1.a from t t1
   left join t t2 on t1.a = t2.a
@@ -9063,13 +9121,15 @@ select * from
                                  Output: b.q1, COALESCE(b.q2, '42'::bigint)
                ->  Seq Scan on public.int8_tbl d
                      Output: d.q1, COALESCE((COALESCE(b.q2, '42'::bigint)), d.q2)
+                     Bloom Filter 1: keys=(d.q1)
          ->  Hash
                Output: c.q1, c.q2
+               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl c
                      Output: c.q1, c.q2
    ->  Result
          Output: (COALESCE((COALESCE(b.q2, '42'::bigint)), d.q2))
-(24 rows)
+(26 rows)
 
 -- another case requiring nested PlaceHolderVars
 explain (verbose, costs off)
@@ -9128,25 +9188,29 @@ select c.*,a.*,ss1.q1,ss2.q1,ss3.* from
                            Join Filter: (b.q1 < b2.f1)
                            ->  Seq Scan on public.int8_tbl b
                                  Output: b.q1, b.q2
+                                 Bloom Filter 1: keys=(b.q1)
                            ->  Materialize
                                  Output: b2.f1
                                  ->  Seq Scan on public.int4_tbl b2
                                        Output: b2.f1
                      ->  Hash
                            Output: a.q1, a.q2
+                           Bloom Filter 1
                            ->  Seq Scan on public.int8_tbl a
                                  Output: a.q1, a.q2
                ->  Seq Scan on public.int8_tbl d
                      Output: d.q1, COALESCE((COALESCE(b.q2, (b2.f1)::bigint)), d.q2)
+                     Bloom Filter 2: keys=(d.q1)
          ->  Hash
                Output: c.q1, c.q2
+               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl c
                      Output: c.q1, c.q2
    ->  Materialize
          Output: i.f1
          ->  Seq Scan on public.int4_tbl i
                Output: i.f1
-(34 rows)
+(38 rows)
 
 -- check processing of postponed quals (bug #9041)
 explain (verbose, costs off)
@@ -9453,8 +9517,10 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                Hash Cond: (t3.b = t2.a)
                ->  Seq Scan on public.join_ut1 t3
                      Output: t3.a, t3.b, t3.c
+                     Bloom Filter 1: keys=(t3.b)
                ->  Hash
                      Output: t2.a
+                     Bloom Filter 1
                      ->  Append
                            ->  Seq Scan on public.join_pt1p1p1 t2_1
                                  Output: t2_1.a
@@ -9462,7 +9528,7 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                            ->  Seq Scan on public.join_pt1p2 t2_2
                                  Output: t2_2.a
                                  Filter: (t1.a = t2_2.a)
-(21 rows)
+(23 rows)
 
 select t1.b, ss.phv from join_ut1 t1 left join lateral
               (select t2.a as t2a, t3.a t3a, least(t1.a, t2.a, t3.a) phv
@@ -9496,12 +9562,14 @@ select * from fkest f1
          Hash Cond: ((f2.x = f1.x) AND (f2.x10b = f1.x10))
          ->  Seq Scan on fkest f2
                Filter: (x100 = 2)
+               Bloom Filter 1: keys=(x, x10b)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on fkest f1
                      Filter: (x100 = 2)
    ->  Index Scan using fkest_x_x10_x100_idx on fkest f3
          Index Cond: (x = f1.x)
-(10 rows)
+(12 rows)
 
 alter table fkest add constraint fk
   foreign key (x, x10b, x100) references fkest (x, x10, x100);
@@ -9517,13 +9585,15 @@ select * from fkest f1
    ->  Hash Join
          Hash Cond: (f3.x = f2.x)
          ->  Seq Scan on fkest f3
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on fkest f2
                      Filter: (x100 = 2)
    ->  Hash
          ->  Seq Scan on fkest f1
                Filter: (x100 = 2)
-(11 rows)
+(13 rows)
 
 rollback;
 --
@@ -9576,19 +9646,21 @@ analyze j3;
 -- ensure join is properly marked as unique
 explain (verbose, costs off)
 select * from j1 inner join j2 on j1.id = j2.id;
-            QUERY PLAN             
------------------------------------
+              QUERY PLAN              
+--------------------------------------
  Hash Join
    Output: j1.id, j2.id
    Inner Unique: true
    Hash Cond: (j1.id = j2.id)
    ->  Seq Scan on public.j1
          Output: j1.id
+         Bloom Filter 1: keys=(j1.id)
    ->  Hash
          Output: j2.id
+         Bloom Filter 1
          ->  Seq Scan on public.j2
                Output: j2.id
-(10 rows)
+(12 rows)
 
 -- ensure join is not unique when not an equi-join
 explain (verbose, costs off)
@@ -9609,19 +9681,21 @@ select * from j1 inner join j2 on j1.id > j2.id;
 -- ensure non-unique rel is not chosen as inner
 explain (verbose, costs off)
 select * from j1 inner join j3 on j1.id = j3.id;
-            QUERY PLAN             
------------------------------------
+              QUERY PLAN              
+--------------------------------------
  Hash Join
    Output: j1.id, j3.id
    Inner Unique: true
    Hash Cond: (j3.id = j1.id)
    ->  Seq Scan on public.j3
          Output: j3.id
+         Bloom Filter 1: keys=(j3.id)
    ->  Hash
          Output: j1.id
+         Bloom Filter 1
          ->  Seq Scan on public.j1
                Output: j1.id
-(10 rows)
+(12 rows)
 
 -- ensure left join is marked as unique
 explain (verbose, costs off)
@@ -9692,19 +9766,21 @@ select * from j1 cross join j2;
 -- ensure a natural join is marked as unique
 explain (verbose, costs off)
 select * from j1 natural join j2;
-            QUERY PLAN             
------------------------------------
+              QUERY PLAN              
+--------------------------------------
  Hash Join
    Output: j1.id
    Inner Unique: true
    Hash Cond: (j1.id = j2.id)
    ->  Seq Scan on public.j1
          Output: j1.id
+         Bloom Filter 1: keys=(j1.id)
    ->  Hash
          Output: j2.id
+         Bloom Filter 1
          ->  Seq Scan on public.j2
                Output: j2.id
-(10 rows)
+(12 rows)
 
 -- ensure a distinct clause allows the inner to become unique
 explain (verbose, costs off)
diff --git a/src/test/regress/expected/join_hash.out b/src/test/regress/expected/join_hash.out
index 75009e29720..0a8ade8b961 100644
--- a/src/test/regress/expected/join_hash.out
+++ b/src/test/regress/expected/join_hash.out
@@ -90,15 +90,17 @@ set local work_mem = '4MB';
 set local hash_mem_multiplier = 1.0;
 explain (costs off)
   select count(*) from simple r join simple s using (id);
-               QUERY PLAN               
-----------------------------------------
+               QUERY PLAN                
+-----------------------------------------
  Aggregate
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
+               Bloom Filter 1: keys=(id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on simple s
-(6 rows)
+(8 rows)
 
 select count(*) from simple r join simple s using (id);
  count 
@@ -203,15 +205,17 @@ set local work_mem = '128kB';
 set local hash_mem_multiplier = 1.0;
 explain (costs off)
   select count(*) from simple r join simple s using (id);
-               QUERY PLAN               
-----------------------------------------
+               QUERY PLAN                
+-----------------------------------------
  Aggregate
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
+               Bloom Filter 1: keys=(id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on simple s
-(6 rows)
+(8 rows)
 
 select count(*) from simple r join simple s using (id);
  count 
@@ -330,9 +334,11 @@ explain (costs off)
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
+               Bloom Filter 1: keys=(id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on bigger_than_it_looks s
-(6 rows)
+(8 rows)
 
 select count(*) FROM simple r JOIN bigger_than_it_looks s USING (id);
  count 
@@ -445,9 +451,11 @@ explain (costs off)
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
+               Bloom Filter 1: keys=(id)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on extremely_skewed s
-(6 rows)
+(8 rows)
 
 select count(*) from simple r join extremely_skewed s using (id);
  count 
@@ -1149,9 +1157,11 @@ lateral (select t1.fivethous, i4.f1 from tenk1 t1 join int4_tbl i4
          ->  Hash Join
                Hash Cond: (t1.fivethous = (i4.f1 + i8.q2))
                ->  Seq Scan on tenk1 t1
+                     Bloom Filter 1: keys=(fivethous)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on int4_tbl i4
-(9 rows)
+(11 rows)
 
 select i8.q2, ss.* from
 int8_tbl i8,
diff --git a/src/test/regress/expected/merge.out b/src/test/regress/expected/merge.out
index 9cb1d87066a..c5aa11cd249 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -323,15 +323,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	UPDATE SET balance = 0;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
    ->  Hash Join
          Hash Cond: (s.sid = t.tid)
          ->  Seq Scan on source s
+               Bloom Filter 1: keys=(sid)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on target t
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
@@ -339,15 +341,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	DELETE;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
    ->  Hash Join
          Hash Cond: (s.sid = t.tid)
          ->  Seq Scan on source s
+               Bloom Filter 1: keys=(sid)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on target t
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
@@ -1831,8 +1835,10 @@ WHEN MATCHED AND t.c > s.cnt THEN
          Join Filter: (t.b < (SubPlan expr_1))
          ->  Seq Scan on public.tgt t
                Output: t.ctid, t.a, t.b
+               Bloom Filter 1: keys=(t.a)
          ->  Hash
                Output: s.a, s.b, s.c, s.d, s.ctid
+               Bloom Filter 1
                ->  Seq Scan on public.src s
                      Output: s.a, s.b, s.c, s.d, s.ctid
          SubPlan expr_1
@@ -1856,7 +1862,7 @@ WHEN MATCHED AND t.c > s.cnt THEN
                    ->  Seq Scan on public.ref r_1
                          Output: r_1.ab, r_1.cd
                          Filter: ((r_1.ab = (s.a + s.b)) AND (r_1.cd = (s.c - s.d)))
-(32 rows)
+(34 rows)
 
 DROP TABLE src, tgt, ref;
 -- Subqueries
diff --git a/src/test/regress/expected/misc_functions.out b/src/test/regress/expected/misc_functions.out
index c3261bff209..b52528870ef 100644
--- a/src/test/regress/expected/misc_functions.out
+++ b/src/test/regress/expected/misc_functions.out
@@ -582,10 +582,12 @@ WHERE my_int_eq(a.unique2, 42);
  Hash Join
    Hash Cond: (b.unique1 = a.unique1)
    ->  Seq Scan on tenk1 b
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 a
                Filter: my_int_eq(unique2, 42)
-(6 rows)
+(8 rows)
 
 -- With support function that knows it's int4eq, we get a different plan
 CREATE FUNCTION test_support_func(internal)
diff --git a/src/test/regress/expected/partition_aggregate.out b/src/test/regress/expected/partition_aggregate.out
index c30304b99c7..be56036461b 100644
--- a/src/test/regress/expected/partition_aggregate.out
+++ b/src/test/regress/expected/partition_aggregate.out
@@ -460,23 +460,29 @@ SELECT t1.x, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                ->  Hash Join
                      Hash Cond: (t1.x = t2.y)
                      ->  Seq Scan on pagg_tab1_p1 t1
+                           Bloom Filter 1: keys=(x)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 t2
          ->  HashAggregate
                Group Key: t1_1.x
                ->  Hash Join
                      Hash Cond: (t1_1.x = t2_1.y)
                      ->  Seq Scan on pagg_tab1_p2 t1_1
+                           Bloom Filter 2: keys=(x)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 t2_1
          ->  HashAggregate
                Group Key: t1_2.x
                ->  Hash Join
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on pagg_tab2_p3 t2_2
+                           Bloom Filter 3: keys=(y)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on pagg_tab1_p3 t1_2
-(24 rows)
+(30 rows)
 
 SELECT t1.x, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2.y GROUP BY t1.x ORDER BY 1, 2, 3;
  x  | sum  | count 
@@ -533,23 +539,29 @@ SELECT t2.y, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                ->  Hash Join
                      Hash Cond: (t1.x = t2.y)
                      ->  Seq Scan on pagg_tab1_p1 t1
+                           Bloom Filter 1: keys=(x)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 t2
          ->  HashAggregate
                Group Key: t2_1.y
                ->  Hash Join
                      Hash Cond: (t1_1.x = t2_1.y)
                      ->  Seq Scan on pagg_tab1_p2 t1_1
+                           Bloom Filter 2: keys=(x)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 t2_1
          ->  HashAggregate
                Group Key: t2_2.y
                ->  Hash Join
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on pagg_tab2_p3 t2_2
+                           Bloom Filter 3: keys=(y)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on pagg_tab1_p3 t1_2
-(24 rows)
+(30 rows)
 
 -- When GROUP BY clause does not match; partial aggregation is performed for each partition.
 -- Also test GroupAggregate paths by disabling hash aggregates.
@@ -572,7 +584,9 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t1.x = t2.y)
                                  ->  Seq Scan on pagg_tab1_p1 t1
+                                       Bloom Filter 1: keys=(x)
                                  ->  Hash
+                                       Bloom Filter 1
                                        ->  Seq Scan on pagg_tab2_p1 t2
                ->  Partial GroupAggregate
                      Group Key: t1_1.y
@@ -581,7 +595,9 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t1_1.x = t2_1.y)
                                  ->  Seq Scan on pagg_tab1_p2 t1_1
+                                       Bloom Filter 2: keys=(x)
                                  ->  Hash
+                                       Bloom Filter 2
                                        ->  Seq Scan on pagg_tab2_p2 t2_1
                ->  Partial GroupAggregate
                      Group Key: t1_2.y
@@ -590,9 +606,11 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t2_2.y = t1_2.x)
                                  ->  Seq Scan on pagg_tab2_p3 t2_2
+                                       Bloom Filter 3: keys=(y)
                                  ->  Hash
+                                       Bloom Filter 3
                                        ->  Seq Scan on pagg_tab1_p3 t1_2
-(34 rows)
+(40 rows)
 
 SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2.y GROUP BY t1.y HAVING avg(t1.x) > 10 ORDER BY 1, 2, 3;
  y  | sum  | count 
@@ -638,9 +656,11 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP B
                      ->  Hash Right Join
                            Hash Cond: (b_2.y = a_2.x)
                            ->  Seq Scan on pagg_tab2_p3 b_2
+                                 Bloom Filter 1: keys=(y)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on pagg_tab1_p3 a_2
-(26 rows)
+(28 rows)
 
 SELECT b.y, sum(a.y) FROM pagg_tab1 a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP BY b.y ORDER BY 1 NULLS LAST;
  y  | sum  
@@ -667,14 +687,18 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP
                ->  Hash Right Join
                      Hash Cond: (a.x = b.y)
                      ->  Seq Scan on pagg_tab1_p1 a
+                           Bloom Filter 1: keys=(x)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 b
          ->  HashAggregate
                Group Key: b_1.y
                ->  Hash Right Join
                      Hash Cond: (a_1.x = b_1.y)
                      ->  Seq Scan on pagg_tab1_p2 a_1
+                           Bloom Filter 2: keys=(x)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 b_1
          ->  HashAggregate
                Group Key: b_2.y
@@ -683,7 +707,7 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP
                      ->  Seq Scan on pagg_tab2_p3 b_2
                      ->  Hash
                            ->  Seq Scan on pagg_tab1_p3 a_2
-(24 rows)
+(28 rows)
 
 SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP BY b.y ORDER BY 1 NULLS LAST;
  y  | sum  
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 38643d41fd7..1906b3641a3 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -36,22 +36,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -77,22 +83,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a =
          ->  Hash Join
                Hash Cond: (t1_1.a = t2_1.a)
                ->  Seq Scan on prt1_p1 t1_1
+                     Bloom Filter 1: keys=(a)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = b)
          ->  Hash Join
                Hash Cond: (t1_2.a = t2_2.a)
                ->  Seq Scan on prt1_p2 t1_2
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = b)
          ->  Hash Join
                Hash Cond: (t1_3.a = t2_3.a)
                ->  Seq Scan on prt1_p3 t1_3
+                     Bloom Filter 3: keys=(a)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt2_p3 t2_3
                            Filter: (a = b)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -202,13 +214,17 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHE
          ->  Hash Right Join
                Hash Cond: (t1_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t1_1
+                     Bloom Filter 1: keys=(a)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = 0)
          ->  Hash Right Join
                Hash Cond: (t1_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t1_2
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = 0)
          ->  Nested Loop Left Join
@@ -216,7 +232,7 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHE
                      Filter: (a = 0)
                ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
                      Index Cond: (a = t2_3.b)
-(20 rows)
+(24 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -283,10 +299,12 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.a <
          Hash Cond: (t2.b = t1.a)
          ->  Seq Scan on prt2_p2 t2
                Filter: (b > 250)
+               Bloom Filter 1: keys=(b)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on prt1_p2 t1
                      Filter: ((a < 450) AND (b = 0))
-(9 rows)
+(11 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.a < 450 AND t2.b > 250 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -382,14 +400,18 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0)
                Hash Cond: (t1_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t1_1
                      Filter: (b = 0)
+                     Bloom Filter 1: keys=(a)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = 0)
          ->  Hash Semi Join
                Hash Cond: (t1_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t1_2
                      Filter: (b = 0)
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = 0)
          ->  Nested Loop Semi Join
@@ -399,7 +421,7 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0)
                ->  Materialize
                      ->  Seq Scan on prt2_p3 t2_3
                            Filter: (a = 0)
-(24 rows)
+(28 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -515,19 +537,25 @@ SELECT t1.a, ss.t2a, ss.t2c FROM prt1 t1 LEFT JOIN LATERAL
                      ->  Hash Join
                            Hash Cond: (t2_1.a = t3_1.b)
                            ->  Seq Scan on prt1_p1 t2_1
+                                 Bloom Filter 1: keys=(a)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on prt2_p1 t3_1
                      ->  Hash Join
                            Hash Cond: (t2_2.a = t3_2.b)
                            ->  Seq Scan on prt1_p2 t2_2
+                                 Bloom Filter 2: keys=(a)
                            ->  Hash
+                                 Bloom Filter 2
                                  ->  Seq Scan on prt2_p2 t3_2
                      ->  Hash Join
                            Hash Cond: (t2_3.a = t3_3.b)
                            ->  Seq Scan on prt1_p3 t2_3
+                                 Bloom Filter 3: keys=(a)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on prt2_p3 t3_3
-(26 rows)
+(32 rows)
 
 SELECT t1.a, ss.t2a, ss.t2c FROM prt1 t1 LEFT JOIN LATERAL
 			  (SELECT t2.a AS t2a, t3.a AS t3a, t2.b t2b, t2.c t2c, least(t1.a,t2.a,t3.a) FROM prt1 t2 JOIN prt2 t3 ON (t2.a = t3.b)) ss
@@ -728,29 +756,41 @@ SELECT * FROM prt1 t1 JOIN prt1 t2 ON t1.a = t2.a WHERE t1.a IN (SELECT a FROM p
          ->  Hash Join
                Hash Cond: (t1_1.a = t2_1.a)
                ->  Seq Scan on prt1_p1 t1_1
+                     Bloom Filter 1: keys=(a)
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t2_1
          ->  Hash
+               Bloom Filter 2
                ->  Seq Scan on prt1_p1 t3_1
    ->  Hash Semi Join
          Hash Cond: (t1_2.a = t3_2.a)
          ->  Hash Join
                Hash Cond: (t1_2.a = t2_2.a)
                ->  Seq Scan on prt1_p2 t1_2
+                     Bloom Filter 3: keys=(a)
+                     Bloom Filter 4: keys=(a)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_p2 t2_2
          ->  Hash
+               Bloom Filter 4
                ->  Seq Scan on prt1_p2 t3_2
    ->  Hash Semi Join
          Hash Cond: (t1_3.a = t3_3.a)
          ->  Hash Join
                Hash Cond: (t1_3.a = t2_3.a)
                ->  Seq Scan on prt1_p3 t1_3
+                     Bloom Filter 5: keys=(a)
+                     Bloom Filter 6: keys=(a)
                ->  Hash
+                     Bloom Filter 5
                      ->  Seq Scan on prt1_p3 t2_3
          ->  Hash
+               Bloom Filter 6
                ->  Seq Scan on prt1_p3 t3_3
-(28 rows)
+(40 rows)
 
 --
 -- partitioned by expression
@@ -821,7 +861,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_1.b = t1_1.a)
                      ->  Seq Scan on prt2_p1 t2_1
+                           Bloom Filter 1: keys=(b)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on prt1_p1 t1_1
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p1_ab2 on prt1_e_p1 t3_1
@@ -831,7 +873,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_2.b = t1_2.a)
                      ->  Seq Scan on prt2_p2 t2_2
+                           Bloom Filter 2: keys=(b)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on prt1_p2 t1_2
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p2_ab2 on prt1_e_p2 t3_2
@@ -841,12 +885,14 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_3.b = t1_3.a)
                      ->  Seq Scan on prt2_p3 t2_3
+                           Bloom Filter 3: keys=(b)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on prt1_p3 t1_3
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p3_ab2 on prt1_e_p3 t3_3
                      Index Cond: (((a + b) / 2) = t2_3.b)
-(33 rows)
+(39 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t3 WHERE t1.a = t2.b AND t1.a = (t3.a + t3.b)/2 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -871,7 +917,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_1.b = t1_1.a)
                            ->  Seq Scan on prt2_p1 t2_1
+                                 Bloom Filter 1: keys=(b)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_p1 t1_1
                                        Filter: (b = 0)
          ->  Hash Right Join
@@ -881,7 +929,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_2.b = t1_2.a)
                            ->  Seq Scan on prt2_p2 t2_2
+                                 Bloom Filter 2: keys=(b)
                            ->  Hash
+                                 Bloom Filter 2
                                  ->  Seq Scan on prt1_p2 t1_2
                                        Filter: (b = 0)
          ->  Hash Right Join
@@ -891,10 +941,12 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_3.b = t1_3.a)
                            ->  Seq Scan on prt2_p3 t2_3
+                                 Bloom Filter 3: keys=(b)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_p3 t1_3
                                        Filter: (b = 0)
-(33 rows)
+(39 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) LEFT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -924,7 +976,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_1.a = ((t3_1.a + t3_1.b) / 2))
                      ->  Seq Scan on prt1_p1 t1_1
+                           Bloom Filter 1: keys=(a)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on prt1_e_p1 t3_1
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p1_b on prt2_p1 t2_1
@@ -933,7 +987,9 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_2.a = ((t3_2.a + t3_2.b) / 2))
                      ->  Seq Scan on prt1_p2 t1_2
+                           Bloom Filter 2: keys=(a)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on prt1_e_p2 t3_2
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p2_b on prt2_p2 t2_2
@@ -942,12 +998,14 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_3.a = ((t3_3.a + t3_3.b) / 2))
                      ->  Seq Scan on prt1_p3 t1_3
+                           Bloom Filter 3: keys=(a)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on prt1_e_p3 t3_3
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
                      Index Cond: (b = t1_3.a)
-(30 rows)
+(36 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) RIGHT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t3.c = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -1205,7 +1263,9 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_6.b = ((t1_9.a + t1_9.b) / 2))
                            ->  Seq Scan on prt2_p1 t1_6
+                                 Bloom Filter 1: keys=(b)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_e_p1 t1_9
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p1_a on prt1_p1 t1_3
@@ -1218,7 +1278,9 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_7.b = ((t1_10.a + t1_10.b) / 2))
                            ->  Seq Scan on prt2_p2 t1_7
+                                 Bloom Filter 2: keys=(b)
                            ->  Hash
+                                 Bloom Filter 2
                                  ->  Seq Scan on prt1_e_p2 t1_10
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p2_a on prt1_p2 t1_4
@@ -1231,13 +1293,15 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_8.b = ((t1_11.a + t1_11.b) / 2))
                            ->  Seq Scan on prt2_p3 t1_8
+                                 Bloom Filter 3: keys=(b)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_e_p3 t1_11
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p3_a on prt1_p3 t1_5
                Index Cond: (a = t1_8.b)
                Filter: (b = 0)
-(41 rows)
+(47 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -1567,29 +1631,41 @@ SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM plt1 t1, pl
                      ->  Hash Join
                            Hash Cond: ((t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                            ->  Seq Scan on plt1_p1 t1_1
+                                 Bloom Filter 1: keys=(b, c)
+                                 Bloom Filter 2: keys=(c)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on plt2_p1 t2_1
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on plt1_e_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.c = ltrim(t3_2.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                            ->  Seq Scan on plt1_p2 t1_2
+                                 Bloom Filter 3: keys=(b, c)
+                                 Bloom Filter 4: keys=(c)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on plt2_p2 t2_2
                      ->  Hash
+                           Bloom Filter 4
                            ->  Seq Scan on plt1_e_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.c = ltrim(t3_3.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_3.b = t2_3.b) AND (t1_3.c = t2_3.c))
                            ->  Seq Scan on plt1_p3 t1_3
+                                 Bloom Filter 5: keys=(b, c)
+                                 Bloom Filter 6: keys=(c)
                            ->  Hash
+                                 Bloom Filter 5
                                  ->  Seq Scan on plt2_p3 t2_3
                      ->  Hash
+                           Bloom Filter 6
                            ->  Seq Scan on plt1_e_p3 t3_3
-(32 rows)
+(44 rows)
 
 SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM plt1 t1, plt2 t2, plt1_e t3 WHERE t1.b = t2.b AND t1.c = t2.c AND ltrim(t3.c, 'A') = t1.c GROUP BY t1.c, t2.c, t3.c ORDER BY t1.c, t2.c, t3.c;
          avg          |         avg          |          avg          |  c   |  c   |   c   
@@ -1637,23 +1713,29 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
          ->  Hash Join
                Hash Cond: (t3_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t3_1
+                     Bloom Filter 1: keys=(a)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
          ->  Hash Join
                Hash Cond: (t3_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t3_2
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
          ->  Hash Join
                Hash Cond: (t3_3.a = t2_3.b)
                ->  Seq Scan on prt1_p3 t3_3
+                     Bloom Filter 3: keys=(a)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt2_p3 t2_3
    ->  Hash
          ->  Result
                Replaces: Scan on prt1
                One-Time Filter: false
-(22 rows)
+(28 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 FULL JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
@@ -1715,29 +1797,41 @@ SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM pht1 t1, ph
                      ->  Hash Join
                            Hash Cond: ((t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                            ->  Seq Scan on pht1_p1 t1_1
+                                 Bloom Filter 1: keys=(b, c)
+                                 Bloom Filter 2: keys=(c)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on pht2_p1 t2_1
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on pht1_e_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.c = ltrim(t3_2.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                            ->  Seq Scan on pht1_p2 t1_2
+                                 Bloom Filter 3: keys=(b, c)
+                                 Bloom Filter 4: keys=(c)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on pht2_p2 t2_2
                      ->  Hash
+                           Bloom Filter 4
                            ->  Seq Scan on pht1_e_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.c = ltrim(t3_3.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_3.b = t2_3.b) AND (t1_3.c = t2_3.c))
                            ->  Seq Scan on pht1_p3 t1_3
+                                 Bloom Filter 5: keys=(b, c)
+                                 Bloom Filter 6: keys=(c)
                            ->  Hash
+                                 Bloom Filter 5
                                  ->  Seq Scan on pht2_p3 t2_3
                      ->  Hash
+                           Bloom Filter 6
                            ->  Seq Scan on pht1_e_p3 t3_3
-(32 rows)
+(44 rows)
 
 SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM pht1 t1, pht2 t2, pht1_e t3 WHERE t1.b = t2.b AND t1.c = t2.c AND ltrim(t3.c, 'A') = t1.c GROUP BY t1.c, t2.c, t3.c ORDER BY t1.c, t2.c, t3.c;
          avg          |         avg          |         avg          |  c   |  c   |   c   
@@ -1767,22 +1861,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 -- test default partition behavior for list
 ALTER TABLE plt1 DETACH PARTITION plt1_p3;
@@ -1803,22 +1903,28 @@ SELECT avg(t1.a), avg(t2.b), t1.c, t2.c FROM plt1 t1 RIGHT JOIN plt2 t2 ON t1.c
                ->  Hash Join
                      Hash Cond: (t2_1.c = t1_1.c)
                      ->  Seq Scan on plt2_p1 t2_1
+                           Bloom Filter 1: keys=(c)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on plt1_p1 t1_1
                                  Filter: ((a % 25) = 0)
                ->  Hash Join
                      Hash Cond: (t2_2.c = t1_2.c)
                      ->  Seq Scan on plt2_p2 t2_2
+                           Bloom Filter 2: keys=(c)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on plt1_p2 t1_2
                                  Filter: ((a % 25) = 0)
                ->  Hash Join
                      Hash Cond: (t2_3.c = t1_3.c)
                      ->  Seq Scan on plt2_p3 t2_3
+                           Bloom Filter 3: keys=(c)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on plt1_p3 t1_3
                                  Filter: ((a % 25) = 0)
-(23 rows)
+(29 rows)
 
 --
 -- multiple levels of partitioning
@@ -1854,7 +1960,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_l_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_l_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
@@ -1876,7 +1984,7 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1
                ->  Hash
                      ->  Seq Scan on prt1_l_p3_p1 t1_5
                            Filter: (b = 0)
-(28 rows)
+(30 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2355,19 +2463,25 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt4_n t2, prt2 t3 WHERE t1.a = t2.a
                ->  Hash Join
                      Hash Cond: (t1_1.a = t3_1.b)
                      ->  Seq Scan on prt1_p1 t1_1
+                           Bloom Filter 1: keys=(a)
                      ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on prt2_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.a = t3_2.b)
                      ->  Seq Scan on prt1_p2 t1_2
+                           Bloom Filter 2: keys=(a)
                      ->  Hash
+                           Bloom Filter 2
                            ->  Seq Scan on prt2_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.a = t3_3.b)
                      ->  Seq Scan on prt1_p3 t1_3
+                           Bloom Filter 3: keys=(a)
                      ->  Hash
+                           Bloom Filter 3
                            ->  Seq Scan on prt2_p3 t3_3
-(23 rows)
+(29 rows)
 
 -- partitionwise join can not be applied if there are no equi-join conditions
 -- between partition keys
@@ -2639,22 +2753,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2680,22 +2800,28 @@ SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -2721,22 +2847,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2766,22 +2898,28 @@ SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -2848,22 +2986,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2889,22 +3033,28 @@ SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -2930,22 +3080,28 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3001,22 +3157,28 @@ SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(b)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -3102,24 +3264,32 @@ SELECT t1.b, t1.c, t2.a, t2.c, t3.a, t3.c FROM prt2_adv t1 LEFT JOIN prt1_adv t2
          ->  Hash Right Join
                Hash Cond: (t2_2.a = t1_2.b)
                ->  Seq Scan on prt1_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Hash Join
                            Hash Cond: (t3_2.a = t1_2.b)
                            ->  Seq Scan on prt1_adv_p2 t3_2
+                                 Bloom Filter 1: keys=(a)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on prt2_adv_p2 t1_2
                                        Filter: (a = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.a = t1_3.b)
                ->  Seq Scan on prt1_adv_p3 t2_3
+                     Bloom Filter 4: keys=(a)
                ->  Hash
+                     Bloom Filter 4
                      ->  Hash Join
                            Hash Cond: (t3_3.a = t1_3.b)
                            ->  Seq Scan on prt1_adv_p3 t3_3
+                                 Bloom Filter 3: keys=(a)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on prt2_adv_p3 t1_3
                                        Filter: (a = 0)
-(31 rows)
+(39 rows)
 
 SELECT t1.b, t1.c, t2.a, t2.c, t3.a, t3.c FROM prt2_adv t1 LEFT JOIN prt1_adv t2 ON (t1.b = t2.a) INNER JOIN prt1_adv t3 ON (t1.b = t3.a) WHERE t1.a = 0 ORDER BY t1.b, t2.a, t3.a;
   b  |  c   |  a  |  c   |  a  |  c   
@@ -3289,16 +3459,20 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_1
                            Filter: (b = 0)
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3390,24 +3564,32 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a, t3.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2
          ->  Hash Right Join
                Hash Cond: (t3_1.a = t1_1.a)
                ->  Seq Scan on prt3_adv_p1 t3_1
+                     Bloom Filter 2: keys=(a)
                ->  Hash
+                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: (t2_2.b = t1_1.a)
                            ->  Seq Scan on prt2_adv_p2 t2_2
+                                 Bloom Filter 1: keys=(b)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_adv_p2 t1_1
                                        Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t3_2.a = t1_2.a)
                ->  Seq Scan on prt3_adv_p2 t3_2
+                     Bloom Filter 4: keys=(a)
                ->  Hash
+                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: (t2_1.b = t1_2.a)
                            ->  Seq Scan on prt2_adv_p1 t2_1
+                                 Bloom Filter 3: keys=(b)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_adv_p1 t1_2
                                        Filter: (b = 0)
-(23 rows)
+(31 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a, t3.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) LEFT JOIN prt3_adv t3 ON (t1.a = t3.a) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a;
   a  |  c   |  b  |  c   |  a  |  c   
@@ -3449,16 +3631,20 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: ((a < 300) AND (b = 0))
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: ((a < 300) AND (b = 0))
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.a < 300 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3488,16 +3674,20 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: ((a >= 100) AND (a < 300) AND (b = 0))
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: ((a >= 100) AND (a < 300) AND (b = 0))
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.a >= 100 AND t1.a < 300 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3538,22 +3728,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3575,22 +3771,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -3612,22 +3814,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3651,22 +3859,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -3731,22 +3945,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3768,22 +3988,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -3805,22 +4031,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3870,22 +4102,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -4098,22 +4336,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4135,22 +4379,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -4172,22 +4422,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   
@@ -4212,22 +4468,28 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a  | b  |  c   
@@ -4303,22 +4565,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4394,22 +4662,28 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(21 rows)
+(27 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4431,19 +4705,25 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
+                     Bloom Filter 3: keys=(a, c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
          ->  Nested Loop Left Join
@@ -4451,7 +4731,7 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
                ->  Seq Scan on plt1_adv_extra t1_4
                      Filter: (b < 10)
                ->  Seq Scan on plt2_adv_extra t2_4
-(26 rows)
+(32 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   
@@ -4525,31 +4805,43 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
          ->  Hash Right Join
                Hash Cond: ((t3_1.a = t1_1.a) AND (t3_1.c = t1_1.c))
                ->  Seq Scan on plt1_adv_p1 t3_1
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                            ->  Seq Scan on plt2_adv_p1 t2_1
+                                 Bloom Filter 1: keys=(a, c)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on plt1_adv_p1 t1_1
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_2.a = t1_2.a) AND (t3_2.c = t1_2.c))
                ->  Seq Scan on plt1_adv_p2 t3_2
+                     Bloom Filter 4: keys=(a, c)
                ->  Hash
+                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                            ->  Seq Scan on plt2_adv_p2 t2_2
+                                 Bloom Filter 3: keys=(a, c)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on plt1_adv_p2 t1_2
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_3.a = t1_3.a) AND (t3_3.c = t1_3.c))
                ->  Seq Scan on plt1_adv_p3 t3_3
+                     Bloom Filter 6: keys=(a, c)
                ->  Hash
+                     Bloom Filter 6
                      ->  Hash Right Join
                            Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                            ->  Seq Scan on plt2_adv_p3 t2_3
+                                 Bloom Filter 5: keys=(a, c)
                            ->  Hash
+                                 Bloom Filter 5
                                  ->  Seq Scan on plt1_adv_p3 t1_3
                                        Filter: (b < 10)
          ->  Nested Loop Left Join
@@ -4560,7 +4852,7 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
                            Filter: (b < 10)
                      ->  Seq Scan on plt2_adv_extra t2_4
                ->  Seq Scan on plt1_adv_extra t3_4
-(41 rows)
+(53 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) LEFT JOIN plt1_adv t3 ON (t1.a = t3.a AND t1.c = t3.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   | a |  c   
@@ -4596,16 +4888,20 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_1
                            Filter: (b < 10)
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4687,24 +4983,32 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
          ->  Hash Right Join
                Hash Cond: ((t3_1.a = t1_1.a) AND (t3_1.c = t1_1.c))
                ->  Seq Scan on plt3_adv_p1 t3_1
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                            ->  Seq Scan on plt2_adv_p2 t2_2
+                                 Bloom Filter 1: keys=(a, c)
                            ->  Hash
+                                 Bloom Filter 1
                                  ->  Seq Scan on plt1_adv_p2 t1_1
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_2.a = t1_2.a) AND (t3_2.c = t1_2.c))
                ->  Seq Scan on plt3_adv_p2 t3_2
+                     Bloom Filter 4: keys=(a, c)
                ->  Hash
+                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                            ->  Seq Scan on plt2_adv_p1 t2_1
+                                 Bloom Filter 3: keys=(a, c)
                            ->  Hash
+                                 Bloom Filter 3
                                  ->  Seq Scan on plt1_adv_p1 t1_2
                                        Filter: (b < 10)
-(23 rows)
+(31 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) LEFT JOIN plt3_adv t3 ON (t1.a = t3.a AND t1.c = t3.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   | a |  c   
@@ -4733,16 +5037,20 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p1_null t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_1
                            Filter: (b < 10)
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4767,10 +5075,12 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p2 t2
+               Bloom Filter 1: keys=(a, c)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p2 t1
                      Filter: (b < 10)
-(8 rows)
+(10 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4809,16 +5119,20 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p3 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p3 t1_1
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p4 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p4 t1_2
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IN ('0003', '0004', '0005') AND t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4837,10 +5151,12 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Right Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p4 t2
+               Bloom Filter 1: keys=(a, c)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p4 t1
                      Filter: ((c IS NULL) AND (b < 10))
-(8 rows)
+(10 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IS NULL AND t1.b < 10 ORDER BY t1.a;
  a  | c | a | c 
@@ -4862,16 +5178,20 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p3 t2_1
+                     Bloom Filter 1: keys=(a, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p3 t1_1
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p4 t2_2
+                     Bloom Filter 2: keys=(a, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p4 t1_2
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
-(15 rows)
+(19 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IN ('0003', '0004', '0005') AND t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4890,10 +5210,12 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Right Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p4 t2
+               Bloom Filter 1: keys=(a, c)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p4 t1
                      Filter: ((c IS NULL) AND (b < 10))
-(8 rows)
+(10 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IS NULL AND t1.b < 10 ORDER BY t1.a;
  a  | c | a | c 
@@ -5015,12 +5337,16 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                Hash Cond: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b))
                ->  Seq Scan on alpha_neg_p1 t1_1
                      Filter: ((b >= 125) AND (b < 225))
+                     Bloom Filter 1: keys=(a, b)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on beta_neg_p1 t2_1
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.b = t1_2.b))
                ->  Seq Scan on beta_neg_p2 t2_2
+                     Bloom Filter 2: keys=(a, b)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on alpha_neg_p2 t1_2
                            Filter: ((b >= 125) AND (b < 225))
          ->  Hash Join
@@ -5037,7 +5363,7 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                                  Filter: ((b >= 125) AND (b < 225))
                            ->  Seq Scan on alpha_pos_p3 t1_6
                                  Filter: ((b >= 125) AND (b < 225))
-(29 rows)
+(33 rows)
 
 SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2.b) WHERE t1.b >= 125 AND t1.b < 225 ORDER BY t1.a, t1.b;
  a  |  b  |  c   | a  |  b  |  c   
@@ -5150,14 +5476,18 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                Hash Cond: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                ->  Seq Scan on alpha_neg_p1 t1_1
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
+                     Bloom Filter 1: keys=(a, b, c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on beta_neg_p1 t2_1
                            Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
          ->  Hash Join
                Hash Cond: ((t1_2.a = t2_2.a) AND (t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                ->  Seq Scan on alpha_neg_p2 t1_2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
+                     Bloom Filter 2: keys=(a, b, c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on beta_neg_p2 t2_2
                            Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
          ->  Nested Loop
@@ -5172,7 +5502,7 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
                ->  Seq Scan on beta_pos_p3 t2_4
                      Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
-(29 rows)
+(33 rows)
 
 SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2.b AND t1.c = t2.c) WHERE ((t1.b >= 100 AND t1.b < 110) OR (t1.b >= 200 AND t1.b < 210)) AND ((t2.b >= 100 AND t2.b < 110) OR (t2.b >= 200 AND t2.b < 210)) AND t1.c IN ('0004', '0009') ORDER BY t1.a, t1.b;
  a  |  b  |  c   | a  |  b  |  c   
@@ -5316,19 +5646,25 @@ EXPLAIN (COSTS OFF) SELECT * FROM pht1 p1 JOIN pht1 p2 USING (c) LIMIT 1000;
          ->  Hash Join
                Hash Cond: (p1_1.c = p2_1.c)
                ->  Seq Scan on pht1_p1 p1_1
+                     Bloom Filter 1: keys=(c)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on pht1_p1 p2_1
          ->  Hash Join
                Hash Cond: (p1_2.c = p2_2.c)
                ->  Seq Scan on pht1_p2 p1_2
+                     Bloom Filter 2: keys=(c)
                ->  Hash
+                     Bloom Filter 2
                      ->  Seq Scan on pht1_p2 p2_2
          ->  Hash Join
                Hash Cond: (p1_3.c = p2_3.c)
                ->  Seq Scan on pht1_p3 p1_3
+                     Bloom Filter 3: keys=(c)
                ->  Hash
+                     Bloom Filter 3
                      ->  Seq Scan on pht1_p3 p2_3
-(17 rows)
+(23 rows)
 
 RESET enable_mergejoin;
 SET max_parallel_workers_per_gather = 1;
diff --git a/src/test/regress/expected/predicate.out b/src/test/regress/expected/predicate.out
index feae77cb840..079f6422fdc 100644
--- a/src/test/regress/expected/predicate.out
+++ b/src/test/regress/expected/predicate.out
@@ -748,14 +748,16 @@ SELECT id FROM dist_tab WHERE row_nn IS DISTINCT FROM ROW(1, 5)::dist_row_t;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT * FROM dist_tab t1 JOIN dist_tab t2 ON t1.val_nn IS NOT DISTINCT FROM t2.val_nn;
-              QUERY PLAN              
---------------------------------------
+              QUERY PLAN               
+---------------------------------------
  Hash Join
    Hash Cond: (t1.val_nn = t2.val_nn)
    ->  Seq Scan on dist_tab t1
+         Bloom Filter 1: keys=(val_nn)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on dist_tab t2
-(5 rows)
+(7 rows)
 
 SELECT * FROM dist_tab t1 JOIN dist_tab t2 ON t1.val_nn IS NOT DISTINCT FROM t2.val_nn;
  id | val_nn | val_null | row_nn | id | val_nn | val_null | row_nn 
diff --git a/src/test/regress/expected/privileges.out b/src/test/regress/expected/privileges.out
index 0de13612818..ca8ce2868d4 100644
--- a/src/test/regress/expected/privileges.out
+++ b/src/test/regress/expected/privileges.out
@@ -618,10 +618,12 @@ EXPLAIN (COSTS OFF) SELECT * FROM atest12 x, atest12 y
  Hash Join
    Hash Cond: (x.a = y.b)
    ->  Seq Scan on atest12 x
+         Bloom Filter 1: keys=(a)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on atest12 y
                Filter: (abs(a) <<< 5)
-(6 rows)
+(8 rows)
 
 -- clean up (regress_priv_user1's objects are all dropped later)
 DROP FUNCTION leak2(integer, integer) CASCADE;
diff --git a/src/test/regress/expected/returning.out b/src/test/regress/expected/returning.out
index 196829e94fa..4625a9447c7 100644
--- a/src/test/regress/expected/returning.out
+++ b/src/test/regress/expected/returning.out
@@ -694,8 +694,10 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
                Hash Cond: (joinme_1.f2j = foo_1.f2)
                ->  Seq Scan on pg_temp.joinme joinme_1
                      Output: joinme_1.ctid, joinme_1.f2j
+                     Bloom Filter 1: keys=(joinme_1.f2j)
                ->  Hash
                      Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
+                     Bloom Filter 1
                      ->  Seq Scan on pg_temp.foo foo_1
                            Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
          ->  Hash
@@ -705,12 +707,14 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
                      Hash Cond: (joinme.f2j = foo_2.f2)
                      ->  Seq Scan on pg_temp.joinme
                            Output: joinme.ctid, joinme.other, joinme.f2j
+                           Bloom Filter 2: keys=(joinme.f2j)
                      ->  Hash
                            Output: foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+                           Bloom Filter 2
                            ->  Seq Scan on pg_temp.foo foo_2
                                  Output: foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
                                  Filter: (foo_2.f3 = 57)
-(27 rows)
+(31 rows)
 
 UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
   RETURNING old.*, new.*, *, new.f3 - old.f3 AS delta_f3;
@@ -768,12 +772,14 @@ UPDATE joinview SET f3 = f3 + 1, f4 = 7 WHERE f3 = 58
          Hash Cond: (joinme.f2j = foo.f2)
          ->  Seq Scan on pg_temp.joinme
                Output: joinme.other, joinme.ctid, joinme.f2j
+               Bloom Filter 1: keys=(joinme.f2j)
          ->  Hash
                Output: foo.f3, foo.f1, foo.f2, foo.f4, foo.ctid, foo.tableoid
+               Bloom Filter 1
                ->  Seq Scan on pg_temp.foo
                      Output: foo.f3, foo.f1, foo.f2, foo.f4, foo.ctid, foo.tableoid
                      Filter: (foo.f3 = 58)
-(12 rows)
+(14 rows)
 
 UPDATE joinview SET f3 = f3 + 1, f4 = 7 WHERE f3 = 58
   RETURNING old.*, new.*, *, new.f3 - old.f3 AS delta_f3;  -- should succeed
diff --git a/src/test/regress/expected/rowsecurity.out b/src/test/regress/expected/rowsecurity.out
index 3a5e82c35bd..4d69d1cd84b 100644
--- a/src/test/regress/expected/rowsecurity.out
+++ b/src/test/regress/expected/rowsecurity.out
@@ -1,6 +1,8 @@
 --
 -- Test of Row-level security feature
 --
+-- disable bloom filter pushdown, to not interfere with calls to functions
+SET enable_hashjoin_bloom = off;
 -- Clean up in case a prior regression run failed
 -- Suppress NOTICE messages when users/groups don't exist
 SET client_min_messages TO 'warning';
diff --git a/src/test/regress/expected/select_views.out b/src/test/regress/expected/select_views.out
index 1aeed8452bd..fe7be9891d5 100644
--- a/src/test/regress/expected/select_views.out
+++ b/src/test/regress/expected/select_views.out
@@ -2,6 +2,8 @@
 -- SELECT_VIEWS
 -- test the views defined in CREATE_VIEWS
 --
+-- disable bloom filter pushdown, to not interfere with calls to functions
+SET enable_hashjoin_bloom = off;
 SELECT * FROM street;
                 name                |                                                                                                                                                                                                                   thepath                                                                                                                                                                                                                    |   cname   
 ------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index 37070c1a896..11cf56fdba4 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -3633,9 +3633,11 @@ SELECT * FROM sb_1 a, sb_2 b WHERE a.x = b.x AND a.y = b.y AND a.z = b.z;
  Hash Join
    Hash Cond: ((a.x = b.x) AND (a.y = b.y) AND (a.z = b.z))
    ->  Seq Scan on sb_1 a
+         Bloom Filter 1: keys=(x, y, z)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on sb_2 b
-(5 rows)
+(7 rows)
 
 -- Check that the Hash Join bucket size estimator detects equal clauses correctly.
 SET enable_nestloop = 'off';
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index a3778c23c34..5c7d49050db 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -406,9 +406,11 @@ select * from int4_tbl o where exists
  Hash Semi Join
    Hash Cond: (o.f1 = i.f1)
    ->  Seq Scan on int4_tbl o
+         Bloom Filter 1: keys=(f1)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on int4_tbl i
-(5 rows)
+(7 rows)
 
 explain (costs off)
 select * from int4_tbl o where not exists
@@ -783,14 +785,16 @@ order by t1.a, t2.a;
                      Hash Cond: (t2.a = (t3.b + 1))
                      ->  Seq Scan on public.semijoin_unique_tbl t2
                            Output: t2.a, t2.b
+                           Bloom Filter 1: keys=(t2.a)
                      ->  Hash
                            Output: t3.a, t3.b
+                           Bloom Filter 1
                            ->  HashAggregate
                                  Output: t3.a, t3.b
                                  Group Key: (t3.a + 1), (t3.b + 1)
                                  ->  Seq Scan on public.semijoin_unique_tbl t3
                                        Output: t3.a, t3.b, (t3.a + 1), (t3.b + 1)
-(24 rows)
+(26 rows)
 
 -- encourage use of parallel plans
 set parallel_setup_cost=0;
@@ -2176,11 +2180,13 @@ order by t1.ten;
                Hash Cond: (t2.fivethous = t1.unique1)
                ->  Seq Scan on public.tenk1 t2
                      Output: t2.unique1, t2.unique2, t2.two, t2.four, t2.ten, t2.twenty, t2.hundred, t2.thousand, t2.twothousand, t2.fivethous, t2.tenthous, t2.odd, t2.even, t2.stringu1, t2.stringu2, t2.string4
+                     Bloom Filter 1: keys=(t2.fivethous)
                ->  Hash
                      Output: t1.ten, t1.unique1
+                     Bloom Filter 1
                      ->  Seq Scan on public.tenk1 t1
                            Output: t1.ten, t1.unique1
-(15 rows)
+(17 rows)
 
 select t1.ten, sum(x) from
   tenk1 t1 left join lateral (
@@ -2275,15 +2281,19 @@ order by 1, 2;
                Hash Cond: (t2.q2 = t3.q2)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
+                     Bloom Filter 1: keys=(t2.q2)
+                     Bloom Filter 2: keys=(t2.q2)
                ->  Hash
                      Output: t3.q2
+                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t3
                            Output: t3.q2
          ->  Hash
                Output: t1.q1, t1.q2
+               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1, t1.q2
-(19 rows)
+(23 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2326,14 +2336,16 @@ order by 1, 2;
                Output: t2.q2, ((t2.q1 + 1))
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
+                     Bloom Filter 1: keys=(t2.q2)
                ->  Seq Scan on public.int8_tbl t3
                      Output: t3.q2, (t2.q1 + 1)
                      Filter: (t2.q2 = t3.q2)
          ->  Hash
                Output: t1.q1, t1.q2
+               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1, t1.q2
-(17 rows)
+(19 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2378,15 +2390,19 @@ order by 1, 2;
                Hash Cond: (t2.q2 = t3.q1)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
+                     Bloom Filter 1: keys=(t2.q2)
+                     Bloom Filter 2: keys=(t2.q1)
                ->  Hash
                      Output: t3.q1
+                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t3
                            Output: t3.q1
          ->  Hash
                Output: t1.q1
+               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1
-(19 rows)
+(23 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2439,14 +2455,16 @@ order by 1, 2;
                Output: t2.q1, (t2.q2)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
+                     Bloom Filter 1: keys=(t2.q1)
                ->  Seq Scan on public.int8_tbl t3
                      Output: t3.q1, t2.q2
                      Filter: (t2.q2 = t3.q1)
          ->  Hash
                Output: t1.q1
+               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1
-(17 rows)
+(19 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2510,19 +2528,21 @@ order by 1, 2, 3;
                      Hash Cond: (t2.q1 = t3.q2)
                      ->  Seq Scan on public.int8_tbl t2
                            Output: t2.q1, t2.q2
+                           Bloom Filter 1: keys=(t2.q2)
                      ->  Hash
                            Output: t3.q2, (COALESCE(t3.q1, t3.q1))
                            ->  Seq Scan on public.int8_tbl t3
                                  Output: t3.q2, COALESCE(t3.q1, t3.q1)
                ->  Hash
                      Output: t4.q1, t4.q2
+                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t4
                            Output: t4.q1, t4.q2
          ->  Hash
                Output: t1.q2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q2
-(26 rows)
+(28 rows)
 
 select ss2.* from
   int8_tbl t1 left join
@@ -2590,11 +2610,13 @@ order by 1, 2, 3;
                                  Output: t3.q2, COALESCE(t3.q1, t3.q1)
                ->  Seq Scan on public.int8_tbl t4
                      Output: t4.q1, t4.q2, (COALESCE(t3.q1, t3.q1))
+                     Bloom Filter 1: keys=(t4.q1)
          ->  Hash
                Output: t1.q2
+               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q2
-(24 rows)
+(26 rows)
 
 select ss2.* from
   int8_tbl t1 left join
@@ -2895,11 +2917,13 @@ select * from tenk1 A where hundred in (select hundred from tenk2 B where B.odd
  Hash Join
    Hash Cond: ((a.odd = b.odd) AND (a.hundred = b.hundred))
    ->  Seq Scan on tenk1 a
+         Bloom Filter 1: keys=(odd, hundred)
    ->  Hash
+         Bloom Filter 1
          ->  HashAggregate
                Group Key: b.odd, b.hundred
                ->  Seq Scan on tenk2 b
-(7 rows)
+(9 rows)
 
 explain (costs off)
 select * from tenk1 A where exists
@@ -2964,11 +2988,13 @@ ON B.hundred in (SELECT c.hundred FROM tenk2 C WHERE c.odd = b.odd);
          ->  Hash Join
                Hash Cond: ((b.odd = c.odd) AND (b.hundred = c.hundred))
                ->  Seq Scan on tenk2 b
+                     Bloom Filter 1: keys=(odd, hundred)
                ->  Hash
+                     Bloom Filter 1
                      ->  HashAggregate
                            Group Key: c.odd, c.hundred
                            ->  Seq Scan on tenk2 c
-(10 rows)
+(12 rows)
 
 -- we can pull up the sublink into the inner JoinExpr.
 explain (costs off)
@@ -2983,13 +3009,15 @@ WHERE a.thousand < 750;
          Hash Cond: (a.hundred = c.hundred)
          ->  Seq Scan on tenk1 a
                Filter: (thousand < 750)
+               Bloom Filter 1: keys=(hundred)
          ->  Hash
+               Bloom Filter 1
                ->  HashAggregate
                      Group Key: c.odd, c.hundred
                      ->  Seq Scan on tenk2 c
    ->  Hash
          ->  Seq Scan on tenk2 b
-(12 rows)
+(14 rows)
 
 -- we can pull up the aggregate sublink into RHS of a left join.
 explain (costs off)
@@ -3126,9 +3154,11 @@ WHERE a.ten IN (VALUES (1), (2));
    Hash Cond: (a.ten = c.ten)
    ->  Seq Scan on onek a
          Filter: (ten = ANY ('{1,2}'::integer[]))
+         Bloom Filter 1: keys=(ten)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 c
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT c.unique1,c.ten FROM tenk1 c JOIN onek a USING (ten)
@@ -3139,9 +3169,11 @@ WHERE c.ten IN (VALUES (1), (2));
    Hash Cond: (c.ten = a.ten)
    ->  Seq Scan on tenk1 c
          Filter: (ten = ANY ('{1,2}'::integer[]))
+         Bloom Filter 1: keys=(ten)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on onek a
-(6 rows)
+(8 rows)
 
 -- Constant expressions are simplified
 EXPLAIN (COSTS OFF)
@@ -3311,9 +3343,11 @@ SELECT * FROM onek t1, lateral (SELECT * FROM onek t2 WHERE t2.ten IN (values (t
    ->  Hash Semi Join
          Hash Cond: (t2.ten = "*VALUES*".column1)
          ->  Seq Scan on onek t2
+               Bloom Filter 1: keys=(ten)
          ->  Hash
+               Bloom Filter 1
                ->  Values Scan on "*VALUES*"
-(7 rows)
+(9 rows)
 
 -- VtA causes the whole expression to be evaluated as a constant
 EXPLAIN (COSTS OFF)
diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out
index 132b56a5864..a796e431415 100644
--- a/src/test/regress/expected/sysviews.out
+++ b/src/test/regress/expected/sysviews.out
@@ -163,6 +163,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_group_by_reordering     | on
  enable_hashagg                 | on
  enable_hashjoin                | on
+ enable_hashjoin_bloom          | on
  enable_incremental_sort        | on
  enable_indexonlyscan           | on
  enable_indexscan               | on
@@ -180,7 +181,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_seqscan                 | on
  enable_sort                    | on
  enable_tidscan                 | on
-(25 rows)
+(26 rows)
 
 -- There are always wait event descriptions for various types.  InjectionPoint
 -- may be present or absent, depending on history since last postmaster start.
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 7b00c742776..7d4af80faf6 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -587,11 +587,13 @@ MERGE INTO rw_view1 t
          Hash Cond: (base_tbl.a = generate_series.generate_series)
          ->  Bitmap Heap Scan on base_tbl
                Recheck Cond: (a > 0)
+               Bloom Filter 1: keys=(a)
                ->  Bitmap Index Scan on base_tbl_pkey
                      Index Cond: (a > 0)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series
-(9 rows)
+(11 rows)
 
 EXPLAIN (costs off)
 MERGE INTO rw_view1 t
@@ -621,11 +623,13 @@ MERGE INTO rw_view1 t
          Hash Cond: (base_tbl.a = generate_series.generate_series)
          ->  Bitmap Heap Scan on base_tbl
                Recheck Cond: (a > 0)
+               Bloom Filter 1: keys=(a)
                ->  Bitmap Index Scan on base_tbl_pkey
                      Index Cond: (a > 0)
          ->  Hash
+               Bloom Filter 1
                ->  Function Scan on generate_series
-(9 rows)
+(11 rows)
 
 -- it's still updatable if we add a DO ALSO rule
 CREATE TABLE base_tbl_hist(ts timestamptz default now(), a int, b text);
@@ -2777,12 +2781,14 @@ EXPLAIN (costs off) UPDATE rw_view1 SET a = a + 5;
    ->  Hash Join
          Hash Cond: (b.a = r.a)
          ->  Seq Scan on base_tbl b
+               Bloom Filter 1: keys=(a)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on ref_tbl r
    SubPlan exists_1
      ->  Index Only Scan using ref_tbl_pkey on ref_tbl r_1
            Index Cond: (a = b.a)
-(9 rows)
+(11 rows)
 
 DROP TABLE base_tbl, ref_tbl CASCADE;
 NOTICE:  drop cascades to view rw_view1
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index de0e14a686e..8a439b28df3 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -5474,10 +5474,12 @@ LIMIT 1;
          ->  Hash Join
                Hash Cond: (t1.unique1 = t2.tenthous)
                ->  Index Only Scan using tenk1_unique1 on tenk1 t1
+                     Bloom Filter 1: keys=(unique1)
                ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on tenk1 t2
                            Filter: (two = 1)
-(9 rows)
+(11 rows)
 
 -- Ensure we get a cheap total plan.  This time use UNBOUNDED FOLLOWING, which
 -- needs to read all join rows to output the first WindowAgg row.
diff --git a/src/test/regress/expected/with.out b/src/test/regress/expected/with.out
index 77ded01b046..db8c77721e7 100644
--- a/src/test/regress/expected/with.out
+++ b/src/test/regress/expected/with.out
@@ -686,9 +686,11 @@ select count(*) from tenk1 a
    ->  Hash Semi Join
          Hash Cond: (a.unique1 = x.unique1)
          ->  Index Only Scan using tenk1_unique1 on tenk1 a
+               Bloom Filter 1: keys=(unique1)
          ->  Hash
+               Bloom Filter 1
                ->  CTE Scan on x
-(8 rows)
+(10 rows)
 
 explain (costs off)
 with x as materialized (insert into tenk1 default values returning unique1)
@@ -3246,8 +3248,10 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
          Hash Cond: (m.k = o.k)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
+               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: o.k, o.v, o.*
+               Bloom Filter 1
                ->  Subquery Scan on o
                      Output: o.k, o.v, o.*
                      ->  Result
@@ -3258,7 +3262,7 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
            ->  CTE Scan on cte_basic
                  Output: (cte_basic.b || ' merge update'::text)
                  Filter: (cte_basic.a = m.k)
-(21 rows)
+(23 rows)
 
 -- InitPlan
 WITH cte_init AS MATERIALIZED (SELECT 1 a, 'cte_init val' b)
@@ -3295,13 +3299,15 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
          Hash Cond: (m.k = o.k)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
+               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: o.k, o.v, o.*
+               Bloom Filter 1
                ->  Subquery Scan on o
                      Output: o.k, o.v, o.*
                      ->  Result
                            Output: 1, 'merge source InitPlan'::text
-(21 rows)
+(23 rows)
 
 -- MERGE source comes from CTE:
 WITH merge_source_cte AS MATERIALIZED (SELECT 15 a, 'merge_source_cte val' b)
@@ -3339,11 +3345,13 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.a, o.b || (SELECT merge_source_cte.*::text
          Hash Cond: (m.k = merge_source_cte.a)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
+               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: merge_source_cte.a, merge_source_cte.b, merge_source_cte.*
+               Bloom Filter 1
                ->  CTE Scan on merge_source_cte
                      Output: merge_source_cte.a, merge_source_cte.b, merge_source_cte.*
-(20 rows)
+(22 rows)
 
 DROP TABLE m;
 -- check that run to completion happens in proper ordering
diff --git a/src/test/regress/sql/rowsecurity.sql b/src/test/regress/sql/rowsecurity.sql
index 6b3566271df..390e79b04c3 100644
--- a/src/test/regress/sql/rowsecurity.sql
+++ b/src/test/regress/sql/rowsecurity.sql
@@ -2,6 +2,9 @@
 -- Test of Row-level security feature
 --
 
+-- disable bloom filter pushdown, to not interfere with calls to functions
+SET enable_hashjoin_bloom = off;
+
 -- Clean up in case a prior regression run failed
 
 -- Suppress NOTICE messages when users/groups don't exist
diff --git a/src/test/regress/sql/select_views.sql b/src/test/regress/sql/select_views.sql
index e742f136990..09f96b0b1ae 100644
--- a/src/test/regress/sql/select_views.sql
+++ b/src/test/regress/sql/select_views.sql
@@ -3,6 +3,9 @@
 -- test the views defined in CREATE_VIEWS
 --
 
+-- disable bloom filter pushdown, to not interfere with calls to functions
+SET enable_hashjoin_bloom = off;
+
 SELECT * FROM street;
 
 SELECT name, #thepath FROM iexit ORDER BY name COLLATE "C", 2;
-- 
2.54.0

---

From: Andrew Dunstan <[email-masked]>
Date: Sat, 30 May 2026 13:12:39 -0400

> me briefly revisit that, before I get to the new patch.

Hi, Tomas

This is terrific and very timely from my POV.

I've been experimenting with a table AM (implemented as a
CustomScan scan provider), and bloom-filter pushdown from a hashjoin is one
of the bigger wins available to it: a fact-table scan joined to a filtered
dimension can use the filter to skip whole row groups and avoid
decompressing columns entirely, rather than just rejecting a tuple after
it's been produced. I'd hacked up a private version of this via a new
table-AM callback (the hashjoin walks the outer subtree, builds a filter
from the build side, and hands it to the AM's scan descriptor). Having now
read your PoC, I think your framework is the better foundation, and I'd
rather build on it than carry a parallel mechanism. But two things stand in
the way of a storage-level consumer using it, and I think both are 
relatively
small.

1) A CustomScan can't currently be a recipient.

find_bloom_filter_recipient() only recognizes the stock scan tags, and the
probe itself lives in ExecScanExtended(), which a CustomScan never calls
(it dispatches to the provider's ExecCustomScan). The second part is
actually a feature, not a bug: if a CustomScan provider does its own
probing, it can choose the granularity -- per dictionary entry, per row
group, or per row -- instead of being locked into the per-row,
post-materialization probe that the stock nodes get. So all that's needed
on your side is to let the planner attach a filter to a base-relation
CustomScan; the provider takes care of consuming it.

Concretely, that's adding T_CustomScan to the scan-leaf case in
find_bloom_filter_recipient() (CustomScan embeds Scan first, so the
scanrelid test is identical; non-leaf custom nodes have scanrelid == 0 and
fall through to NULL), plus the matching fix_scan_bloom_filters() call in
set_customscan_references(). The provider then calls ExecInitBloomFilters()
in BeginCustomScan and ExecBloomFilters() (or a coarser-grained variant)
inside its scan loop. Everything else -- producer registration, the
es_bloom_producers lookup, the adaptive sampling, EXPLAIN -- is reused
unchanged.

2) The combined-hash filter can't be tested against a single column.

You build one filter keyed on hash32() of all the join keys combined. For a
single-key join that's ideal, and a column store can use it directly: hash
each distinct dictionary value once per row group and skip groups whose
values are all absent. For a multi-column join, though, the combined hash
mixes the keys, so it can only ever be tested per-row (with all key columns
in hand) -- it can't be checked against any one column's dictionary. The
per-row probe is still useful, but the row-group/dictionary skipping, which
is where most of the storage win comes from, isn't available.

The obvious thought is to key a filter per column instead. But I don't
think that should *replace* the combined filter, because per-column filters
are strictly less selective on multi-column joins: they only test whether
each column's value appears *somewhere* in the build side, not whether the
combination does. With build pairs {(1,10),(2,20)}, an outer (1,20) passes
both per-column filters even though it matches no build row, whereas the
combined filter rejects it. So for the row-level probe -- and especially
for plain heap -- the combined filter is the better one, and replacing it
would be a regression.

What I think would actually help is to let the framework *optionally* emit
per-column filters in addition to the combined one, when a recipient
signals it can use them. The combined filter stays the default and does the
precise per-row rejection (unchanged for heap, and usable per-row by a
column store too); the per-column filters are extra, built only on demand,
and let a storage consumer cheaply eliminate whole row groups before the
combined filter does the exact work. The cost is the build CPU and memory
for the extra filters -- but only for consumers that ask, so your design is
untouched when nobody does. For a single-key join the two filters 
coincide, so
there'd be no reason to build both.

I'd be happy to work on patches for these.

cheers

andrew

--
Andrew Dunstan
EDB:https://www.enterprisedb.com

---

From: Tomas Vondra <[email-masked]>
Date: Sat, 30 May 2026 20:14:33 +0200

> the way of a storage-level consumer using it, and I think both are

OK, good to hear. I was actually thinking about that use case too, i.e.
making it possible for the scan to do something smart with the filter
(like even pushing it even further down, to "storage"). Or maybe the
ForeignScan could push it to the remote side, so that it's actually
filtered there.

I didn't mention that my message, and there are some difficulties:

1) We only build the hash (and bloom) with a delay, after the scan
already produces some tuples. That complicates the pushdown, whiich may
need to happen when starting the scan. Presumably, we'd need to allow
disabling this optimization, optionally.

2) We'd need some sort of "portable" Bloom filter, with serialization
and deserialization, etc.

Both of these seem rather solvable.

> es_bloom_producers lookup, the adaptive sampling, EXPLAIN -- is reused

Yes, that should work and it's a mostly mechanical change.

Maybe we'd want some sort of opt-in, so that the CustomScan can indicate
it can handle Bloom filters. Like, setting
CUSTOMPATH_SUPPORT_BLOOM_FILTERS to flags.

> there'd be no reason to build both.

I think I speculated about this (having per-key filters) in some of the
comments in the patch, although the use case was different. I haven't
thought about TAM, but about different joins where the join keys come
from both sides. Consider a join like

        HJ
      /    \
     A     HJ
         /    \
        B      C

where A-(BC) is on (A.x = B.x AND A.y = C.y), so the complete filter
can't be pushed to either side. But we could:

(1) Push the filter on top of the BC join (which in this example is not
really a push-down).

(2) Build filters on (x) and (y) separately, and push-down these.

Or we could do both, really.

I suppose a variation of (2) would work for your use case too, except
we'd push all three filters (x,y), (x) and (y) to the same scan.

I guess this could also be opt-in, enabled by some CUSTOMPATH_ flag.

The question is how efficient can the smaller filters be. The complete
filter can be very selective, while the per-key filters are terrible.

> I'd be happy to work on patches for these.

Great. It's and interesting experiment / area to explore.

FWIW I think the main difficulty for this PoC is going to be the
planning/costing stuff, and the impact on EXPLAIN.

regards

-- 
Tomas Vondra

---

From: Andrew Dunstan <[email-masked]>
Date: Sun, 31 May 2026 11:03:47 -0400

> Great. It's and interesting experiment / area to explore.

Here are 3 patches (developed using Claude) that sit on top of your POC.

Patch 1 enables the pushdown filters for custom scans. As you say it's 
fairly mechanical and is enabled by a CUSTOMPATH_SUPPORT_BLOOM_FILTERS 
path flag.

Patch 2 provides for building per-key filters in addition to the 
multi-key filter if that flag is set. There may be other cases that 
would want it, but this would suit my immediate use case.

Patch 3 provides for eager creation of the filter(s) in such cases, 
disabling the optimization you mentioned in point 1 above.

> planning/costing stuff, and the impact on EXPLAIN.

I haven't dealt with that or other issues you raise, but I think this is 
enough for me to begin testing. I have adapted my TAM to it and verified 
that it acts as expected. I will start doing some benchmarks.

cheers

andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com
From ff734511d22bcb93f5c1256fd745a9d21818f7f1 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email-masked]>
Date: Sun, 31 May 2026 07:13:48 -0400
Subject: [PATCH addon 1/3] Allow a CustomScan to receive a pushed-down
 hashjoin bloom filter

Extend the hashjoin bloom-filter pushdown so that a base-relation
CustomScan can be a recipient, gated on a new opt-in path flag
CUSTOMPATH_SUPPORT_BLOOM_FILTERS.  This lets a table AM implemented as a
CustomScan scan provider consume the filter and apply it inside its own
scan loop -- for a column store, at row-group or dictionary granularity,
before decompression -- rather than only rejecting an already-produced
tuple.

find_bloom_filter_recipient() now treats a base-rel CustomScan
(scanrelid > 0) that advertised the flag the same as a SeqScan.  The
probe is not wired into ExecScanExtended() (a CustomScan dispatches to
the provider's ExecCustomScan), so the provider calls ExecBloomFilters()
itself; ExecInitCustomScan() compiles the probe state up front via
ExecInitBloomFilters() so the provider need not touch bloom internals.
set_customscan_references() fixes the pushed key expressions for a
base-relation scan just like the scan qual.

Providers that do not set the flag, and heap, are unaffected.
---
 src/backend/executor/nodeCustom.c       | 10 ++++++++++
 src/backend/optimizer/plan/createplan.c | 19 +++++++++++++++++++
 src/backend/optimizer/plan/setrefs.c    | 10 ++++++++++
 src/include/nodes/extensible.h          |  2 ++
 4 files changed, 41 insertions(+)

diff --git a/src/backend/executor/nodeCustom.c b/src/backend/executor/nodeCustom.c
index b7cc890cd20..dfd87e49737 100644
--- a/src/backend/executor/nodeCustom.c
+++ b/src/backend/executor/nodeCustom.c
@@ -101,6 +101,16 @@ ExecInitCustomScan(CustomScan *cscan, EState *estate, int eflags)
 	css->ss.ps.qual =
 		ExecInitQual(cscan->scan.plan.qual, (PlanState *) css);
 
+	/*
+	 * Set up any bloom filters a hash join pushed down to this scan (see
+	 * nodeHashjoin.c).  This compiles the probe expressions against the scan
+	 * tuple slot; the provider is responsible for actually probing them with
+	 * ExecBloomFilters() from its ExecCustomScan callback, at whatever
+	 * granularity it supports.  A no-op unless the provider advertised
+	 * CUSTOMPATH_SUPPORT_BLOOM_FILTERS and the planner found a filter to push.
+	 */
+	ExecInitBloomFilters((PlanState *) css, css->ss.ss_ScanTupleSlot);
+
 	/*
 	 * The callback of custom-scan provider applies the final initialization
 	 * of the custom-scan-state node according to its logic.
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 7ecb551aae6..304ce0e3c0d 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4799,6 +4799,25 @@ find_bloom_filter_recipient(Plan *plan, Index target_relid)
 					return plan;
 				return NULL;
 			}
+		case T_CustomScan:
+			{
+				/*
+				 * A CustomScan on a base relation can act as a recipient, but
+				 * only if the provider advertised that it knows how to consume
+				 * a pushed-down bloom filter.  Unlike the stock scans, the
+				 * probe is not performed by ExecScanExtended() (a CustomScan
+				 * dispatches to the provider's own ExecCustomScan); the
+				 * provider is responsible for calling ExecBloomFilters() at
+				 * whatever granularity it likes.  Non-leaf custom nodes have
+				 * scanrelid == 0 and so are rejected by the relid test.
+				 */
+				CustomScan *cscan = (CustomScan *) plan;
+
+				if ((cscan->flags & CUSTOMPATH_SUPPORT_BLOOM_FILTERS) &&
+					cscan->scan.scanrelid == target_relid)
+					return plan;
+				return NULL;
+			}
 		case T_Sort:
 		case T_IncrementalSort:
 		case T_Material:
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 0059acfccbe..74c7a5bf3a5 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -1826,6 +1826,16 @@ set_customscan_references(PlannerInfo *root,
 		cscan->custom_exprs =
 			fix_scan_list(root, cscan->custom_exprs,
 						  rtoffset, NUM_EXEC_QUAL((Plan *) cscan));
+
+		/*
+		 * Bloom filters pushed down to a base-relation CustomScan: the key
+		 * expressions are plain Vars of the scanned relation, so they are
+		 * fixed up the same way as the scan qual.  (A CustomScan emitting a
+		 * custom_scan_tlist takes the branch above and would instead need
+		 * fix_upper_expr against the tlist index, like IndexOnlyScan; no
+		 * in-tree provider needs that yet.)
+		 */
+		fix_scan_bloom_filters(root, (Plan *) cscan, rtoffset);
 	}
 
 	/* Adjust child plan-nodes recursively, if needed */
diff --git a/src/include/nodes/extensible.h b/src/include/nodes/extensible.h
index 517db95c4a3..ea2cef4fe3b 100644
--- a/src/include/nodes/extensible.h
+++ b/src/include/nodes/extensible.h
@@ -84,6 +84,8 @@ extern const ExtensibleNodeMethods *GetExtensibleNodeMethods(const char *extnode
 #define CUSTOMPATH_SUPPORT_BACKWARD_SCAN	0x0001
 #define CUSTOMPATH_SUPPORT_MARK_RESTORE		0x0002
 #define CUSTOMPATH_SUPPORT_PROJECTION		0x0004
+/* provider can accept a hashjoin bloom filter pushed down to its scan */
+#define CUSTOMPATH_SUPPORT_BLOOM_FILTERS	0x0008
 
 /*
  * Custom path methods.  Mostly, we just need to know how to convert a
-- 
2.43.0

From 2bac3bb8a4917f77deb19998752493f75b4f1c70 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email-masked]>
Date: Sun, 31 May 2026 07:13:58 -0400
Subject: [PATCH addon 2/3] Optionally build per-key hashjoin bloom filters for
 opted-in recipients

Add an opt-in path that builds one bloom filter per join key, in
addition to the existing combined-hash filter, when the pushdown
recipient is a CustomScan that advertised CUSTOMPATH_SUPPORT_BLOOM_FILTERS
and the join has more than one key.

The combined filter, keyed on the hash of all keys together, stays the
default and remains the more selective one for a per-row probe: per-key
filters only test whether each column's value appears somewhere in the
build side, so on a multi-column join they are strictly weaker (they
cannot reject a row whose columns each match but not as a tuple).  What
they enable is testing a single key column on its own -- a column store
can check one column against its per-column dictionary or zone map and
skip whole row groups before decompression, which the combined filter
cannot support.

The build reuses the per-key inner hash functions (the combined hash
value cannot be decomposed, so the Hash node builds one single-key hash
ExprState per key); the extra CPU and memory are paid only by a consumer
that opted in.  A recipient correlates HashState.perkey_filters[i] with
BloomFilter.filter_exprs[i] by position.  Heap and single-key joins are
unaffected.
---
 src/backend/executor/nodeHash.c         | 35 +++++++++++++++++++++++++
 src/backend/executor/nodeHashjoin.c     | 25 ++++++++++++++++++
 src/backend/optimizer/plan/createplan.c | 12 +++++++++
 src/include/nodes/execnodes.h           | 14 ++++++++++
 src/include/nodes/plannodes.h           | 11 ++++++++
 5 files changed, 97 insertions(+)

diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 37224324bce..2b045eae186 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -197,6 +197,25 @@ MultiExecPrivateHash(HashState *node)
 								  (unsigned char *) &hashvalue,
 								  sizeof(hashvalue));
 
+			/*
+			 * Likewise for the optional per-key filters, using the per-key
+			 * (single-key) hash ExprStates.  Same econtext as the combined
+			 * hash above (ecxt_outertuple is the just-fetched inner tuple).
+			 */
+			for (int k = 0; k < node->perkey_nfilters; k++)
+			{
+				bool		keyisnull;
+				uint32		keyhash;
+
+				keyhash = DatumGetUInt32(ExecEvalExprSwitchContext(node->perkey_hash[k],
+																   econtext,
+																   &keyisnull));
+				if (!keyisnull)
+					bloom_add_element(node->perkey_filters[k],
+									  (unsigned char *) &keyhash,
+									  sizeof(keyhash));
+			}
+
 			bucketNumber = ExecHashGetSkewBucket(hashtable, hashvalue);
 			if (bucketNumber != INVALID_SKEW_BUCKET_NO)
 			{
@@ -722,6 +741,22 @@ ExecHashTableCreate(HashState *state)
 		oldctx = MemoryContextSwitchTo(hashtable->hashCxt);
 		state->bloom_filter = bloom_create((int64) Max(rows, 1.0),
 										   bloom_work_mem, 0);
+
+		/*
+		 * If a recipient opted in, also build one filter per join key (in
+		 * addition to the combined one above).  These let a recipient test an
+		 * individual key column on its own; they are less selective than the
+		 * combined filter, so they are built only on demand.
+		 */
+		if (state->want_perkey_bloom)
+		{
+			state->perkey_filters = palloc_array(struct bloom_filter *,
+												 state->perkey_nfilters);
+			for (int i = 0; i < state->perkey_nfilters; i++)
+				state->perkey_filters[i] = bloom_create((int64) Max(rows, 1.0),
+														bloom_work_mem, 0);
+		}
+
 		MemoryContextSwitchTo(oldctx);
 	}
 
diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 8fa7af4cfef..1eaf81285f8 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -908,6 +908,7 @@ ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 	hashState = castNode(HashState, innerPlanState(hjstate));
 	hashState->want_bloom_filter = (node->bloom_consumer_count > 0);
 	hashState->bloom_filter_id = node->bloom_filter_id;
+	hashState->want_perkey_bloom = node->bloom_perkey;
 
 	/*
 	 * Initialize result slot, type and projection.
@@ -1031,6 +1032,28 @@ ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 								&hashstate->ps,
 								0);
 
+		/*
+		 * If a recipient opted in to per-key bloom filters, build one inner
+		 * (single-key) hash ExprState per join key, used by the Hash node to
+		 * populate the per-key filters.  The combined hash above cannot be
+		 * decomposed, so this is the extra cost a per-key consumer pays.
+		 */
+		if (hashstate->want_perkey_bloom)
+		{
+			hashstate->perkey_nfilters = nkeys;
+			hashstate->perkey_hash = palloc_array(ExprState *, nkeys);
+			for (int i = 0; i < nkeys; i++)
+				hashstate->perkey_hash[i] =
+					ExecBuildHash32Expr(hashstate->ps.ps_ResultTupleDesc,
+										hashstate->ps.resultops,
+										&inner_hashfuncid[i],
+										list_make1_oid(list_nth_oid(node->hashcollations, i)),
+										list_make1(list_nth(hash->hashkeys, i)),
+										&hash_strict[i],
+										&hashstate->ps,
+										0);
+		}
+
 		/* Remember whether we need to save tuples with null join keys */
 		hjstate->hj_KeepNullTuples = HJ_FILL_OUTER(hjstate);
 		hashstate->keep_null_tuples = HJ_FILL_INNER(hjstate);
@@ -1118,6 +1141,7 @@ ExecEndHashJoin(HashJoinState *node)
 		ExecHashTableDestroy(node->hj_HashTable);
 		node->hj_HashTable = NULL;
 		hashNode->bloom_filter = NULL;
+		hashNode->perkey_filters = NULL;
 	}
 
 	/*
@@ -1775,6 +1799,7 @@ ExecReScanHashJoin(HashJoinState *node)
 			 * freed by the ExecHashTableDestroy call.
 			 */
 			hashNode->bloom_filter = NULL;
+			hashNode->perkey_filters = NULL;
 
 			/*
 			 * if chgParam of subnode is not null then plan will be re-scanned
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 304ce0e3c0d..5b01b3e45cc 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4992,6 +4992,18 @@ try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan)
 
 	recipient->bloom_filters = lappend(recipient->bloom_filters, bf);
 
+	/*
+	 * If the recipient is a CustomScan that opted in, also build a separate
+	 * filter per join key.  Only such a recipient can make use of them (to
+	 * test a single column against a dictionary or zone map); the combined
+	 * filter is always built and is the more selective one for the per-row
+	 * probe.  There is nothing to gain for a single-key join, where the two
+	 * coincide.
+	 */
+	if (list_length(hashkeys) > 1 && IsA(recipient, CustomScan) &&
+		(((CustomScan *) recipient)->flags & CUSTOMPATH_SUPPORT_BLOOM_FILTERS))
+		hj->bloom_perkey = true;
+
 	/*
 	 * XXX We've manged to push the filter to the scan node, but maybe
 	 * we should wait with updating bloom_consumer_count when it actually
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 04333f1a4d0..ee98bcb3adf 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2783,6 +2783,20 @@ typedef struct HashState
 	 */
 	struct bloom_filter *bloom_filter;
 
+	/*
+	 * Optional per-key bloom filters, built in addition to the combined
+	 * bloom_filter above when a recipient opted in (HashJoin.bloom_perkey).
+	 * perkey_filters has perkey_nfilters entries, one per join key, in hashkey
+	 * order; a recipient correlates them with BloomFilter.filter_exprs by
+	 * position.  perkey_hash holds the matching per-key (single-key) hash
+	 * ExprStates used to populate them during the build.  All live in hashCxt
+	 * and follow the same lifecycle as bloom_filter.
+	 */
+	bool		want_perkey_bloom;
+	int			perkey_nfilters;
+	struct bloom_filter **perkey_filters;
+	ExprState **perkey_hash;
+
 	/*
 	 * Counters with total per-filter instrumentation. Separate from the
 	 * per-recipient counters in BloomFilterState. Redundant, but will be
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 4e35d77cc49..21ec7ffae1a 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1124,6 +1124,17 @@ typedef struct HashJoin
 	 * Zero when this HashJoin has no consumers.
 	 */
 	int			bloom_filter_id;
+
+	/*
+	 * Whether to also build one bloom filter per join key (in addition to the
+	 * combined-hash filter), so that a recipient can test an individual key
+	 * column on its own -- e.g. a column store probing a per-column dictionary
+	 * or zone map.  Set at plan time only when the recipient is a CustomScan
+	 * that advertised CUSTOMPATH_SUPPORT_BLOOM_FILTERS.  The combined filter is
+	 * always built and remains the more selective one; per-key filters are an
+	 * opt-in extra that nobody else pays for.
+	 */
+	bool		bloom_perkey;
 } HashJoin;
 
 /* ----------------
-- 
2.43.0

From 3a4be73ebded2c7cb683f2f0803dcf3badf0686a Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email-masked]>
Date: Sun, 31 May 2026 07:48:23 -0400
Subject: [PATCH addon 3/3] Build the hashjoin bloom filter eagerly for a
 CustomScan recipient

When the outer relation's startup cost is below the hash-table build
cost, ExecHashJoinImpl fetches the first outer tuple before building the
hash table, to take the empty-outer shortcut.  For a CustomScan that
consumes a pushed-down bloom filter in its own scan loop that is too
late: its first tuple request -- which for a column store may decompress
a whole row group -- happens before the filter exists, so the first
batch is scanned unfiltered.

Add a HashJoin.bloom_eager flag, set at plan time when the filter is
pushed to a CustomScan recipient (which advertised
CUSTOMPATH_SUPPORT_BLOOM_FILTERS), telling the executor to skip the
empty-outer prefetch and build the hash table -- and the filter --
before the outer scan starts.  This is driven by the same opt-in path as
the recipient itself rather than a GUC, and only such a recipient pays
the cost (a possibly-needless hash build when the outer turns out empty);
stock-scan recipients, which probe per-row after producing a tuple
anyway, are unaffected.
---
 src/backend/executor/nodeHashjoin.c     | 11 +++++++++
 src/backend/optimizer/plan/createplan.c | 30 ++++++++++++++++++-------
 src/include/nodes/plannodes.h           | 10 +++++++++
 3 files changed, 43 insertions(+), 8 deletions(-)

diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 1eaf81285f8..9154310c09a 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -317,6 +317,17 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 					 */
 					node->hj_FirstOuterTupleSlot = NULL;
 				}
+				else if (((HashJoin *) node->js.ps.plan)->bloom_eager)
+				{
+					/*
+					 * We pushed a bloom filter to a CustomScan on the outer
+					 * side that wants it at scan start (e.g. to skip row groups
+					 * before decompression).  Skip the empty-outer prefetch and
+					 * build the hash table -- and the filter -- first, so it is
+					 * ready before the outer scan produces its first tuple.
+					 */
+					node->hj_FirstOuterTupleSlot = NULL;
+				}
 				else if (HJ_FILL_OUTER(node) ||
 						 (outerNode->plan->startup_cost < hashNode->ps.plan->total_cost &&
 						  !node->hj_OuterNotEmpty))
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 5b01b3e45cc..a70f1104800 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4993,16 +4993,30 @@ try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan)
 	recipient->bloom_filters = lappend(recipient->bloom_filters, bf);
 
 	/*
-	 * If the recipient is a CustomScan that opted in, also build a separate
-	 * filter per join key.  Only such a recipient can make use of them (to
-	 * test a single column against a dictionary or zone map); the combined
-	 * filter is always built and is the more selective one for the per-row
-	 * probe.  There is nothing to gain for a single-key join, where the two
-	 * coincide.
+	 * A CustomScan recipient that opted in consumes the filter in its own
+	 * scan loop, possibly at the storage level, so it wants two things a
+	 * stock scan does not.
 	 */
-	if (list_length(hashkeys) > 1 && IsA(recipient, CustomScan) &&
+	if (IsA(recipient, CustomScan) &&
 		(((CustomScan *) recipient)->flags & CUSTOMPATH_SUPPORT_BLOOM_FILTERS))
-		hj->bloom_perkey = true;
+	{
+		/*
+		 * Build the hash table (and filter) before the outer scan starts, so
+		 * the filter is available on the first tuple request rather than after
+		 * a batch has already been scanned unfiltered.
+		 */
+		hj->bloom_eager = true;
+
+		/*
+		 * Also build a separate filter per join key, so the recipient can test
+		 * a single column on its own (e.g. against a per-column dictionary or
+		 * zone map).  The combined filter is always built and is the more
+		 * selective one for a per-row probe; there is nothing to gain for a
+		 * single-key join, where the two coincide.
+		 */
+		if (list_length(hashkeys) > 1)
+			hj->bloom_perkey = true;
+	}
 
 	/*
 	 * XXX We've manged to push the filter to the scan node, but maybe
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 21ec7ffae1a..0e011f3d4e2 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1135,6 +1135,16 @@ typedef struct HashJoin
 	 * opt-in extra that nobody else pays for.
 	 */
 	bool		bloom_perkey;
+
+	/*
+	 * Whether to build the hash table (and bloom filter) before fetching the
+	 * first outer tuple, skipping the empty-outer prefetch optimization.  Set
+	 * at plan time when the filter is pushed to a CustomScan recipient, which
+	 * may want to apply the filter the moment its scan starts (e.g. a column
+	 * store skipping row groups before decompression) rather than after having
+	 * already produced a batch unfiltered.  See ExecHashJoinImpl.
+	 */
+	bool		bloom_eager;
 } HashJoin;
 
 /* ----------------
-- 
2.43.0

---

From: Andrei Lepikhov <[email-masked]>
Date: Mon, 1 Jun 2026 11:30:17 +0200

Postgres is still gaining ground in this area. It’s helpful to see how other
databases handle these challenges.

> The regressions are relatively small, though (<10%).

We ran into the same problem when trying to estimate the number of 'generated'
NULLs on the nullable side. So, it makes sense to focus on the estimation method
for 'unmatched' tuples as a separate task.

> bit silly. Maybe we can't fit the 2% filter, but 5% of 10% would be OK?

Looking at DuckDB’s code, using bloom filters during hash table construction
solves this issue.

---

From: Andrei Lepikhov <[email-masked]>
Date: Tue, 2 Jun 2026 08:25:46 +0200

> joins, we'd end up with 2^3 combinations. That seems not great.
I overlooked this part of your first message, so let me add a quick comment.

In principle, the optimiser is not restricted to bottom-up planning.  For
example, in extension modules, I sometimes use the create_upper_paths_hook to
add a 'Top-Down' iteration after 'bottom-up' planning [1].

This helps improve complex query plans, such as adding a Memoize node at the
head of a subplan when the number of distinct input parameter values is expected
to be low.  It can also use the startup_cost-optimal subpaths in MergeJoin if
histogram comparisons indicate that only a small portion of the input will be
scanned.  There are other possible cases involving LIMIT and sort propagation as
well.

I'm not sure whether this approach makes sense for the specific technique you
develop, since it's already quite complex.  Also, additional planning iteration
is a pure overhead in most of cases except complex analytical queries.  However,
it might provide an idea for future improvement.

[1] https://github.com/danolivo/conf/blob/main/2025-MiddleOut/MiddleOut.pdf

-- 
regards, Andrei Lepikhov,
pgEdge

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 2 Jun 2026 17:22:36 +0200

Hi,

I kept thinking about the various issues discussed after I posted the v1
pshdown patch. Some of the issues are specific to the pushdown (to scan
nodes), but a lot of the issues seem to be shared with using Bloom
filters within the hashjoin (which is what the old threads were about).

We'd need to do something about these issues no matter where we place
the filter, so it's a bit of prerequisite for using Bloom in hash joins
in general. And they seem somewhat more limited / easier to solve than
the planning/costing issues.

So I decided it'd be interesting to see how beneficial can the Bloom
filters be in the scope of a single hashjoin, without pushing it all the
way to the scan nodes, and see what we can do about the issues.

Attached is a PoC patch series optinally adding Bloom filters to a hash
join, both for serial and parallel joins. It's labeled as v2, but it's
really independent of the v1 pushdoown patch posted last week. Some of
the ideas implemented in this could be applied to the pushdown patch too
(in particular all the adaptive behavior).

I'm not sure if we should try to merge these two things into a single
patch series, or whether it'd be better to split those into two threads
(otherwise it'll just keep confusing both people and cfbot).

how the patch works
-------------------

Anyway, let me briefly explain what the patch does (see the commit
messages and comments for more details, I tried to keep those
comprehensive). I suggest focusing on the serial case (in 0001), the
parallel joins are a direct extension of that - but inherently harder to
understand, due to the parallel hash build, shmem etc.

In principle, using Bloom filters is pretty simple - while adding tuples
from the inner relation to the hash table, build also a Bloom filter and
then use it to discard outer tuples cheaply, without having to do an
expensive lookup in a hash table. It does not depend if the hash table
is in private or shared memory.

The difficulty is to figure out whether it makes sense to build/probe
the filter. For that to be the case, the filter needs to eliminate
enough outer tuples, so that the hash table lookup is not needed, and/or
the tuple can be discarded without spilling it to disk (with nbatch>1).

Note: With the pushdown, the benefits "compound" by combining multiple
filters (if there are multiple joins) and/or by skipping some
intermediate operators (between the scan and the hashjoin). So it's
maybe less risky, but the issue still exists.

adaptive build / probing
------------------------

I see two complementary ways to deal with this - during planning (based
on estimates and a cost model), and adaptively during execution (based
on probe/lookup stats). The v2 patch does the latter, mostly because I
think it's beneficial even if we eventually add some smarts to the
planning phase.

The adaptive behavior decides (a) when a filter is built, and (b) if a
filter is probed before hash table lookups.

For builds, we don't want to build filters when ~100% of lookups in the
hash table find a match. It'd not pay for itself. So when the hash table
fits into memory (nbatch=1), we wait for the first 1000 lookups, and
only build the filter only if <90% have a match (and recheck once in a
while, so the filter may be built later).

But with batched joins (nbatch>1) we can't delay building the filter, we
have to decide before spilling some of the tuples to disk (otherwise the
filter would be incomplete, and we couldn't reject tuples from later
batches - which is the main benefit with batched joins). So with batched
joins we build the filter, and hope that either it helps, or the
overhead is negligible overall.

Then when probing, we don't want to use filter that does not reject any
tuples. To deal with this, the patch tracks number of probes and number
of rejections, and if fewer than 10% of probes reject the tuple (i.e.
the filter is ineffective), it gets temporarily "disabled". When
disabled, a filter samples 1% of probes, and then may get enabled again
if the fraction of rejected tuples gets >20%.

Overall, this seems to work pretty well. Of course, it can be improved
in various ways. For example, the thresholds 10% and 20% are somewhat
arbitrary - it's based on earlier experiments, and it works OK on a
number of machines, with different queries / data types. But having a
more formal "cost model" for Bloom filters might help.

Another possible improvement is about maybe doing some decisions during
planning, particularly when the decisions are reliable. I'm rather
skeptical about deciding to build a Bloom filter based on estimates. I
think it's better to do that decision during execution, as explained in
the preceding sections. We could still consider the "expected" Bloom
filter for costing purposed, but leave the decision for execution.

However, in some cases we may be able to know for sure a Bloom filter is
useless. For example, if we know a given join is on a FK, every outer
tuple will have a match. In that case the filter can't help. The patch
won't build it anyway (at least for nbatch=1), thanks to the adaptive
build heuristics. But we could short-circuit that entirely.

perf evaluation
---------------

Now, some numbers. Attached is a .tgz with benchmark script running a
hashjoin on two tables (fact-dimension), varying the selectivity of the
join (5%-100%), work_mem, number of parallel workers, data types of the
join keys, and size of the tables. There's a .csv with more complete
results of the tests, I'll focus on results for scale 100, i.e. fact
100M rows, dimension 10M rows.

The two attached PDFs show timings for master + patched branch, with
enable_hashjoin_bloomm=on/off. And then columns showing timing relative
to master (<1.0 speedup / green, >1.0 regression / red). Green = good.
BTW this is from my ryzen machine (Ryzen 9 9900X).

The results for serial queries (workers=0) seem pretty nice. For
selective joins (>50% outer tuples discarded) it's about 20% faster, and
with 5% selectivity (95% discarded), it's ~2x faster. Which seems nice.
The adaptive thresholds seem to about match reality.

For parallel queries it's a bit worse. There are some nice speedups, but
the benefits are clearly more limited. One interesting observation is
that while for serial queries, the cases that most benefit are with
batching, while with parallel joins it's exactly the opposite. See the
hashjoin-bloom-batched.pdf, which shows timings only for queries with
batched joins.

I'm not sure why is that, but it's entirely possible it's due to a bug
in the patch - the parallel join is fairly complex, I can't rule this
out. Or it might be due to some hardware bottlenecks or whatever?

I'd definitely welcome some review and ideas what might be causing this.

One thing I realized when looking at the results is that this may need
some different trade offs regarding the size of the filter. The library
lib/bloomfilter.c aims for 1-2% false positive rate, but we sometimes
end up with a filter like this:

    Bloom Filter: Size: 16384kB  Hash Functions: 10
                  False Positive Rate: 0.077%

This is for work_mem=64MB, with batched join:

  Buckets: 2097152  Batches: 16  Memory Usage: 82784kB

so maybe it's not that large. But maybe it'd be better to accept
somewhat higher false-positive rate (e.g. ~10%) in exchange for a much
smaller filter, and fewer hash functions (i.e. fewer bits to check)?

regards

-- 
Tomas Vondra

From 24ffd790ec0e1bd05f426aaf7a27a5380c132432 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <[email-masked]>
Date: Mon, 1 Jun 2026 14:22:36 +0200
Subject: [PATCH v2 2/2] Using Bloom filters for parallel hash joins

Extends the usage of Bloom filters to parallel hash joins too. Overall
it works very similarly (when the filter is built/used, and the adaptive
behavior managing that) to the serial case.

The main difference is that with parallel hash join, the filter is
placed in allocated in DSA, so that it can be shared by all workers
participating in the join.

While building the filter, workers are building a filter in their
private memory. Once the hash table build is complete, the private
filters are merged into the shared filter. After that, the workers
probe the shared filter.
---
 src/backend/commands/explain.c                |  61 +++-
 src/backend/executor/nodeHash.c               | 297 +++++++++++++++++-
 src/backend/executor/nodeHashjoin.c           |  12 +-
 src/backend/lib/bloomfilter.c                 | 127 ++++++++
 src/include/executor/hashjoin.h               |  16 +
 src/include/lib/bloomfilter.h                 |   9 +
 src/test/regress/expected/join_hash_bloom.out | 145 +++++++++
 src/test/regress/sql/join_hash_bloom.sql      |  46 +++
 8 files changed, 695 insertions(+), 18 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 1b3a3579df9..7c24be797e2 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3433,6 +3433,61 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 											  worker_hi->nbatch_original);
 			hinstrument.space_peak = Max(hinstrument.space_peak,
 										 worker_hi->space_peak);
+
+			/*
+			 * In a parallel-aware hash join each worker probes its own outer
+			 * tuples, so the probe and match counts are summed.
+			 */
+			hinstrument.bloom_nprobes += worker_hi->bloom_nprobes;
+			hinstrument.bloom_nmatches += worker_hi->bloom_nmatches;
+			hinstrument.hash_nlookups += worker_hi->hash_nlookups;
+			hinstrument.hash_nmatches += worker_hi->hash_nmatches;
+
+			ExplainOpenWorker(i, es);
+
+			if (es->verbose)
+			{
+				if (worker_hi->hash_nlookups > 0)
+				{
+					if (es->format == EXPLAIN_FORMAT_TEXT)
+					{
+						ExplainIndentText(es);
+						appendStringInfo(es->str,
+										 "Hash Lookups: " INT64_FORMAT "  Matches: " INT64_FORMAT "  Match Rate: %.3f%%\n",
+										 worker_hi->hash_nlookups,
+										 worker_hi->hash_nmatches,
+										 (100.0 * worker_hi->hash_nmatches / worker_hi->hash_nlookups));
+					}
+				}
+
+				if (worker_hi->bloom_nprobes > 0)
+				{
+					if (es->format == EXPLAIN_FORMAT_TEXT)
+					{
+						ExplainIndentText(es);
+						appendStringInfo(es->str,
+										 "Bloom Filter Probes: " INT64_FORMAT "  Matches: " INT64_FORMAT "  Match Rate: %.3f%%\n",
+										 worker_hi->bloom_nprobes,
+										 worker_hi->bloom_nmatches,
+										 (100.0 * worker_hi->bloom_nmatches / worker_hi->bloom_nprobes));
+					}
+				}
+			}
+
+			ExplainCloseWorker(i, es);
+
+
+			/*
+			 * The Bloom filter dimensions and false positive rate describe the
+			 * (shared) filter itself rather than per-worker counters, so they
+			 * are identical across participants; just keep any non-zero value.
+			 */
+			hinstrument.bloom_nbytes = Max(hinstrument.bloom_nbytes,
+										   worker_hi->bloom_nbytes);
+			hinstrument.bloom_nhashfuncs = Max(hinstrument.bloom_nhashfuncs,
+											   worker_hi->bloom_nhashfuncs);
+			hinstrument.bloom_false_positive_rate = Max(hinstrument.bloom_false_positive_rate,
+														worker_hi->bloom_false_positive_rate);
 		}
 	}
 
@@ -3497,7 +3552,7 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 			ExplainPropertyFloat("Hash Match Rate", NULL,
 								 (100.0 * match_rate), 3, es);
 		}
-		else
+		else if (hinstrument.hash_nlookups > 0)
 		{
 			ExplainIndentText(es);
 			appendStringInfo(es->str,
@@ -3539,7 +3594,7 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 			ExplainPropertyFloat("False Positive Rate", NULL,
 								 100.0 * hinstrument.bloom_false_positive_rate, 3, es);
 
-			if (es->analyze)
+			if (es->analyze && es->verbose)
 			{
 				ExplainPropertyInteger("Probes", NULL,
 									   hinstrument.bloom_nprobes, es);
@@ -3560,7 +3615,7 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 							 hinstrument.bloom_nhashfuncs,
 							 100.0 * hinstrument.bloom_false_positive_rate);
 
-			if (es->analyze)
+			if (es->analyze && es->verbose)
 			{
 				ExplainIndentText(es);
 				appendStringInfo(es->str,
diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 442beee7b70..1cb141703a2 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -83,6 +83,9 @@ static bool ExecParallelHashTuplePrealloc(HashJoinTable hashtable,
 static void ExecParallelHashMergeCounters(HashJoinTable hashtable);
 static void ExecParallelHashCloseBatchAccessors(HashJoinTable hashtable);
 
+static void ExecParallelHashInitBloomFilter(HashJoinTable hashtable, int64 nelems);
+static void ExecParallelHashBuildBloomFilter(HashJoinTable hashtable);
+
 /*
  * Bloom filters
  *
@@ -391,6 +394,15 @@ MultiExecParallelHash(HashState *node)
 				ExecParallelHashIncreaseNumBuckets(hashtable);
 			ExecParallelHashEnsureBatchAccessors(hashtable);
 			ExecParallelHashTableSetCurrentBatch(hashtable, 0);
+
+			/*
+			 * When in a multi-batch case, we want to build a shared filter from
+			 * the very beginning. So create per-worker filters with matching
+			 * parameters, and we'll merge them at the end of the build.
+			 */
+			if (pstate->bloom_nelems > 0)
+				ExecParallelHashInitBloomFilter(hashtable, pstate->bloom_nelems);
+
 			for (;;)
 			{
 				bool		isnull;
@@ -412,6 +424,11 @@ MultiExecParallelHash(HashState *node)
 					/* normal case with a non-null join key */
 					ExecParallelHashTableInsert(hashtable, slot, hashvalue);
 					hashtable->reportTuples++;
+
+					/* add the hash to the private Bloom filter */
+					if (hashtable->bloomFilter != NULL)
+						bloom_add_element(hashtable->bloomFilter,
+										  (unsigned char *) &hashvalue, sizeof(uint32));
 				}
 				else if (node->keep_null_tuples)
 				{
@@ -437,6 +454,35 @@ MultiExecParallelHash(HashState *node)
 			 */
 			ExecParallelHashMergeCounters(hashtable);
 
+			/*
+			 * If we built a private Bloom filter, merge it into the shared
+			 * filter now, while still holding the build barrier, so that the
+			 * shared filter is complete by the time anyone starts probing.
+			 *
+			 * XXX This covers both the case when we know about batching from
+			 * the beginning of the build (with the shared filter allocated in
+			 * ExecHashTableCreate), and when we start batching only during
+			 * the build (in ExecParallelHashIncreaseNumBatches).
+			 */
+			if (hashtable->bloomFilter != NULL &&
+				DsaPointerIsValid(pstate->bloom_filter))
+			{
+				bloom_filter *shared;
+
+				LWLockAcquire(&pstate->lock, LW_EXCLUSIVE);
+				shared = (bloom_filter *)
+					dsa_get_address(hashtable->area, pstate->bloom_filter);
+				bloom_merge(shared, hashtable->bloomFilter);
+				LWLockRelease(&pstate->lock);
+
+				/*
+				 * Free the private filter, we won't need it anymore, and we
+				 * will set the pointer to the shared one in a bit.
+				 */
+				bloom_free(hashtable->bloomFilter);
+				hashtable->bloomFilter = NULL;
+			}
+
 			BarrierDetach(&pstate->grow_buckets_barrier);
 			BarrierDetach(&pstate->grow_batches_barrier);
 
@@ -469,6 +515,19 @@ MultiExecParallelHash(HashState *node)
 	hashtable->log2_nbuckets = pg_ceil_log2_32(hashtable->nbuckets);
 	hashtable->totalTuples = pstate->total_tuples;
 
+	/*
+	 * In the multi-batch case, set the pointer at the filter in shared memory,
+	 * so that the workers can probe it directly. In the single-batch case the
+	 * filter may get built adaptively later, during probing (and we'll have to
+	 * do this then).
+	 */
+	if (pstate->bloom_state == PHJ_BLOOM_BUILT &&
+		DsaPointerIsValid(pstate->bloom_filter))
+	{
+		hashtable->bloomFilter = (bloom_filter *)
+			dsa_get_address(hashtable->area, pstate->bloom_filter);
+	}
+
 	/*
 	 * Unless we're completely done and the batch state has been freed, make
 	 * sure we have accessors.
@@ -742,6 +801,38 @@ ExecHashTableCreate(HashState *state)
 			 */
 			pstate->nbuckets = nbuckets;
 			ExecParallelHashTableAlloc(hashtable, 0);
+
+			/*
+			 * If we already know we'll need more than one batch, set up a
+			 * shared Bloom filter right away so that it includes all inner
+			 * inner tuples. Each worker builds a private filter while hashing
+			 * and merges it into this one at the end of the build (see
+			 * MultiExecParallelHash).
+			 *
+			 * We size it from the planner's estimate so that all the filters
+			 * have matching dimensions (which is required for merging).
+			 *
+			 * XXX Minimum set to 1000 tuples. Maybe we should use a multiple
+			 * of rows, to handle misestimates better? But Bloom filters degrade
+			 * smoothly, so that's probably fine. We don't want the filters to
+			 * get too large - once it exceeds CPU caches, it gets much slower.
+			 * In the worst case, we'll adaptively disable the filter once the
+			 * false positive rate gets too high (too many probes matching).
+			 */
+			if (nbatch > 1 && enable_hashjoin_bloom)
+			{
+				pstate->bloom_nelems = Max((int64) rows, 1000);
+				pstate->bloom_filter =
+					dsa_allocate(hashtable->area,
+								 bloom_estimate_custom(pstate->bloom_nelems,
+													   work_mem,
+													   BLOOM_MIN_FILTER_SIZE));
+				bloom_init_custom(dsa_get_address(hashtable->area,
+												  pstate->bloom_filter),
+								  pstate->bloom_nelems, work_mem,
+								  BLOOM_MIN_FILTER_SIZE, 0);
+				pstate->bloom_state = PHJ_BLOOM_BUILT;
+			}
 		}
 
 		/*
@@ -910,8 +1001,13 @@ ExecHashBloomReject(HashJoinTable hashtable, uint32 hashvalue)
 	/*
 	 * Ignore the filter after processing the first batch (all tuples spilled
 	 * to temporary files already went through the check).
+	 *
+	 * XXX With parallel joins we need to allow (curbatch > 0), because it works
+	 * differently with batches (compared to serial builds). For more details ses
+	 * ExecParallelHashJoinOuterGetTuple. Without this we'd fail to probe the
+	 * filter from some workers (for many outer tuples).
 	 */
-	if (hashtable->curbatch != 0)
+	if (hashtable->curbatch != 0 && !hashtable->parallel_state)
 		return false;
 
 	/* If there's no filter, all tuples should pass. */
@@ -993,6 +1089,8 @@ ExecHashBloomSamplingUpdate(HashJoinTable hashtable, bool match)
 void
 ExecHashBloomAccountLookup(HashJoinTable hashtable)
 {
+	ParallelHashJoinState *pstate = hashtable->parallel_state;
+
 	hashtable->hashMatches++;
 
 	/* Bail out if Bloom filters are disabled. */
@@ -1003,12 +1101,16 @@ ExecHashBloomAccountLookup(HashJoinTable hashtable)
 	if (hashtable->bloomFilter != NULL)
 		return;
 
-	/* We can't build filters for parallel hash joins. */
-	if (hashtable->parallel_state != NULL)
+	/* All batched runs should have a filter created automatically. */
+	Assert(hashtable->nbatch == 1);
+
+	/* haven't collected enough probe samples yet */
+	if ((hashtable->hashLookups % BLOOM_BUILD_WINDOW) != 0)
 		return;
 
-	/* All serial batched runs should have a filter created automatically. */
-	Assert(hashtable->nbatch == 1);
+	/* have enough samples, but there are too many matches */
+	if (hashtable->hashMatches > hashtable->hashLookups * BLOOM_BUILD_THRESHOLD)
+		return;
 
 	/*
 	 * Build a filter if the hash table lookups found sufficiently few matches
@@ -1018,11 +1120,40 @@ ExecHashBloomAccountLookup(HashJoinTable hashtable)
 	 * would mean we look at individual windows, while now we look at the whole
 	 * history of lookups. Not sure if one of these is a "more right".
 	 */
-	if (((hashtable->hashLookups % BLOOM_BUILD_WINDOW) == 0) &&
-		(hashtable->hashMatches < hashtable->hashLookups * BLOOM_BUILD_THRESHOLD))
+	if (!pstate)
 	{
+		/* serial join */
 		ExecHashBuildBloomFilter(hashtable);
 	}
+	else
+	{
+		/*
+		 * Parallel join: coordinate so that only one backend builds the shared
+		 * filter (by scanning the current hash table). The first backend to finish
+		 * its sample makes the decision for everyone; later backends simply observe
+		 * the result.
+		 *
+		 * The workers may hit the adaptive build threshold at different times (or
+		 * maybe some workers may not hit it at all), in which case the worker will
+		 * not know about the filter and won't probe it. But that's fine - it's up
+		 * to the worker to decide whether to probe or not (based on it's local
+		 * stats). We're not adding items to the hash table, so this can't cause
+		 * missing data or anything like that.
+		 */
+		LWLockAcquire(&pstate->lock, LW_EXCLUSIVE);
+
+		if (pstate->bloom_state == PHJ_BLOOM_NONE)
+		{
+			ExecParallelHashBuildBloomFilter(hashtable);
+		}
+		else if (pstate->bloom_state == PHJ_BLOOM_BUILT)
+		{
+			hashtable->bloomFilter = (bloom_filter *)
+					dsa_get_address(hashtable->area, pstate->bloom_filter);
+		}
+
+		LWLockRelease(&pstate->lock);
+	}
 }
 
 /*
@@ -1361,6 +1492,73 @@ ExecHashTableDestroy(HashJoinTable hashtable)
 	pfree(hashtable);
 }
 
+/*
+ * ExecParallelHashInitBloomFilter
+ *		Allocate an empty Bloom filter for this hash table.
+ *
+ * The filter is allocated in the long-lived hashCxt so that it survives
+ * per-batch resets.  "nelems" is an estimate of the number of inner tuples,
+ * used to size the filter; it should be computed identically by every
+ * participant of a Parallel Hash join so that local filters can be merged.
+ */
+static void
+ExecParallelHashInitBloomFilter(HashJoinTable hashtable, int64 nelems)
+{
+	MemoryContext oldcxt;
+
+	Assert(hashtable->bloomFilter == NULL);
+
+	oldcxt = MemoryContextSwitchTo(hashtable->hashCxt);
+	hashtable->bloomFilter = bloom_create_custom(nelems, work_mem,
+												 BLOOM_MIN_FILTER_SIZE, 0);
+	MemoryContextSwitchTo(oldcxt);
+}
+
+/*
+ * ExecParallelHashBuildBloomFilter
+ *		Build the shared Bloom filter for a parallel single-batch hash table.
+ *
+ * Called with pstate->lock held by a single backend that has decided the join
+ * is selective enough to benefit.  The completed shared hash table is scanned
+ * to populate the filter, which is then published for all backends to probe.
+ */
+static void
+ExecParallelHashBuildBloomFilter(HashJoinTable hashtable)
+{
+	ParallelHashJoinState *pstate = hashtable->parallel_state;
+	bloom_filter *shared;
+	int			i;
+
+	Assert(hashtable->nbatch == 1);
+	Assert(pstate->bloom_state == PHJ_BLOOM_NONE);
+	Assert(!DsaPointerIsValid(pstate->bloom_filter));
+
+	pstate->bloom_nelems = Max((int64) pstate->total_tuples, 1000);
+	pstate->bloom_filter =
+		dsa_allocate(hashtable->area,
+					 bloom_estimate_custom(pstate->bloom_nelems, work_mem,
+										   BLOOM_MIN_FILTER_SIZE));
+	shared = bloom_init_custom(dsa_get_address(hashtable->area,
+											   pstate->bloom_filter),
+							   pstate->bloom_nelems, work_mem,
+							   BLOOM_MIN_FILTER_SIZE, 0);
+
+	for (i = 0; i < hashtable->nbuckets; i++)
+	{
+		HashJoinTuple tuple = ExecParallelHashFirstTuple(hashtable, i);
+
+		while (tuple != NULL)
+		{
+			bloom_add_element(shared,
+							  (unsigned char *) &tuple->hashvalue,
+							  sizeof(uint32));
+			tuple = ExecParallelHashNextTuple(hashtable, tuple);
+		}
+	}
+
+	pstate->bloom_state = PHJ_BLOOM_BUILT;
+}
+
 /*
  * Consider adjusting the allowed hash table size, depending on the number
  * of batches, to minimize the overall memory usage (for both the hashtable
@@ -1699,6 +1897,49 @@ ExecParallelHashIncreaseNumBatches(HashJoinTable hashtable)
 					for (i = 0; i < new_nbuckets; ++i)
 						dsa_pointer_atomic_init(&buckets[i], InvalidDsaPointer);
 					pstate->nbuckets = new_nbuckets;
+
+					/*
+					 * Create the new shared filter (we'll create the new private
+					 * per-worker filters in ExecParallelHashRepartitionFirst).
+					 *
+					 * sizing: We assume we've only seen 1/2 the tuples so far.
+					 * We clearly expected fewer tuples (otherwise we'd choose
+					 * batching right away), but maybe we should use a bigger
+					 * factor? We can easily be off by an order of magniture.
+					 * OTOH we don't want to overdo it, oversized filters get
+					 * somewhat useless, especially once larger than CPU cache.
+					 *
+					 * Also, we have a per-worker count. Let's assume workers
+					 * saw the same number.
+					 *
+					 * XXX Is there a bettew way to estimate the number of tuples
+					 * we'll see for the inner relation?
+					 *
+					 * XXX This might be a good fit for scalable Bloom filters
+					 * (or some other type of filter?)
+					 */
+					if (enable_hashjoin_bloom)
+					{
+						/*
+						 * Double the number of tuples we saw so far (in the
+						 * only batch we have). Calculate total for all workers
+						 * participating in the join.
+						 */
+						pstate->bloom_nelems = (old_batch0->ntuples * 2) *
+									hashtable->parallel_state->nparticipants;
+
+						pstate->bloom_filter =
+							dsa_allocate(hashtable->area,
+										 bloom_estimate_custom(pstate->bloom_nelems,
+															   work_mem,
+															   BLOOM_MIN_FILTER_SIZE));
+						bloom_init_custom(dsa_get_address(hashtable->area,
+														  pstate->bloom_filter),
+										  pstate->bloom_nelems, work_mem,
+										  BLOOM_MIN_FILTER_SIZE, 0);
+						pstate->bloom_state = PHJ_BLOOM_BUILT;
+					}
+
 				}
 				else
 				{
@@ -1822,8 +2063,25 @@ ExecParallelHashRepartitionFirst(HashJoinTable hashtable)
 	dsa_pointer chunk_shared;
 	HashMemoryChunk chunk;
 
+	/*
+	 * If starting to batch (from nbatch=1), we need to create a local filter
+	 * and populate it with entries in each worker.
+	 */
+	bool build_filter = enable_hashjoin_bloom &&
+			(hashtable->parallel_state->old_nbatch == 1);
+
 	Assert(hashtable->nbatch == hashtable->parallel_state->nbatch);
 
+	/*
+	 * Build filter with the same parameters as the shared filter created in
+	 * ExecParallelHashIncreaseNumBatches (so that we can merge them later).
+	 */
+	if (build_filter)
+	{
+		ExecParallelHashInitBloomFilter(hashtable,
+										hashtable->parallel_state->bloom_nelems);
+	}
+
 	while ((chunk = ExecParallelHashPopChunkQueue(hashtable, &chunk_shared)))
 	{
 		size_t		idx = 0;
@@ -1841,6 +2099,12 @@ ExecParallelHashRepartitionFirst(HashJoinTable hashtable)
 			ExecHashGetBucketAndBatch(hashtable, hashTuple->hashvalue,
 									  &bucketno, &batchno);
 
+			/* insert everything into the filter */
+			if (build_filter)
+				bloom_add_element(hashtable->bloomFilter,
+								  (unsigned char *) &hashTuple->hashvalue,
+								  sizeof(uint32));
+
 			Assert(batchno < hashtable->nbatch);
 			if (batchno == 0)
 			{
@@ -3324,17 +3588,22 @@ ExecHashAccumInstrumentation(HashInstrumentation *instrument,
 		instrument->bloom_nbytes = bloom_total_bits(hashtable->bloomFilter) / BITS_PER_BYTE;
 		instrument->bloom_false_positive_rate =
 			bloom_false_positive_rate(hashtable->bloomFilter);
-		instrument->bloom_nprobes = hashtable->bloomProbes;
-		instrument->bloom_nmatches = hashtable->bloomMatches;
 	}
 
 	/*
-	 * Record hash-table probe statistics.
-	 *
-	 * XXX Shouldn't this use Max(), just like the earlier block?
+	 * Bloom filter probe and match counts are cumulative, so sum them across
+	 * successive hash table instances (e.g. rescans) rather than taking the
+	 * maximum.
+	 */
+	instrument->bloom_nprobes += hashtable->bloomProbes;
+	instrument->bloom_nmatches += hashtable->bloomMatches;
+
+	/*
+	 * Hash table lookup and match counts are cumulative as well, so sum them
+	 * across successive hash table instances (e.g. rescans).
 	 */
-	instrument->hash_nlookups = hashtable->hashLookups;
-	instrument->hash_nmatches = hashtable->hashMatches;
+	instrument->hash_nlookups += hashtable->hashLookups;
+	instrument->hash_nmatches += hashtable->hashMatches;
 }
 
 /*
diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index db14cf98f9b..5f87e948dce 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -509,7 +509,7 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 				 * HJ_FILL_OUTER_TUPLE emits a null-extended row for outer joins
 				 * and simply discards the tuple otherwise.
 				 */
-				if (!parallel && ExecHashBloomReject(hashtable, hashvalue))
+				if (ExecHashBloomReject(hashtable, hashvalue))
 				{
 					node->hj_JoinState = HJ_FILL_OUTER_TUPLE;
 					continue;
@@ -1914,6 +1914,9 @@ ExecHashJoinInitializeDSM(HashJoinState *state, ParallelContext *pcxt)
 	pg_atomic_init_u32(&pstate->distributor, 0);
 	pstate->nparticipants = pcxt->nworkers + 1;
 	pstate->total_tuples = 0;
+	pstate->bloom_filter = InvalidDsaPointer;
+	pstate->bloom_state = PHJ_BLOOM_NONE;
+	pstate->bloom_nelems = 0;
 	LWLockInitialize(&pstate->lock,
 					 LWTRANCHE_PARALLEL_HASH_JOIN);
 	BarrierInit(&pstate->build_barrier, 0);
@@ -1985,6 +1988,13 @@ ExecHashJoinReInitializeDSM(HashJoinState *state, ParallelContext *pcxt)
 
 	/* Reset build_barrier to PHJ_BUILD_ELECT so we can go around again. */
 	BarrierInit(&pstate->build_barrier, 0);
+
+	/* Free any shared Bloom filter from the previous scan and reset state. */
+	if (DsaPointerIsValid(pstate->bloom_filter))
+		dsa_free(state->js.ps.state->es_query_dsa, pstate->bloom_filter);
+	pstate->bloom_filter = InvalidDsaPointer;
+	pstate->bloom_state = PHJ_BLOOM_NONE;
+	pstate->bloom_nelems = 0;
 }
 
 void
diff --git a/src/backend/lib/bloomfilter.c b/src/backend/lib/bloomfilter.c
index bb04aa600e8..b4c2b7b8315 100644
--- a/src/backend/lib/bloomfilter.c
+++ b/src/backend/lib/bloomfilter.c
@@ -60,10 +60,137 @@ struct bloom_filter
 
 static int	my_bloom_power(uint64 target_bitset_bits);
 static int	optimal_k(uint64 bitset_bits, int64 total_elems);
+static uint64 bloom_bitset_bytes(int64 total_elems, int bloom_work_mem,
+								 size_t min_filter_size);
 static void k_hashes(bloom_filter *filter, uint32 *hashes, unsigned char *elem,
 					 size_t len);
 static inline uint32 mod_m(uint32 val, uint64 m);
 
+/*
+ * Determine the size of the bitset (in bytes) that bloom_create()/bloom_init()
+ * will use for the given parameters.  The bitset is always a power-of-two
+ * number of bits; see bloom_create() for the rationale behind the sizing.
+ *
+ * min_filter_size is the minimum size of the bitset, in bytes.  The bitset
+ * will never be sized below this, even when the total_elems estimate would
+ * suggest a smaller one.
+ */
+static uint64
+bloom_bitset_bytes(int64 total_elems, int bloom_work_mem, size_t min_filter_size)
+{
+	uint64		bitset_bytes;
+	uint64		bitset_bits;
+	int			bloom_power;
+
+	/*
+	 * Aim for two bytes per element; this is sufficient to get a false
+	 * positive rate below 1%, independent of the size of the bitset or total
+	 * number of elements.  Also, if rounding down the size of the bitset to
+	 * the next lowest power of two turns out to be a significant drop, the
+	 * false positive rate still won't exceed 2% in almost all cases.
+	 */
+	bitset_bytes = Min(bloom_work_mem * UINT64CONST(1024), total_elems * 2);
+	bitset_bytes = Max(min_filter_size, bitset_bytes);
+
+	/*
+	 * Size in bits should be the highest power of two <= target.  bitset_bits
+	 * is uint64 because PG_UINT32_MAX is 2^32 - 1, not 2^32
+	 */
+	bloom_power = my_bloom_power(bitset_bytes * BITS_PER_BYTE);
+	bitset_bits = UINT64CONST(1) << bloom_power;
+	bitset_bytes = bitset_bits / BITS_PER_BYTE;
+
+	return bitset_bytes;
+}
+
+/*
+ * Amount of memory (in bytes) that a Bloom filter sized for the given
+ * parameters occupies, including the fixed-size header.  This lets callers
+ * place a Bloom filter in caller-managed storage (for example shared memory)
+ * with bloom_init().
+ */
+size_t
+bloom_estimate(int64 total_elems, int bloom_work_mem)
+{
+	return bloom_estimate_custom(total_elems, bloom_work_mem,
+								 DEFAULT_MIN_BITSET_BYTES);
+}
+
+/*
+ * Like bloom_estimate(), but the minimum size of the bitset (in bytes) is
+ * provided by the caller instead of the default.  See bloom_create_custom().
+ */
+size_t
+bloom_estimate_custom(int64 total_elems, int bloom_work_mem,
+					  size_t min_filter_size)
+{
+	return offsetof(bloom_filter, bitset) +
+		sizeof(unsigned char) * bloom_bitset_bytes(total_elems, bloom_work_mem,
+												   min_filter_size);
+}
+
+/*
+ * Initialize a Bloom filter in caller-provided memory.
+ *
+ * "ptr" must point to at least bloom_estimate(total_elems, bloom_work_mem)
+ * bytes.  This is useful when the filter must live in memory that the caller
+ * manages itself, such as a DSA allocation shared between parallel workers.
+ *
+ * Two filters initialized with identical total_elems, bloom_work_mem and seed
+ * values share the same dimensions and may be combined with bloom_merge().
+ */
+bloom_filter *
+bloom_init(void *ptr, int64 total_elems, int bloom_work_mem, uint64 seed)
+{
+	return bloom_init_custom(ptr, total_elems, bloom_work_mem,
+							 DEFAULT_MIN_BITSET_BYTES, seed);
+}
+
+/*
+ * Like bloom_init(), but the minimum size of the bitset (in bytes) is provided
+ * by the caller instead of the default.  See bloom_create_custom().
+ */
+bloom_filter *
+bloom_init_custom(void *ptr, int64 total_elems, int bloom_work_mem,
+				  size_t min_bitset_bytes, uint64 seed)
+{
+	bloom_filter *filter = (bloom_filter *) ptr;
+	uint64		bitset_bytes = bloom_bitset_bytes(total_elems, bloom_work_mem,
+												  min_bitset_bytes);
+	uint64		bitset_bits = bitset_bytes * BITS_PER_BYTE;
+
+	filter->k_hash_funcs = optimal_k(bitset_bits, total_elems);
+	filter->seed = seed;
+	filter->m = bitset_bits;
+	memset(filter->bitset, 0, bitset_bytes);
+
+	return filter;
+}
+
+/*
+ * Merge the bits set in "src" into "dst".
+ *
+ * Both filters must have been created with identical dimensions (that is, the
+ * same total_elems, bloom_work_mem and seed values).  After this call "dst"
+ * reports an element as possibly-present if it was possibly-present in either
+ * of the input filters, which is exactly the filter that would have resulted
+ * from adding every element of both filters to a single Bloom filter.
+ */
+void
+bloom_merge(bloom_filter *dst, const bloom_filter *src)
+{
+	uint64		bitset_bytes;
+	uint64		i;
+
+	Assert(dst->m == src->m);
+	Assert(dst->k_hash_funcs == src->k_hash_funcs);
+	Assert(dst->seed == src->seed);
+
+	bitset_bytes = dst->m / BITS_PER_BYTE;
+	for (i = 0; i < bitset_bytes; i++)
+		dst->bitset[i] |= src->bitset[i];
+}
+
 /*
  * Create Bloom filter in caller's memory context.  We aim for a false positive
  * rate of between 1% and 2% when bitset size is not constrained by memory
diff --git a/src/include/executor/hashjoin.h b/src/include/executor/hashjoin.h
index 62d43c7dab4..3e932dbb985 100644
--- a/src/include/executor/hashjoin.h
+++ b/src/include/executor/hashjoin.h
@@ -275,8 +275,24 @@ typedef struct ParallelHashJoinState
 	pg_atomic_uint32 distributor;	/* counter for load balancing */
 
 	SharedFileSet fileset;		/* space for shared temporary files */
+
+	/*
+	 * Shared Bloom filter state.  When a Parallel Hash join uses a Bloom
+	 * filter, the filter lives in the DSA area pointed to by "bloom_filter".
+	 * "bloom_state" coordinates building it (see PHJ_BLOOM_* constants), and
+	 * "bloom_nelems" is the element count estimate used to size it so that
+	 * every worker builds a mergeable local filter of identical dimensions.
+	 */
+	dsa_pointer bloom_filter;	/* shared bloom_filter, or InvalidDsaPointer */
+	int			bloom_state;	/* PHJ_BLOOM_* */
+	int64		bloom_nelems;	/* element estimate used to size the filter */
 } ParallelHashJoinState;
 
+/* Values for ParallelHashJoinState.bloom_state. */
+#define PHJ_BLOOM_NONE			0	/* no decision made yet */
+#define PHJ_BLOOM_BUILT			1	/* shared filter is built and usable */
+#define PHJ_BLOOM_DISABLED		2	/* decided not to use a bloom filter */
+
 /* The phases for building batches, used by build_barrier. */
 #define PHJ_BUILD_ELECT					0
 #define PHJ_BUILD_ALLOCATE				1
diff --git a/src/include/lib/bloomfilter.h b/src/include/lib/bloomfilter.h
index 8b705319f82..dc19bec93da 100644
--- a/src/include/lib/bloomfilter.h
+++ b/src/include/lib/bloomfilter.h
@@ -19,6 +19,15 @@ extern bloom_filter *bloom_create(int64 total_elems, int bloom_work_mem,
 								  uint64 seed);
 extern bloom_filter *bloom_create_custom(int64 total_elems, int bloom_work_mem,
 										 uint64 min_bitset_bytes, uint64 seed);
+extern size_t bloom_estimate(int64 total_elems, int bloom_work_mem);
+extern size_t bloom_estimate_custom(int64 total_elems, int bloom_work_mem,
+									size_t min_filter_size);
+extern bloom_filter *bloom_init(void *ptr, int64 total_elems,
+								int bloom_work_mem, uint64 seed);
+extern bloom_filter *bloom_init_custom(void *ptr, int64 total_elems,
+									   int bloom_work_mem, size_t min_filter_size,
+									   uint64 seed);
+extern void bloom_merge(bloom_filter *dst, const bloom_filter *src);
 extern void bloom_free(bloom_filter *filter);
 extern void bloom_add_element(bloom_filter *filter, unsigned char *elem,
 							  size_t len);
diff --git a/src/test/regress/expected/join_hash_bloom.out b/src/test/regress/expected/join_hash_bloom.out
index c9b5bdc66c9..7396e84a9ab 100644
--- a/src/test/regress/expected/join_hash_bloom.out
+++ b/src/test/regress/expected/join_hash_bloom.out
@@ -174,5 +174,150 @@ EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELE
                Rows Removed by Filter: 4982
 (15 rows)
 
+-- test parallel hash joins
+SET work_mem = '512kB';
+SET max_parallel_workers_per_gather = 2;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+ALTER TABLE hash_bloom_fact SET (parallel_workers = 2);
+-- non-selective in-memory hash join does not use Bloom filters
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Gather (actual rows=100000.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=33333.33 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=10000.00 loops=3)
+               Buckets: 16384  Batches: 1  Memory Usage: 920kB
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=10000.00 loops=3)
+(9 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Gather (actual rows=100000.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=33333.33 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=10000.00 loops=3)
+               Buckets: 16384  Batches: 1  Memory Usage: 920kB
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=10000.00 loops=3)
+(9 rows)
+
+-- a selective in-memory join uses a filter (after 1000 lookups)
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                     
+------------------------------------------------------------------------------------
+ Gather (actual rows=50180.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=16726.67 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=5018.00 loops=3)
+               Buckets: 8192  Batches: 1  Memory Usage: 461kB
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=5018.00 loops=3)
+                     Filter: (r < '0.5'::double precision)
+                     Rows Removed by Filter: 4982
+(11 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                      QUERY PLAN                                       
+---------------------------------------------------------------------------------------
+ Gather (actual rows=50180.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=16726.67 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=5018.00 loops=3)
+               Buckets: 8192  Batches: 1  Memory Usage: 461kB
+               Bloom Filter: Size: 8kB  Hash Functions: 9  False Positive Rate: 0.191%
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=5018.00 loops=3)
+                     Filter: (r < '0.5'::double precision)
+                     Rows Removed by Filter: 4982
+(12 rows)
+
+-- force batching
+SET work_mem = '128kB';
+-- batched join always creates a Bloom filter, but then disables it if
+-- not selective enough
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Gather (actual rows=100000.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=33333.33 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=10000.00 loops=3)
+               Buckets: 4096  Batches: 4  Memory Usage: 229kB
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=10000.00 loops=3)
+(9 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                       QUERY PLAN                                       
+----------------------------------------------------------------------------------------
+ Gather (actual rows=100000.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=33333.33 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=10000.00 loops=3)
+               Buckets: 4096  Batches: 4  Memory Usage: 229kB
+               Bloom Filter: Size: 16kB  Hash Functions: 9  False Positive Rate: 0.187%
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=10000.00 loops=3)
+(10 rows)
+
+-- batched join always creates a Bloom filter, and keeps using it if
+-- selective enough
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                     
+------------------------------------------------------------------------------------
+ Gather (actual rows=50180.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=16726.67 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=5018.00 loops=3)
+               Buckets: 4096  Batches: 2  Memory Usage: 228kB
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=5018.00 loops=3)
+                     Filter: (r < '0.5'::double precision)
+                     Rows Removed by Filter: 4982
+(11 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                      QUERY PLAN                                       
+---------------------------------------------------------------------------------------
+ Gather (actual rows=50180.00 loops=1)
+   Workers Planned: 2
+   Workers Launched: 2
+   ->  Hash Join (actual rows=16726.67 loops=3)
+         Hash Cond: (f.did = d.id)
+         ->  Parallel Seq Scan on hash_bloom_fact f (actual rows=33333.33 loops=3)
+         ->  Hash (actual rows=5018.00 loops=3)
+               Buckets: 4096  Batches: 2  Memory Usage: 228kB
+               Bloom Filter: Size: 8kB  Hash Functions: 9  False Positive Rate: 0.191%
+               ->  Seq Scan on hash_bloom_dimension d (actual rows=5018.00 loops=3)
+                     Filter: (r < '0.5'::double precision)
+                     Rows Removed by Filter: 4982
+(12 rows)
+
 DROP TABLE hash_bloom_fact;
 DROP TABLE hash_bloom_dimension;
diff --git a/src/test/regress/sql/join_hash_bloom.sql b/src/test/regress/sql/join_hash_bloom.sql
index b62e0b2ed90..139266390a0 100644
--- a/src/test/regress/sql/join_hash_bloom.sql
+++ b/src/test/regress/sql/join_hash_bloom.sql
@@ -52,5 +52,51 @@ EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELE
 SET enable_hashjoin_bloom = on;
 EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
 
+-- test parallel hash joins
+SET work_mem = '512kB';
+SET max_parallel_workers_per_gather = 2;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+
+ALTER TABLE hash_bloom_fact SET (parallel_workers = 2);
+
+-- non-selective in-memory hash join does not use Bloom filters
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+-- a selective in-memory join uses a filter (after 1000 lookups)
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+-- force batching
+SET work_mem = '128kB';
+
+-- batched join always creates a Bloom filter, but then disables it if
+-- not selective enough
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+-- batched join always creates a Bloom filter, and keeps using it if
+-- selective enough
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+
 DROP TABLE hash_bloom_fact;
 DROP TABLE hash_bloom_dimension;
-- 
2.54.0

From a5d2068e698d3d2e8f5168fa8b29e074d01f08bc Mon Sep 17 00:00:00 2001
From: test <test>
Date: Sun, 31 May 2026 10:20:50 +0200
Subject: [PATCH v2 1/2] Using Bloom filters for serial hash joins

Builds a Bloom filter on the inner side (on the hash values used for the
regular hash table), and probes it before lookups in the main hash
table. The expectation is that probing the filter is cheaper than hash
table lookup, and much cheaper than spilling the tuple to temporary
files (with batched joins). If a significant fraction of outer tuples
can be skipped based on the probe, it makes the join cheaper.

The patch is limited to serial (non-parallel) joins, within the scope
of a single join node (no pushdown of the Bloom filter).

The feature is gated behind a new GUC enable_hashjoin_bloom (=on).

It's possible the filter does not reject enough tuples to outweigh the
build/probe costs. To mitigate this risk, the patch implements two
adaptive behaviors based on lookup and probe statistics, driving the
filter build and probing.

The filter is built when:

* The join is using batching (nbatch>1). We expect the spilling to be
  expensive enough to justify the cost to build the filter, even if only
  a very small fraction of tuples gets eliminated.

* For single-batch joins (nbatch=1) the filter gets built based on
  lookup match rate. The filter is built if the rate dropp below 90%,
  i.e. if at least 10% of outer tuples can get eliminated.

Furthermore, the probing is driven by a similar statistics. If less than
10% of probes reject the tuple, the filter is considered ineffective,
and is temporarily disabled. It's probed only for 1% of the tuples,
until the reject fraction increases above 20%.

See comments in nodeHash.c and nodeHashjoin.c for more details.

The patch also adds a number of relevant stats to EXPLAIN (ANALYZE),
some of which require VERBOSE.
---
 src/backend/commands/explain.c                |  97 +++++
 src/backend/executor/nodeHash.c               | 390 ++++++++++++++++++
 src/backend/executor/nodeHashjoin.c           |  30 ++
 src/backend/lib/bloomfilter.c                 |  68 ++-
 src/backend/optimizer/path/costsize.c         |   1 +
 src/backend/utils/misc/guc_parameters.dat     |   7 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/executor/hashjoin.h               |  39 ++
 src/include/executor/instrument_node.h        |  12 +
 src/include/executor/nodeHash.h               |   5 +
 src/include/lib/bloomfilter.h                 |   5 +
 src/include/optimizer/cost.h                  |   1 +
 src/test/regress/expected/join_hash_bloom.out | 178 ++++++++
 src/test/regress/expected/sysviews.out        |   3 +-
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/join_hash_bloom.sql      |  56 +++
 16 files changed, 889 insertions(+), 6 deletions(-)
 create mode 100644 src/test/regress/expected/join_hash_bloom.out
 create mode 100644 src/test/regress/sql/join_hash_bloom.sql

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 112c17b0d64..1b3a3579df9 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3474,6 +3474,103 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 							 spacePeakKb);
 		}
 	}
+
+	/*
+	 * Hash table runtime statistics - number of hash table lookups and matches.
+	 * This does not include tuples rejected by a Bloom filter (if there's one).
+	 */
+	if (es->analyze && es->verbose)
+	{
+		double	match_rate = 0.0;
+
+		/* fraction of lookups with a match */
+		if (hinstrument.hash_nmatches > 0)
+			match_rate = (double) hinstrument.hash_nmatches /
+				hinstrument.hash_nlookups;
+
+		if (es->format != EXPLAIN_FORMAT_TEXT)
+		{
+			ExplainPropertyInteger("Hash Lookups", NULL,
+								   hinstrument.hash_nlookups, es);
+			ExplainPropertyInteger("Hash Matches", NULL,
+								   hinstrument.hash_nmatches, es);
+			ExplainPropertyFloat("Hash Match Rate", NULL,
+								 (100.0 * match_rate), 3, es);
+		}
+		else
+		{
+			ExplainIndentText(es);
+			appendStringInfo(es->str,
+							 "Hash Lookups: " INT64_FORMAT "  Matches: " INT64_FORMAT "  Match Rate: %.3f%%\n",
+							 hinstrument.hash_nlookups,
+							 hinstrument.hash_nmatches,
+							 (100.0 * match_rate));
+		}
+	}
+
+	/*
+	 * Bloom filter statistics - similarly to hash tables we report number of
+	 * probes and number of matches, but we also report some basic properties
+	 * of the Bloom filter (size, number of hash functions and the estimated
+	 * false positive rate). The false positive rate is estimated from how
+	 * many bits are set in the filter at the end, not the rate the filter was
+	 * originally sized for.
+	 *
+	 * XXX This only really matters under EXPLAIN ANALYZE, probably. In most
+	 * cases we only decide to build the filter during execution (except for
+	 * the case when we know the hash join neeeds to be batched)?
+	 */
+	if (hinstrument.bloom_used)
+	{
+		uint64		bloomSizeKb = BYTES_TO_KILOBYTES(hinstrument.bloom_nbytes);
+		double		match_fraction = 0.0;
+
+		/* fraction of probes matching the filter */
+		if (hinstrument.bloom_nprobes > 0)
+			match_fraction = (double) hinstrument.bloom_nmatches /
+				hinstrument.bloom_nprobes;
+
+		if (es->format != EXPLAIN_FORMAT_TEXT)
+		{
+			ExplainOpenGroup("Bloom Filter", "Bloom Filter", true, es);
+			ExplainPropertyUInteger("Filter Size", "kB", bloomSizeKb, es);
+			ExplainPropertyInteger("Hash Functions", NULL,
+								   hinstrument.bloom_nhashfuncs, es);
+			ExplainPropertyFloat("False Positive Rate", NULL,
+								 100.0 * hinstrument.bloom_false_positive_rate, 3, es);
+
+			if (es->analyze)
+			{
+				ExplainPropertyInteger("Probes", NULL,
+									   hinstrument.bloom_nprobes, es);
+				ExplainPropertyInteger("Matches", NULL,
+									   hinstrument.bloom_nmatches, es);
+				ExplainPropertyFloat("Match Rate", NULL,
+									 (100.0 * match_fraction), 3, es);
+			}
+
+			ExplainCloseGroup("Bloom Filter", "Bloom Filter", true, es);
+		}
+		else
+		{
+			ExplainIndentText(es);
+			appendStringInfo(es->str,
+							 "Bloom Filter: Size: " UINT64_FORMAT "kB  Hash Functions: %d  False Positive Rate: %.3f%%\n",
+							 bloomSizeKb,
+							 hinstrument.bloom_nhashfuncs,
+							 100.0 * hinstrument.bloom_false_positive_rate);
+
+			if (es->analyze)
+			{
+				ExplainIndentText(es);
+				appendStringInfo(es->str,
+								 "Bloom Filter Probes: " INT64_FORMAT "  Matches: " INT64_FORMAT "  Match Rate: %.3f%%\n",
+								 hinstrument.bloom_nprobes,
+								 hinstrument.bloom_nmatches,
+								 (100.0 * match_fraction));
+			}
+		}
+	}
 }
 
 /*
diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 8825bb6fa23..442beee7b70 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -35,7 +35,9 @@
 #include "executor/instrument.h"
 #include "executor/nodeHash.h"
 #include "executor/nodeHashjoin.h"
+#include "lib/bloomfilter.h"
 #include "miscadmin.h"
+#include "optimizer/cost.h"
 #include "port/pg_bitutils.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -81,6 +83,95 @@ static bool ExecParallelHashTuplePrealloc(HashJoinTable hashtable,
 static void ExecParallelHashMergeCounters(HashJoinTable hashtable);
 static void ExecParallelHashCloseBatchAccessors(HashJoinTable hashtable);
 
+/*
+ * Bloom filters
+ *
+ * A hashjoin may benefit from a Bloom filter on the inner side, allowing it to
+ * reject some of the outer tuples without having to perform a full hash table
+ * lookup, and/or spilling them to disk (for batched joins).
+ *
+ * Probing a filter is significantly cheaper than a hash table lookup (by 1-2
+ * orders of magnitude), and even cheaper than spilling it to disk. If a join
+ * is selective, a significant fraction of the outer tuples can be rejected
+ * after probing the filter. If a join is not selective, and finds a match for
+ * (almost) all outer tuples, there are no benefits of the Bloom filter.
+ *
+ * To make regressions less likely, we employ two adaptive strategies during
+ * building and probing, to limit the impact in case the join is not selective
+ * enough for the filter to pay for itself.
+ *
+ *
+ * 1) adaptive build
+ *
+ * goal: Build filters only when there's a good chance the filter will pay for
+ * itself, i.e. that it will eliminate enough lookups and/or tuples spilled to
+ * disk with (nbatch>1).
+ *
+ * If we expect the hash table to fit into memory (i.e. nbatch=1), we don't
+ * build the filter right away. Instead, we build just the hash table, and
+ * start executing the join as usual. After 1000 lookups (BLOOM_BUILD_WINDOW)
+ * we check how selective the join is, i.e. how many lookups found a match. If
+ * the fraction is below 90% (BLOOM_BUILD_THRESHOLD), we expect the filter to
+ * be worth it, and build it on the tuples in the hash table. We repeat this
+ * check every BLOOM_BUILD_WINDOW lookups, in case the data set is not uniform.
+ *
+ * If we expect the hash table to not fit into memory (i.e. nbatch>1), or if
+ * we find this while building the hash table, we start building the filter
+ * immediately. We can't delay the decision, because once we spill some tuples
+ * to disk, we won't be able to build a valid filter. We also expect the
+ * spilling to be expensive enough to "hide" the overhead, and if we can
+ * eliminate at least some outer tuples before spilling them to disk, it's
+ * likely a win overall.
+ *
+ *
+ * 2) adaptive probing
+ *
+ * goal: Stop probing filters that turn out to not be selective, and start
+ * probing them if that changes during the join. There's no point in probing
+ * a useless filter. But also we've already paid the price for building it,
+ * so if there's a chance it'll be useful, no harm to check again.
+ *
+ * To evaluate the efficiency of a filter, we track the number of matches
+ * for every 1000 probes (BLOOM_PROBE_WINDOW). If more than 90% probes have
+ * a possible match (and thus proceed to perform a hash table lookup), the
+ * filter is considered not effective.
+ *
+ * Instead of just disabling the filter entirely, we start sampling only a
+ * fraction (1%, per BLOOM_PROBE_SAMPLE_RATE) of the probes. Only those
+ * probes are evaluated using the filter, the remaining 99% go directly to
+ * the hash table lookuk (as if the filter did not reject them). After
+ * about 100k values, we should have another "window" and we recheck the
+ * efficiency of the filter. If the fraction of matches is lower than 80%
+ * (per BLOOM_PROBE_THRESHOLD_LOW), we enable the filter again.
+ *
+ * This way we can enable/disable the filter for different parts of the
+ * data set, in case the distribution is not uniform in some way.
+ *
+ * XXX The gap between 80% and 90% is intentional. It adds hysteresis, so
+ * that the heuristics does not "flap" for datasets that oscillate right
+ * around ~90% matches.
+ *
+ * XXX Maybe 1000 and 1% is a bit too much, because we'll recheck after 100k
+ * lookups. Which seems like a lot, maybe we should recheck more often?
+ * Idea: Double the distance, i.e. cut the sample rate in half. We start
+ * with 1, so 100% is sampled. If disable, double sample to 2, so 50% is
+ * sampled, and the distance is 2. Then 4, 8, 16, 32, .... up to some upper
+ * limit (64k?). A change drops it to 1 again.
+ */
+
+/* minimum filter size, in bytes */
+#define BLOOM_MIN_FILTER_SIZE	(8 * 1024)
+
+/* adaptive filter build */
+#define BLOOM_BUILD_WINDOW		1000
+#define BLOOM_BUILD_THRESHOLD	0.9
+
+/* adaptive filter probing */
+#define BLOOM_PROBE_WINDOW				1000
+#define BLOOM_PROBE_THRESHOLD_HIGH		0.9
+#define BLOOM_PROBE_THRESHOLD_LOW		0.8
+#define BLOOM_PROBE_SAMPLE_RATE			100
+
 
 /* ----------------------------------------------------------------
  *		ExecHash
@@ -184,6 +275,12 @@ MultiExecPrivateHash(HashState *node)
 			uint32		hashvalue = DatumGetUInt32(hashdatum);
 			int			bucketNumber;
 
+			/* If a Bloom filter is already in use, record the hash in it. */
+			if (hashtable->bloomFilter != NULL)
+				bloom_add_element(hashtable->bloomFilter,
+								  (unsigned char *) &hashvalue,
+								  sizeof(uint32));
+
 			bucketNumber = ExecHashGetSkewBucket(hashtable, hashvalue);
 			if (bucketNumber != INVALID_SKEW_BUCKET_NO)
 			{
@@ -535,6 +632,16 @@ ExecHashTableCreate(HashState *state)
 	hashtable->totalTuples = 0;
 	hashtable->reportTuples = 0;
 	hashtable->skewTuples = 0;
+	hashtable->bloomFilter = NULL;
+	hashtable->bloomElements = rows;
+	hashtable->bloomSampling = false;
+	hashtable->bloomSampleCounter = 0;
+	hashtable->bloomSampleMatches = 0;
+	hashtable->bloomSampleProbes = 0;
+	hashtable->bloomProbes = 0;
+	hashtable->bloomMatches = 0;
+	hashtable->hashLookups = 0;
+	hashtable->hashMatches = 0;
 	hashtable->innerBatchFile = NULL;
 	hashtable->outerBatchFile = NULL;
 	hashtable->spaceUsed = 0;
@@ -663,11 +770,260 @@ ExecHashTableCreate(HashState *state)
 			ExecHashBuildSkewHash(state, hashtable, node, num_skew_mcvs);
 
 		MemoryContextSwitchTo(oldcxt);
+
+		/*
+		 * If we already expect to need more than one batch, start building a
+		 * Bloom filter right away so that it ends up containing every inner
+		 * tuple. (For nbatch=1 we start without a filter and may build one
+		 * later, either when we are forced to start batching or adaptively
+		 * while probing.)
+		 */
+		if (nbatch > 1)
+			ExecHashCreateBloomFilter(hashtable);
 	}
 
 	return hashtable;
 }
 
+/*
+ * ExecHashCreateBloomFilter
+ *		Create and empty bloom filter for the inner-side hash table.
+ *
+ * Creates an empty Bloom filter for the hashes of the inner join keys. The
+ * filter is created in hashCxt just like the hash table, so that it survives
+ * between batches etc.
+ *
+ * If the filter is not created at the beginning of the build, before any
+ * tuples are added to the hash table, it needs to be populated with hashes
+ * already added to the hash table. See ExecHashBuildBloomFilter.
+ *
+ * XXX Actually, could we destroy the filter after the first batch? At that
+ * point all outer tuples are already probed, so the filter is not needed. Or
+ * do we need to keep it for rescans?
+ */
+void
+ExecHashCreateBloomFilter(HashJoinTable hashtable)
+{
+	MemoryContext oldcxt;
+	int64		nelems;
+
+	Assert(hashtable->parallel_state == NULL);
+	Assert(hashtable->bloomFilter == NULL);
+
+	/* bail out if bloom filters disabled */
+	if (!enable_hashjoin_bloom)
+		return;
+
+	/*
+	 * Size the filter for the expected number of inner tuples. Use the larger
+	 * of the planner estimate and the number of tuples seen so far; the Bloom
+	 * filter implementation copes well with the estimate being somewhat off.
+	 *
+	 * XXX We know if we're building before the hash table is complete. If it's
+	 * complete, we've seen all tuples - no need to consider bloomElements.
+	 *
+	 * XXX Maybe we should use a multiple, to make it better in case of poor
+	 * estimates? But only if we build the filter while still reading the inner
+	 * relation. If we already saw all tuples, we size the filter perfectly.
+	 *
+	 * XXX We should also consider what to do if the filter can't fit into
+	 * the memory budget. We may try building a filter with worse false
+	 * positive rate, as long as the final match rate is low enough.
+	 */
+	nelems = (int64) Max(hashtable->bloomElements, hashtable->totalTuples);
+	nelems = Max(nelems, 1000);
+
+	oldcxt = MemoryContextSwitchTo(hashtable->hashCxt);
+	hashtable->bloomFilter = bloom_create_custom(nelems, work_mem,
+												 BLOOM_MIN_FILTER_SIZE, 0);
+	MemoryContextSwitchTo(oldcxt);
+}
+
+/*
+ * ExecHashBuildBloomFilter
+ *		Creates an empty Bloom filter, and populates it with current hashes.
+ *
+ * Creates an empty filter, and seeds it with the hashes of tuples already
+ * present in the hash table (both the main and skew hash table). Remaining
+ * tuples are added as they are inserted into the hash table.
+ */
+void
+ExecHashBuildBloomFilter(HashJoinTable hashtable)
+{
+	/* create an empty bloom filter */
+	ExecHashCreateBloomFilter(hashtable);
+
+	/* add tuples already stored in the main hash table */
+	for (HashMemoryChunk chunk = hashtable->chunks;
+		 chunk != NULL;
+		 chunk = chunk->next.unshared)
+	{
+		size_t		idx = 0;
+
+		while (idx < chunk->used)
+		{
+			HashJoinTuple hashTuple = (HashJoinTuple) (HASH_CHUNK_DATA(chunk) + idx);
+			MinimalTuple tuple = HJTUPLE_MINTUPLE(hashTuple);
+
+			bloom_add_element(hashtable->bloomFilter,
+							  (unsigned char *) &hashTuple->hashvalue,
+							  sizeof(uint32));
+
+			idx += MAXALIGN(HJTUPLE_OVERHEAD + tuple->t_len);
+		}
+	}
+
+	/* add tuples already stored in the skep hash table */
+	if (hashtable->skewEnabled)
+	{
+		for (int i = 0; i < hashtable->nSkewBuckets; i++)
+		{
+			int			j = hashtable->skewBucketNums[i];
+			HashJoinTuple skewTuple = hashtable->skewBucket[j]->tuples;
+
+			while (skewTuple != NULL)
+			{
+				bloom_add_element(hashtable->bloomFilter,
+								  (unsigned char *) &skewTuple->hashvalue,
+								  sizeof(uint32));
+				skewTuple = skewTuple->next.unshared;
+			}
+		}
+	}
+}
+
+/*
+ * ExecHashBloomReject
+ *		Should this hash value (for an outer tuple) be rejected?
+ *
+ * Returns true if a Bloom filter is in use and it proves that the given hash
+ * value (and therefore the outer tuple) cannot match any inner tuple.
+ *
+ * When sampling the filter probes, most tuples bypass the filter and the
+ * function returns false without consulting it.
+ */
+bool
+ExecHashBloomReject(HashJoinTable hashtable, uint32 hashvalue)
+{
+	bool	reject = false;
+
+	/*
+	 * Ignore the filter after processing the first batch (all tuples spilled
+	 * to temporary files already went through the check).
+	 */
+	if (hashtable->curbatch != 0)
+		return false;
+
+	/* If there's no filter, all tuples should pass. */
+	if (hashtable->bloomFilter == NULL)
+		return false;
+
+	/*
+	 * Probe the filter for the hash value, unless it should be skipped due to
+	 * sampling. With sampling enabled, we only probe the filter for one tuple
+	 * in BLOOM_PROBE_SAMPLE_RATE; the rest go straight to the hash table.
+	 */
+	if (!(hashtable->bloomSampling &&
+		  (hashtable->bloomSampleCounter++ % BLOOM_PROBE_SAMPLE_RATE) != 0))
+	{
+		hashtable->bloomProbes++;
+		if (bloom_lacks_element(hashtable->bloomFilter,
+								(unsigned char *) &hashvalue,
+								sizeof(uint32)))
+		{
+			hashtable->bloomRejects++;
+			reject = true;
+		}
+
+		if (!reject)
+			hashtable->bloomMatches++;
+
+		/* record the result and adjust the sampling state */
+		ExecHashBloomSamplingUpdate(hashtable, !reject);
+	}
+
+	return reject;
+}
+
+/*
+ * ExecHashBloomSamplingUpdate
+ *		Record the outcome of a filter probe and adjust the filter behavior.
+ *
+ * "match" indicates whether the filter probe rejected the hash value, so that
+ * the tuple can be eliminated. We track the fraction of matches over a sliding
+ * window of BLOOM_PROBE_WINDOW probes, and use it to enable/disable sampling.
+ * If too many probes find a match, we let most probes through, except for a
+ * small sample. Once the fraction of matches drops, we stop sampling.
+ */
+void
+ExecHashBloomSamplingUpdate(HashJoinTable hashtable, bool match)
+{
+	double		fraction;
+
+	/* Record the probe and the result in the current window. */
+	hashtable->bloomSampleProbes++;
+	if (match)
+		hashtable->bloomSampleMatches++;
+
+	/* Wait until we have a full window before reassessing. */
+	if (hashtable->bloomSampleProbes < BLOOM_PROBE_WINDOW)
+		return;
+
+	/* fraction of probes that found a (possible) match */
+	fraction = (double) hashtable->bloomSampleMatches / hashtable->bloomSampleProbes;
+
+	/* if the match rate is too high, start sampling */
+	if (fraction > BLOOM_PROBE_THRESHOLD_HIGH)
+		hashtable->bloomSampling = true;
+
+	/* if the match rate is lowe enough, stop sampling */
+	if (fraction < BLOOM_PROBE_THRESHOLD_LOW)
+		hashtable->bloomSampling = false;
+
+	/* reset the sample window */
+	hashtable->bloomSampleCounter = 0;
+	hashtable->bloomSampleMatches = 0;
+	hashtable->bloomSampleProbes = 0;
+}
+
+/*
+ * ExecHashBloomAccountLookup
+ *		Account for hash table lookup, and maybe create the Bloom filter.
+ */
+void
+ExecHashBloomAccountLookup(HashJoinTable hashtable)
+{
+	hashtable->hashMatches++;
+
+	/* Bail out if Bloom filters are disabled. */
+	if (!enable_hashjoin_bloom)
+		return;
+
+	/* If the filter is already built, we're done. */
+	if (hashtable->bloomFilter != NULL)
+		return;
+
+	/* We can't build filters for parallel hash joins. */
+	if (hashtable->parallel_state != NULL)
+		return;
+
+	/* All serial batched runs should have a filter created automatically. */
+	Assert(hashtable->nbatch == 1);
+
+	/*
+	 * Build a filter if the hash table lookups found sufficiently few matches
+	 * so far. We recheck regularly, after each window of lookups.
+	 *
+	 * XXX Maybe we should reset the counters, just like for filter probes? That
+	 * would mean we look at individual windows, while now we look at the whole
+	 * history of lookups. Not sure if one of these is a "more right".
+	 */
+	if (((hashtable->hashLookups % BLOOM_BUILD_WINDOW) == 0) &&
+		(hashtable->hashMatches < hashtable->hashLookups * BLOOM_BUILD_THRESHOLD))
+	{
+		ExecHashBuildBloomFilter(hashtable);
+	}
+}
 
 /*
  * Compute appropriate size for hashtable given the estimated size of the
@@ -1103,6 +1459,15 @@ ExecHashIncreaseNumBatches(HashJoinTable hashtable)
 
 	hashtable->nbatch = nbatch;
 
+	/*
+	 * Build the Bloom filter, if we're switching from a single batch to multiple
+	 * batches, so that it contains all inner tuples loaded so far. Remaining
+	 * tuples will be added as they are loaded from the inner plan, so the filter
+	 * will contain cover all batches.
+	 */
+	if (oldnbatch == 1)
+		ExecHashBuildBloomFilter(hashtable);
+
 	/*
 	 * Scan through the existing hash table entries and dump out any that are
 	 * no longer of the current batch.
@@ -2945,6 +3310,31 @@ ExecHashAccumInstrumentation(HashInstrumentation *instrument,
 									  hashtable->nbatch_original);
 	instrument->space_peak = Max(instrument->space_peak,
 								 hashtable->spacePeak);
+
+	/*
+	 * Record Bloom filter information, if a filter was built.
+	 *
+	 * XXX Shouldn't this use Max(), just like the block above?
+	 */
+	if (hashtable->bloomFilter != NULL)
+	{
+		instrument->bloom_used = true;
+		instrument->bloom_nhashfuncs =
+			bloom_num_hash_funcs(hashtable->bloomFilter);
+		instrument->bloom_nbytes = bloom_total_bits(hashtable->bloomFilter) / BITS_PER_BYTE;
+		instrument->bloom_false_positive_rate =
+			bloom_false_positive_rate(hashtable->bloomFilter);
+		instrument->bloom_nprobes = hashtable->bloomProbes;
+		instrument->bloom_nmatches = hashtable->bloomMatches;
+	}
+
+	/*
+	 * Record hash-table probe statistics.
+	 *
+	 * XXX Shouldn't this use Max(), just like the earlier block?
+	 */
+	instrument->hash_nlookups = hashtable->hashLookups;
+	instrument->hash_nmatches = hashtable->hashMatches;
 }
 
 /*
diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 0b365d5b475..db14cf98f9b 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -170,6 +170,7 @@
 #include "executor/nodeHash.h"
 #include "executor/nodeHashjoin.h"
 #include "miscadmin.h"
+#include "optimizer/cost.h"
 #include "utils/lsyscache.h"
 #include "utils/sharedtuplestore.h"
 #include "utils/tuplestore.h"
@@ -500,6 +501,20 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 																 hashvalue);
 				node->hj_CurTuple = NULL;
 
+				/*
+				 * Consult the inner-relation Bloom filter, if any, before
+				 * probing the hash table. A negative answer means this outer
+				 * tuple cannot match in any batch: we can skip both the
+				 * hash-table lookup and any spilling to a later batch. Jumping to
+				 * HJ_FILL_OUTER_TUPLE emits a null-extended row for outer joins
+				 * and simply discards the tuple otherwise.
+				 */
+				if (!parallel && ExecHashBloomReject(hashtable, hashvalue))
+				{
+					node->hj_JoinState = HJ_FILL_OUTER_TUPLE;
+					continue;
+				}
+
 				/*
 				 * The tuple might not belong to the current batch (where
 				 * "current batch" includes the skew buckets if any).
@@ -531,6 +546,9 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 				/* OK, let's scan the bucket for matches */
 				node->hj_JoinState = HJ_SCAN_BUCKET;
 
+				/* Count this as a lookup in the hash table. */
+				hashtable->hashLookups++;
+
 				pg_fallthrough;
 
 			case HJ_SCAN_BUCKET:
@@ -565,6 +583,18 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 					HeapTupleHeaderHasMatch(HJTUPLE_MINTUPLE(node->hj_CurTuple)))
 					continue;
 
+				/*
+				 * Count the first match found for this outer tuple (may create
+				 * the Bloom filter, if sufficienly few matches.
+				 *
+				 * If an outer tuple has multiple matching inner tuples, we want
+				 * it to count as a single match, so that it's comparable to
+				 * counters for the Bloom filter (which also counts each outer
+				 * as a single probe).
+				 */
+				if (!node->hj_MatchedOuter)
+					ExecHashBloomAccountLookup(hashtable);
+
 				/*
 				 * We've got a match, but still need to test non-hashed quals.
 				 * ExecScanHashBucket already set up all the state needed to
diff --git a/src/backend/lib/bloomfilter.c b/src/backend/lib/bloomfilter.c
index 73b3768a172..bb04aa600e8 100644
--- a/src/backend/lib/bloomfilter.c
+++ b/src/backend/lib/bloomfilter.c
@@ -39,6 +39,13 @@
 #include "lib/bloomfilter.h"
 #include "port/pg_bitutils.h"
 
+/*
+ * Default minimum size of the bitset, in bytes. bloom_create() won't create
+ * a bitset smaller than this, even when the caller's total_elems estimate would
+ * suggest a smaller one.
+ */
+#define DEFAULT_MIN_BITSET_BYTES	(1024 * 1024)
+
 #define MAX_HASH_FUNCS		10
 
 struct bloom_filter
@@ -74,17 +81,26 @@ static inline uint32 mod_m(uint32 val, uint64 m);
  * bits, and the largest possible bitset is 512MB (2^32 bits).  The
  * implementation allocates only enough memory to target its standard false
  * positive rate, using a simple formula with caller's total_elems estimate as
- * an input.  The bitset might be as small as 1MB, even when bloom_work_mem is
- * much higher.
+ * an input.  The bitset might be as small as min_bitset_bytes, even when
+ * bloom_work_mem is much higher.
  *
  * The Bloom filter is seeded using a value provided by the caller.  Using a
  * distinct seed value on every call makes it unlikely that the same false
  * positives will reoccur when the same set is fingerprinted a second time.
  * Callers that don't care about this pass a constant as their seed, typically
  * 0.  Callers can also use a pseudo-random seed, eg from pg_prng_uint64().
+ *
+ * min_bitset_bytes is the minimum bitset size. The bitset might be as small
+ * as 1KiB, even when bloom_work_mem is much higher. This is useful for callers
+ * that want to allow filters smaller than the default DEFAULT_MIN_BITSET_BYTES
+ * (1MB), for example when fingerprinting small sets where the 1MB minimum
+ * would waste memory and would not fit into CPU caches. The bitset is still
+ * sized as a power of two number of bits, and is never smaller than this
+ * minimum (subject to that rounding).
  */
 bloom_filter *
-bloom_create(int64 total_elems, int bloom_work_mem, uint64 seed)
+bloom_create_custom(int64 total_elems, int bloom_work_mem,
+					uint64 min_bitset_bytes, uint64 seed)
 {
 	bloom_filter *filter;
 	int			bloom_power;
@@ -99,7 +115,7 @@ bloom_create(int64 total_elems, int bloom_work_mem, uint64 seed)
 	 * false positive rate still won't exceed 2% in almost all cases.
 	 */
 	bitset_bytes = Min(bloom_work_mem * UINT64CONST(1024), total_elems * 2);
-	bitset_bytes = Max(1024 * 1024, bitset_bytes);
+	bitset_bytes = Max(min_bitset_bytes, bitset_bytes);
 
 	/*
 	 * Size in bits should be the highest power of two <= target.  bitset_bits
@@ -119,6 +135,17 @@ bloom_create(int64 total_elems, int bloom_work_mem, uint64 seed)
 	return filter;
 }
 
+/*
+ * Create Bloom filter in caller's memory context, like bloom_create_custom(),
+ * but with the minimum bitset size set to DEFAULT_MIN_BITSET_BYTES (i.e. 1MB).
+ */
+bloom_filter *
+bloom_create(int64 total_elems, int bloom_work_mem, uint64 seed)
+{
+	return bloom_create_custom(total_elems, bloom_work_mem, seed,
+							   DEFAULT_MIN_BITSET_BYTES);
+}
+
 /*
  * Free Bloom filter
  */
@@ -192,6 +219,39 @@ bloom_prop_bits_set(bloom_filter *filter)
 	return bits_set / (double) filter->m;
 }
 
+/*
+ * Returns the number of hash functions used by this Bloom filter.
+ */
+int
+bloom_num_hash_funcs(bloom_filter *filter)
+{
+	return filter->k_hash_funcs;
+}
+
+/*
+ * Returns the total size of the Bloom filter's bitset, in bits.
+ */
+uint64
+bloom_total_bits(bloom_filter *filter)
+{
+	return filter->m;
+}
+
+/*
+ * Estimate the current false positive rate of the Bloom filter.
+ *
+ * For a filter that uses k hash functions, the probability that a membership
+ * test for an element that was never added still reports "possibly present" is
+ * approximately p^k, where p is the proportion of bits currently set. This
+ * reflects the actual contents of the filter rather than the target rate aimed
+ * for at creation time.
+ */
+double
+bloom_false_positive_rate(bloom_filter *filter)
+{
+	return pow(bloom_prop_bits_set(filter), filter->k_hash_funcs);
+}
+
 /*
  * Which element in the sequence of powers of two is less than or equal to
  * target_bitset_bits?
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 1c575e56ff6..c3072a29ccc 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -156,6 +156,7 @@ bool		enable_material = true;
 bool		enable_memoize = true;
 bool		enable_mergejoin = true;
 bool		enable_hashjoin = true;
+bool		enable_hashjoin_bloom = true;
 bool		enable_gathermerge = true;
 bool		enable_partitionwise_join = false;
 bool		enable_partitionwise_aggregate = false;
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b046..eb75cf4c5a2 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -920,6 +920,13 @@
   boot_val => 'true',
 },
 
+{ name => 'enable_hashjoin_bloom', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
+  short_desc => 'Enables the use of a Bloom filter to prefilter hash join probes.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'enable_hashjoin_bloom',
+  boot_val => 'true',
+},
+
 { name => 'enable_incremental_sort', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
   short_desc => 'Enables the planner\'s use of incremental sort steps.',
   flags => 'GUC_EXPLAIN',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..c598504fe25 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -428,6 +428,7 @@
 #enable_gathermerge = on
 #enable_hashagg = on
 #enable_hashjoin = on
+#enable_hashjoin_bloom = on
 #enable_incremental_sort = on
 #enable_indexscan = on
 #enable_indexonlyscan = on
diff --git a/src/include/executor/hashjoin.h b/src/include/executor/hashjoin.h
index 4d342174b9a..62d43c7dab4 100644
--- a/src/include/executor/hashjoin.h
+++ b/src/include/executor/hashjoin.h
@@ -14,6 +14,7 @@
 #ifndef HASHJOIN_H
 #define HASHJOIN_H
 
+#include "lib/bloomfilter.h"
 #include "nodes/execnodes.h"
 #include "port/atomics.h"
 #include "storage/barrier.h"
@@ -338,6 +339,44 @@ typedef struct HashJoinTableData
 
 	bool		growEnabled;	/* flag to shut off nbatch increases */
 
+	/*
+	 * Optional Bloom filter built on the hashes of the inner relation's join
+	 * keys (the same hash values used by the hash table).  When present, it is
+	 * consulted before probing the hash table to discard outer tuples that
+	 * cannot have a match.  It always contains the hashes of every inner
+	 * tuple, so a negative answer is conclusive across all batches.  This is
+	 * only used by the non-parallel hash join.
+	 */
+	bloom_filter *bloomFilter;	/* Bloom filter, or NULL if not used */
+	double		bloomElements;	/* estimated number of inner tuples */
+	int64		bloomProbes;	/* hash-table probes in the current window */
+	int64		bloomMatches;	/* matches among those probes */
+	bool		bloomSampling;	/* only probe the filter for a sample? */
+	uint64		bloomSampleCounter; /* counter used while sampling */
+	uint64		bloomSampleProbes;  /* counter used while sampling */
+	uint64		bloomSampleMatches; /* counter used while sampling */
+
+	/*
+	 * Cumulative Bloom filter probe statistics, retained for the lifetime of
+	 * the join so EXPLAIN ANALYZE can report how effective the filter was.
+	 * bloomLookups counts how many outer tuples were actually checked against
+	 * the filter, and bloomRejects how many of those were discarded because
+	 * the filter proved they could not match.  Unlike bloomProbes/bloomMatches
+	 * above, these are never reset.
+	 */
+	int64		bloomLookups;	/* outer tuples tested against the filter */
+	int64		bloomRejects;	/* outer tuples rejected by the filter */
+
+	/*
+	 * Cumulative hash-table probe statistics, retained for the lifetime of the
+	 * join.  hashLookups counts how many outer tuples actually probed the hash
+	 * table, and hashMatches how many of those found at least one matching
+	 * inner tuple.  Outer tuples eliminated by the Bloom filter never probe the
+	 * hash table and so are not counted here.
+	 */
+	int64		hashLookups;	/* outer tuples that probed the hash table */
+	int64		hashMatches;	/* probes that found a matching inner tuple */
+
 	/*
 	 * totalTuples is the running total of tuples inserted into either the
 	 * main or skew hash tables.  reportTuples is the number of tuples that we
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index 4076990408e..215e03d5529 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -227,6 +227,18 @@ typedef struct HashInstrumentation
 	int			nbatch;			/* number of batches at end of execution */
 	int			nbatch_original;	/* planned number of batches */
 	Size		space_peak;		/* peak memory usage in bytes */
+
+	/* Bloom filter statistics (only the non-parallel hash join builds one) */
+	bool		bloom_used;		/* was a Bloom filter built? */
+	int			bloom_nhashfuncs;	/* number of hash functions used */
+	uint64		bloom_nbytes;	/* size of the filter's bitset, in bytes */
+	double		bloom_false_positive_rate;	/* estimated false positive rate */
+	int64		bloom_nprobes;	/* number of filter probes */
+	int64		bloom_nmatches;	/* number of probes matching the filter */
+
+	/* Hash table probe statistics */
+	int64		hash_nlookups;	/* outer tuples that probed the hash table */
+	int64		hash_nmatches;	/* probes that found a matching inner tuple */
 } HashInstrumentation;
 
 /*
diff --git a/src/include/executor/nodeHash.h b/src/include/executor/nodeHash.h
index 9ff493b627a..a5f45e55875 100644
--- a/src/include/executor/nodeHash.h
+++ b/src/include/executor/nodeHash.h
@@ -36,6 +36,11 @@ extern void ExecParallelHashTableSetCurrentBatch(HashJoinTable hashtable,
 extern void ExecHashTableInsert(HashJoinTable hashtable,
 								TupleTableSlot *slot,
 								uint32 hashvalue);
+extern void ExecHashCreateBloomFilter(HashJoinTable hashtable);
+extern void ExecHashBuildBloomFilter(HashJoinTable hashtable);
+extern bool ExecHashBloomReject(HashJoinTable hashtable, uint32 hashvalue);
+extern void ExecHashBloomSamplingUpdate(HashJoinTable hashtable, bool matched);
+extern void ExecHashBloomAccountLookup(HashJoinTable hashtable);
 extern void ExecParallelHashTableInsert(HashJoinTable hashtable,
 										TupleTableSlot *slot,
 										uint32 hashvalue);
diff --git a/src/include/lib/bloomfilter.h b/src/include/lib/bloomfilter.h
index 860ee9bdc72..8b705319f82 100644
--- a/src/include/lib/bloomfilter.h
+++ b/src/include/lib/bloomfilter.h
@@ -17,11 +17,16 @@ typedef struct bloom_filter bloom_filter;
 
 extern bloom_filter *bloom_create(int64 total_elems, int bloom_work_mem,
 								  uint64 seed);
+extern bloom_filter *bloom_create_custom(int64 total_elems, int bloom_work_mem,
+										 uint64 min_bitset_bytes, uint64 seed);
 extern void bloom_free(bloom_filter *filter);
 extern void bloom_add_element(bloom_filter *filter, unsigned char *elem,
 							  size_t len);
 extern bool bloom_lacks_element(bloom_filter *filter, unsigned char *elem,
 								size_t len);
 extern double bloom_prop_bits_set(bloom_filter *filter);
+extern int bloom_num_hash_funcs(bloom_filter *filter);
+extern uint64 bloom_total_bits(bloom_filter *filter);
+extern double bloom_false_positive_rate(bloom_filter *filter);
 
 #endif							/* BLOOMFILTER_H */
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index f2fd5d31507..7339979c008 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -62,6 +62,7 @@ extern PGDLLIMPORT bool enable_material;
 extern PGDLLIMPORT bool enable_memoize;
 extern PGDLLIMPORT bool enable_mergejoin;
 extern PGDLLIMPORT bool enable_hashjoin;
+extern PGDLLIMPORT bool enable_hashjoin_bloom;
 extern PGDLLIMPORT bool enable_gathermerge;
 extern PGDLLIMPORT bool enable_partitionwise_join;
 extern PGDLLIMPORT bool enable_partitionwise_aggregate;
diff --git a/src/test/regress/expected/join_hash_bloom.out b/src/test/regress/expected/join_hash_bloom.out
new file mode 100644
index 00000000000..c9b5bdc66c9
--- /dev/null
+++ b/src/test/regress/expected/join_hash_bloom.out
@@ -0,0 +1,178 @@
+CREATE TABLE hash_bloom_fact (id int, did int, padding text);
+CREATE TABLE hash_bloom_dimension (id int, r float, padding text);
+-- fact is 10x the dimension size
+SELECT setseed(0); -- stabilize random() output
+ setseed 
+---------
+ 
+(1 row)
+
+INSERT INTO hash_bloom_fact SELECT i, 1 + mod(i, 10000), md5(i::text) FROM generate_series(1,100000) s(i);
+INSERT INTO hash_bloom_dimension SELECT i, random(), md5(i::text) FROM generate_series(1,10000) s(i);
+VACUUM ANALYZE hash_bloom_fact;
+VACUUM ANALYZE hash_bloom_dimension;
+-- no parallel queries for now, force hashjoins
+SET max_parallel_workers_per_gather = 0;
+SET enable_nestloop = off;
+SET enable_mergejoin = off;
+SET work_mem = '512kB';
+-- non-selective in-memory hash join does not use Bloom filters
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Hash Join (actual rows=100000.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=10000.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 16384  Batches: 1  Memory Usage: 920kB
+         Hash Lookups: 100000  Matches: 100000  Match Rate: 100.000%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=10000.00 loops=1)
+               Output: d.id, d.r, d.padding
+(11 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Hash Join (actual rows=100000.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=10000.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 16384  Batches: 1  Memory Usage: 920kB
+         Hash Lookups: 100000  Matches: 100000  Match Rate: 100.000%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=10000.00 loops=1)
+               Output: d.id, d.r, d.padding
+(11 rows)
+
+-- a selective in-memory join uses a filter (after 1000 lookups)
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join (actual rows=50180.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=5018.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 8192  Batches: 1  Memory Usage: 461kB
+         Hash Lookups: 100000  Matches: 50180  Match Rate: 50.180%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=5018.00 loops=1)
+               Output: d.id, d.r, d.padding
+               Filter: (d.r < '0.5'::double precision)
+               Rows Removed by Filter: 4982
+(13 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join (actual rows=50180.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=5018.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 8192  Batches: 1  Memory Usage: 461kB
+         Hash Lookups: 52754  Matches: 50180  Match Rate: 95.121%
+         Bloom Filter: Size: 8kB  Hash Functions: 9  False Positive Rate: 0.191%
+         Bloom Filter Probes: 95000  Matches: 47754  Match Rate: 50.267%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=5018.00 loops=1)
+               Output: d.id, d.r, d.padding
+               Filter: (d.r < '0.5'::double precision)
+               Rows Removed by Filter: 4982
+(15 rows)
+
+-- force batching
+SET work_mem = '128kB';
+-- batched join always creates a Bloom filter, but then disables it if
+-- not selective enough
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Hash Join (actual rows=100000.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=10000.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 4096  Batches: 4  Memory Usage: 229kB
+         Hash Lookups: 100000  Matches: 100000  Match Rate: 100.000%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=10000.00 loops=1)
+               Output: d.id, d.r, d.padding
+(11 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Hash Join (actual rows=100000.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=10000.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 4096  Batches: 4  Memory Usage: 229kB
+         Hash Lookups: 100000  Matches: 100000  Match Rate: 100.000%
+         Bloom Filter: Size: 16kB  Hash Functions: 9  False Positive Rate: 0.187%
+         Bloom Filter Probes: 1990  Matches: 1990  Match Rate: 100.000%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=10000.00 loops=1)
+               Output: d.id, d.r, d.padding
+(13 rows)
+
+-- batched join always creates a Bloom filter, and keeps using it if
+-- selective enough
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join (actual rows=50180.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=5018.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 4096  Batches: 2  Memory Usage: 228kB
+         Hash Lookups: 100000  Matches: 50180  Match Rate: 50.180%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=5018.00 loops=1)
+               Output: d.id, d.r, d.padding
+               Filter: (d.r < '0.5'::double precision)
+               Rows Removed by Filter: 4982
+(13 rows)
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join (actual rows=50180.00 loops=1)
+   Output: f.id, f.did, f.padding, d.id, d.r, d.padding
+   Hash Cond: (f.did = d.id)
+   ->  Seq Scan on public.hash_bloom_fact f (actual rows=100000.00 loops=1)
+         Output: f.id, f.did, f.padding
+   ->  Hash (actual rows=5018.00 loops=1)
+         Output: d.id, d.r, d.padding
+         Buckets: 4096  Batches: 2  Memory Usage: 228kB
+         Hash Lookups: 50250  Matches: 50180  Match Rate: 99.861%
+         Bloom Filter: Size: 8kB  Hash Functions: 9  False Positive Rate: 0.191%
+         Bloom Filter Probes: 100000  Matches: 50250  Match Rate: 50.250%
+         ->  Seq Scan on public.hash_bloom_dimension d (actual rows=5018.00 loops=1)
+               Output: d.id, d.r, d.padding
+               Filter: (d.r < '0.5'::double precision)
+               Rows Removed by Filter: 4982
+(15 rows)
+
+DROP TABLE hash_bloom_fact;
+DROP TABLE hash_bloom_dimension;
diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out
index 132b56a5864..a796e431415 100644
--- a/src/test/regress/expected/sysviews.out
+++ b/src/test/regress/expected/sysviews.out
@@ -163,6 +163,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_group_by_reordering     | on
  enable_hashagg                 | on
  enable_hashjoin                | on
+ enable_hashjoin_bloom          | on
  enable_incremental_sort        | on
  enable_indexonlyscan           | on
  enable_indexscan               | on
@@ -180,7 +181,7 @@ select name, setting from pg_settings where name like 'enable%';
  enable_seqscan                 | on
  enable_sort                    | on
  enable_tidscan                 | on
-(25 rows)
+(26 rows)
 
 -- There are always wait event descriptions for various types.  InjectionPoint
 -- may be present or absent, depending on history since last postmaster start.
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..095a3fea981 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -71,7 +71,7 @@ test: brin gin gist spgist privileges init_privs security_label collate matview
 # ----------
 # Additional BRIN tests
 # ----------
-test: brin_bloom brin_multi
+test: brin_bloom brin_multi join_hash_bloom
 
 # ----------
 # Another group of parallel tests
diff --git a/src/test/regress/sql/join_hash_bloom.sql b/src/test/regress/sql/join_hash_bloom.sql
new file mode 100644
index 00000000000..b62e0b2ed90
--- /dev/null
+++ b/src/test/regress/sql/join_hash_bloom.sql
@@ -0,0 +1,56 @@
+CREATE TABLE hash_bloom_fact (id int, did int, padding text);
+CREATE TABLE hash_bloom_dimension (id int, r float, padding text);
+
+-- fact is 10x the dimension size
+SELECT setseed(0); -- stabilize random() output
+INSERT INTO hash_bloom_fact SELECT i, 1 + mod(i, 10000), md5(i::text) FROM generate_series(1,100000) s(i);
+INSERT INTO hash_bloom_dimension SELECT i, random(), md5(i::text) FROM generate_series(1,10000) s(i);
+
+VACUUM ANALYZE hash_bloom_fact;
+VACUUM ANALYZE hash_bloom_dimension;
+
+-- no parallel queries for now, force hashjoins
+SET max_parallel_workers_per_gather = 0;
+SET enable_nestloop = off;
+SET enable_mergejoin = off;
+SET work_mem = '512kB';
+
+-- non-selective in-memory hash join does not use Bloom filters
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+-- a selective in-memory join uses a filter (after 1000 lookups)
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+-- force batching
+SET work_mem = '128kB';
+
+-- batched join always creates a Bloom filter, but then disables it if
+-- not selective enough
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id);
+
+-- batched join always creates a Bloom filter, and keeps using it if
+-- selective enough
+
+SET enable_hashjoin_bloom = off;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+SET enable_hashjoin_bloom = on;
+EXPLAIN (ANALYZE, VERBOSE, TIMING OFF, COSTS OFF, BUFFERS OFF, SUMMARY OFF) SELECT * FROM hash_bloom_fact f JOIN hash_bloom_dimension d ON (f.did = d.id) WHERE d.r < 0.5;
+
+DROP TABLE hash_bloom_fact;
+DROP TABLE hash_bloom_dimension;
-- 
2.54.0

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 2 Jun 2026 17:46:56 +0200

On 5/31/26 17:03, Andrew Dunstan wrote:
> ..>
> Patch 2 provides for building per-key filters in addition to the multi-
> key filter if that flag is set. There may be other cases that would want
> it, but this would suit my immediate use case.
> 

Thanks. I'll take a look when I have time.

> that it acts as expected. I will start doing some benchmarks.

OK. I think it's enough for testing, i.e. to see if it's actually worth
pursuing further. But I think we'll eventually need to solve the issues
planning/costing somehow. I'm not sure it'll be committable without
having some sort of solution.

I happened to find this 2025 paper:

    Including Bloom Filters in Bottom-up Optimization
    https://arxiv.org/html/2505.02994v1

I read it over the weekend, and interestingly enough it's exactly about
the planning issues I outlined last week, i.e. difficulties with costing
paths that might include pushed-down Bloom filters.

They even describe a solution that kinda looks a bit like "tracking a
separate set of paths" from my e-mail, although they use somewhat
different terminology (sub-plan == our path, etc.). But if you squint a
little bit, it talks about the costing issue, path explosion, etc.

Their solutions is some sort of two-phase process, which I'm not sure we
can do. It'd require a fundamental rework of how we construct join rels
and all that, and TBH I don't have an ambition to do that.

But while reading the paper, I kept thinking about how we deal with
pathkeys. I wonder if we could do something similar to that? That is,
have a concept of "potentially interesting" filters, and construct the
extra paths only for those, to limit the number of extra paths.

Imagine we construct the the baserels (essentially scan nodes), and then
do a pass over those. Each scan would look at what joins it participates
in, and which of those could benefit from a Bloom filter (some can't,
because it's a FK join, or we don't expect many rejected tuples, or
maybe it's a LEFT JOIN, ... etc.).

And then we'd maybe have some additional heuristics to pick which "Bloom
filters" to attach to the path. And then later, when planning that
particular join involving that path, we'd reject the join if it's not a
hash join. The scan would always have to construct a "clean path" not
requiring any filters, similarly to what custom scans need to do for
parallel paths.

It's just a rough idea, but I think it would work. Worth a try.

regards

-- 
Tomas Vondra

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 2 Jun 2026 18:01:59 +0200

> for 'unmatched' tuples as a separate task.

Not sure how treating it as a separate task solves that? In any case,
the patches I posted a couple minutes ago (for filters in the scope of a
single hashjoin, but the problem is the same) deal with this by delaying
the build until execution time, when we have better idea how many outer
tuples match the hash table.

> From what I can tell, Apache Impala [1] and Spark [2] use the same approach.
> 

It's not clear how any of these solve the issue I described (about
sizing the filter and the trade offs). The links just say that the
feature exist.

> In my experience, the outer side often has a complex subtree and is sometimes
> capped by a GROUP BY statement, or even a HAVING clause, which can break all
> estimations. A bloom filter might help if there is an accidental misestimate.
> 

Perhaps, but with substantial misestimates all bets are off. Maybe it'd
be better to discuss a particular example.

> This approach should not cause any issues. It is likely a reasonable way to
> improve performance without expanding the optimisation scope, which would
> increase planning time. We can always adjust it later if needed.
> For example, I am designing the post-optimising NestLoop 'lazy join' [3] using
> the 'gating' concept.
> 

I agree, except that it also makes EXPLAIN pretty difficult to
interpret, because it "breaks" the row counts.

> People are often confused when trying to understand the correctness of
> estimation for parallel plans and, in some cases, MergeJoin plans. Personally, I
> don't think it's a big issue.
> 

I disagree. The fact that people may be confused by plans does not mean
we can just make plans confusing for everyone.

> Overall, I think there are even more useful ways to apply bloom filters in the
> planner:
> 1. Real-time partition pruning
> 2. FDW pushed-down filters, which are especially helpful for sharded tables.
> 3. Skipping storage layer blocks. I know of at least one attempt to use the
> BRIN+FSM approach to avoid reading parts of a large table that definitely don't
> match the filter. Bloom filters could be used here as well.
> 

Could be. I speculated about options (2) and (3) myself elsewhere in
this thread.

regards

-- 
Tomas Vondra

---

From: Oleg Bartunov <[email-masked]>
Date: Wed, 3 Jun 2026 10:20:35 +0100

> me briefly revisit that, before I get to the new patch.

Bloom filters have two rather different roles here.

For a local Hash Join optimization, Bloom does not require any particular
physical ordering of the heap. It can be useful simply when the join is
selective enough, or when batching/spilling makes failed probes expensive:
the Bloom filter rejects many outer tuples before a full hash-table probe
or before writing them to temporary batches.

But once we talk about pushing a runtime filter down to the scan/storage
layer, the physical preconditions become crucial. To get more than a cheap
per-row check, the scan must have something coarse-grained to skip:
partitions, row groups, chunks, block ranges, dictionaries, min/max
metadata, BRIN-like summaries, etc. Without that, the filter is still
correct, but the benefit is mostly CPU/probe reduction rather than avoiding
data production.

So for me the most interesting part of this thread is not Bloom itself, but
the architectural idea: pushing runtime knowledge down to the scan node,
against the normal direction of data flow. The build side of a join
produces compact knowledge about admissible keys, and lower layers may use
it before rows are materialized and sent upward.

I saw this in my own experiments with zone/chunk-oriented storage for
Postgres: static predicates could prune zones nicely, but joins were the
hard case because the useful filtering knowledge was produced above the
scan. A runtime semi-join filter pushed from the Hash Join build side into
the scan could turn join-derived knowledge into scan-level pruning.

For example:

  SELECT sum(e.cost)
  FROM events e
  JOIN accounts a ON e.account_id = a.id
  WHERE a.region = 'NP'; -- Nepal

The events scan does not know which account_id values are EU accounts. That
knowledge is produced above it, on the build side of the join. A runtime
semi-join filter pushed from the Hash Join build side down into the events
scan could let the scan reject impossible account_id values before
producing tuples.

For a plain heap scan this may mostly save hash probes. But with
zone/chunk-oriented storage, where chunks have dictionaries, min/max
metadata, Bloom summaries, or tenant ranges, the same runtime filter can
skip whole chunks. That is the part I find most interesting: turning
join-derived knowledge into scan-level pruning, against the normal
direction of data flow.

Bloom is just one carrier for that knowledge. The real feature is a
pluggable runtime-filter mechanism that heap, CustomScan, FDW,
columnar/table AMs, partitioned storage, or chunk/cold storage can consume
at the level they understand.

This may be a topic for a separate thread, because it quickly becomes less
about Hash Join Bloom filters and more about runtime knowledge pushdown
into storage.

> regards
>
>
> [1]
> https://www.postgresql.org/message-id/5670946E.8070705%402ndquadrant.com
>
> [2]
>

---

From: Tomas Vondra <[email-masked]>
Date: Wed, 3 Jun 2026 12:07:40 +0200

On 6/3/26 11:20, Oleg Bartunov wrote:
> ...
> For a local Hash Join optimization, Bloom does not require any
> particular physical ordering of the heap. It can be useful simply when
> the join is selective enough, or when batching/spilling makes failed
> probes expensive: the Bloom filter rejects many outer tuples before a
> full hash-table probe or before writing them to temporary batches.
> 

Right. Adding a filter within a hash join is certainly less ambitious,
and the possible benefits are smaller.

> layer, the physical preconditions become crucial. To get more than a
> cheap per-row check, the scan must have something coarse-grained to
> skip: partitions, row groups, chunks, block ranges, dictionaries, min/
> max metadata, BRIN-like summaries, etc. Without that, the filter is
> still correct, but the benefit is mostly CPU/probe reduction rather than
> avoiding data production.
> 

Maybe, but there's also ongoing work on adding batches to the executor,
in which case we'd eliminate "row groups" even when using a filter in
the scope of a hashjoin operator. Of course, the tuples will flow all
the way up to that operator.

> So for me the most interesting part of this thread is not Bloom itself,
> but the architectural idea: pushing runtime knowledge down to the scan
> node, against the normal direction of data flow. The build side of a
> join produces compact knowledge about admissible keys, and lower layers
> may use it before rows are materialized and sent upward.
> 
> scan. A runtime semi-join filter pushed from the Hash Join build side
> into the scan could turn join-derived knowledge into scan-level pruning.
> 
> For example:
> 
>   SELECT sum(e.cost)
>   FROM events e
>   JOIN accounts a ON e.account_id = a.id <http://a.id>
> The events scan does not know which account_id values are EU accounts.
> That knowledge is produced above it, on the build side of the join. A
> runtime semi-join filter pushed from the Hash Join build side down into
> the events scan could let the scan reject impossible account_id values
> before producing tuples.
> 

Yes. This is known as "predicate transfer" in academic papers.

> For a plain heap scan this may mostly save hash probes. But with zone/
> chunk-oriented storage, where chunks have dictionaries, min/max
> pluggable runtime-filter mechanism that heap, CustomScan, FDW, columnar/
> table AMs, partitioned storage, or chunk/cold storage can consume at the
> level they understand.
> 
> This may be a topic for a separate thread, because it quickly becomes
> less about Hash Join Bloom filters and more about runtime knowledge
> pushdown into storage.
> 

Right, there's a general concept of a "filter", and Bloom filters are
just one example of that. And maybe we could build other types of
filters more suitable for the scan. But I think it'll still be tied to a
hash join, because what other nodes / joins can build the filter?

regards

-- 
Tomas Vondra

---

From: Ben Mejia <[email-masked]>
Date: Tue, 23 Jun 2026 15:36:06 -0700

> old patches
As it happens, I've been exploring the use of a bitmap filter for the
same two cases you mention. This has some relevance to the issues you 
mention in your post about sizing, false-positive rate, etc.

Instead of a Bloom filter, I chose to use a bitmap filter, with one bit 
per bucket on the build side. As the inner table is built, I set a bit 
in the bitmap filter for every occupied bucket. If a bucket is empty, 
there are no matching hashes and those hash values can be skipped where 
appropriate. The advantages of this bitmap over a Bloom filter are:

  - sizing is pre-determined by nbuckets
  - small bitmaps (4k for 32k buckets)
  - cheaper - nominal cost to set/check bits

A well-chosen Bloom filter will be more discriminating, but the bitmap 
has the same no-false-negatives guarantee and costs much less space and 
time to build.

I implemented both of your cases:

Drop-before-spill: (Case b)
Build per-batch bitmaps during inner partition pass and drop tuples that 
don't have a bit set. Saves I/O on tuples that will never match. This 
only works for inner and semi joins.

Single-Batch probe: (Case a)
Only pays off in high-miss-rate joins and a bucket array larger than 
L2/L3 cache. This case has a higher penalty for hash table lookup than 
the in-cache bitmap check. This case works in multi-batch, but the I/O 
cost dominates and there is no gain.

I put runtime guards on both of these; I sampled the drop rate over a 
window and disable the filter for the rest of the pass if the rate falls 
below a threshold. (~5% for case b; ~25% for case a)

The benchmarks are encouraging:

For case a, I was able to see a best-case improvement of ~15% for 
carefully chosen data (dependent on L2/L3 cache size).

For case b, I tested 3 cases with sparse, average and dense probe hits:

     sparse probe (~95% miss):           +18% to +36%
     avg probe    (~37% miss):            +9%  to +13%
     dense probe  (FK-like, ~0% miss):    flat, within noise

(This was on a 8-core x86-64, L1 32KB/core, L2 4MB/core, L3 32MB, 31 GB 
RAM. PostgreSQL 19devel, serial hash join, 
max_parallel_workers_per_gather = 0, across work_mem = 1-8MB)

Happy to share the patch and full benchmark data if useful.

-Ben Mejia

---

From: Tomas Vondra <[email-masked]>
Date: Thu, 25 Jun 2026 16:04:30 +0200

> has the same no-false-negatives guarantee and costs much less space and

Isn't that pretty much the same thing as a Bloom filter with a single
hash function? So it has the same false positive properties, i.e. it may
be sacrificing some of the accuracy for not having to calculate any more
hashes. Could be a win.

> only works for inner and semi joins.

OK, makes sense.

> Only pays off in high-miss-rate joins and a bucket array larger than L2/
> L3 cache. This case has a higher penalty for hash table lookup than the
> in-cache bitmap check. This case works in multi-batch, but the I/O cost
> dominates and there is no gain.
> 

Right, it's hard to beat the hash table in this case.

> below a threshold. (~5% for case b; ~25% for case a)

This seems similar to the adaptive behavior I implemented in v2. I
haven't thought of the idea of using different thresholds for the two
cases - I like that.

> Happy to share the patch and full benchmark data if useful.

I'd be happy to collaborate of some of this, if you're interested. Feel
free to post your patch here, or in a separate thread. Up to you.
Separate threads might be easier for cfbot to track.

I've spent some time hacking on v1, i.e. the pushdown - making the
planning work properly, etc. That's mostly orthogonal to this, with some
overlap. Ideally we'd want to do both I think.

regards

-- 
Tomas Vondra

---

From: Tomas Vondra <[email-masked]>
Date: Wed, 1 Jul 2026 15:25:25 +0200

Hi,

> It's just a rough idea, but I think it would work. Worth a try.

I kept thinking about how we could improve the v1 patch (with filter
pushdown to scans deep below the join), so that it does the decisions
"properly" during planning, i.e. during the bottom-up phase when
constructing paths. I kept going back to PathKeys as a precedent for
considering this kind of Path feature, so I gave that a try.

Attached is a v3, a PoC patch reworking the v1 patch in this way. The
patch looks pretty large (~300kB), but the vast majority of that is
churn in regression tests. And also a lot of FIXME/XXX comments
explaining remaining issues or opportunities for improvement. The actual
code changes are rather modest (maybe ~1000 lines).

It also looks much larger because I chose to include v1 as 0001, with
0002 reworking it. But 0002 undoes a lot of the changes made by 0001,
and the resulting diff is way *smaller* than either of the parts.

So it really is not that large in the end.

Note: Most of the changes in regression tests is due to plan changes,
where a Bloom filter gets pushed down, and maybe it even changes larger
plan changes (e.g. because it makes hashjoin cheaper, and so it wins).
There's also changes in results, because the ordering changes (which is
fine, if the plan switches to a hashjoin). I haven't investigated all
the individual changes in detail, but those that I looked at seem OK.

The v1 patch (still included as 0001) did the pushdown in createplan.c,
in create_hashjoin_plan. And that's problematic for the various reasons
I explained in the previous message (costing, missing better plans, ...)

The v2 patch (in 0002) reworks that to actually determine the filters
during the bottom-up phase.

In short:

(a) Path gets "expected_filters" list with filters it expects to be
satisfied by a later join.

(b) While creating paths for scans, we now also generate paths for
interesting filters. This happens in set_rel_pathlist(), it looks at
joins the relation participates in, decides which are selective enough,
and adds a couple paths for combinations of expected filters.

The default is to pick 3 selective filters, and generate all
combinations. So ~8 new paths for each scan path. There's a bunch of
open questions regarding which filters to pick, how many, which
combinations to create.

(c) While creating paths for joins, we also consider these additional
paths with expected filters. A join may either "satisfy" a filter (if
it's a hashjoin and the filter is pushed down by the join), or it can
propagate it (if it's pushed from a later join).

This means nestloop/mergejoin "discard" paths with filters matching that
join, but can still propagate the other filters.

The paths with filters don't compete with regular paths, so we still
have the regular paths with no filters. So there should be no risk of
not being able to find a plan, or something.

(d) At planning / execution this works similarly to v1, except that all
the decisions were already done. The code in createplan.c and executor
is more for book-keeping and allowing lookup of filters.

There's more details in the commit message and the various comments,
along with a bunch of XXX comments about needed improvements.

I haven't incorporated the two patches posted by Andrew:

1) making it work with CustomScans

2) supporting per-key filters

3) allow eager creation of filters (disable delayed Hash build)

I agree those seem like a worthwhile improvements, and the patches
seemed to be OK too, but I was focusing on reworking the planning. Based
on some off-list discussion, Andrew (or one of his colleagues) should be
able to adjust those for this v3 patch.

While I think this v3 approach is generally correct, there certainly is
enough issues to solve.

For example, I really dislike how generate_expected_filter_paths()
constructs the new paths by "cloning" the existing paths. That works,
but does not seem right - for example, the scan may be able to do
something smart with the filter (I presume this is why Andrew wanted the
per-key filters). In which case it probably would like to adjust the
costing accordingly. But with the memcpy() clone that's not possible. So
we'd need something better.

v3 also does not do much regarding the filter tradeoffs (larger filter
vs. higher false positive). Or even setting a memory limit.

In fact, I'm thinking it might be better to abstract the "filter" and
stop thinking just about Bloom filters. There probably are other kinds
of interesting filters, so maybe we should not assume all filters are
Bloom filters. Those filters probably won't be that different, it's more
about not putting "Bloom" in naming etc.

There's a lot more XXX comments, discussing all kinds of ideas (or stuff
that needs improving). I'm not going to repeat all of that here.

While working on the v3 changes, I've been looking for papers discussing
this sort of things. Because we're certainly not the only (or first)
database considering this. Aside from the paper I already mentioned in
my previous message:

I found two more related recent papers:

    Predicate Transfer: Efficient Pre-Filtering on Multi-Join Queries
    https://vldb.org/cidrdb/papers/2024/p22-yang.pdf

    Debunking the Myth of Join Ordering: Toward Robust SQL Analytics
    https://dl.acm.org/doi/pdf/10.1145/3725283

These papers talk about "predicate transafer", but that's really the
same thing as building and pushing down Bloom filters. Because that
effectively "transfers" the predicates (WHERE conditions) from the join
to the other part of the query.

I have not read those papers in detail yet, but I have a hunch they may
be answers to some of the open questions (e.g. a better heuristics how
to pick the interesting filters, etc.).

regards

-- 
Tomas Vondra
From c704431fcacd77839c60f41b089141554eb75572 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <[email-masked]>
Date: Sat, 20 Jun 2026 20:44:00 +0200
Subject: [PATCH v3 1/2] PoC: hashjoin bloom filter pushdown

 

 

 COMMIT;

 

 
 COMMIT;
 
 COMMIT;

 COMMIT;
 
 COMMIT;

 
 COMMIT;
 
 COMMIT;

 

 

 

  QUERY PLAN 
 ------------
 
  QUERY PLAN 
 ------------

index 0805c56cb1b..428b7d8c2b3 100644
@@ -11823,12 +11823,14 @@ INSERT INTO join_tbl SELECT * FROM async_pt t1, async_pt t2 WHERE t1.a = t2.a AN
 
@@ -11886,12 +11888,14 @@ INSERT INTO join_tbl SELECT t1.a, t1.b, 'AAA' || t1.c, t2.a, t2.b, 'AAA' || t2.c
 
index a40d03d35f3..8893e36c8aa 100644
 
+/*
 
 /*

 

 	/*
 
+			/*
 
+	/*

+	/*
 
+	/*
 
 	/*
 	 * Free hash table
+	 *
 
 	/*
 
+			/*

 

 
 
 	/*

 	/*
 
+/*
 
+/*

+	/*
 
index 846bd7c1fbe..e7ca3472d7c 100644
 
+/*
index 3c1e6b31bf8..c9dcb294d4d 100644
@@ -919,6 +919,13 @@
   boot_val => 'true',
 },
 
index 86f2e16eba0..34f98b42ff6 100644

 
 	/*
 
+		/*
index 1798e6027d4..12b0b2faedf 100644
@@ -514,6 +514,18 @@ ExecProject(ProjectionInfo *projInfo)
 }
 #endif
 
+/*
 
+/*
 
 #endif							/* BLOOMFILTER_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e64fd8c7ea3..aad721f3421 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -763,6 +763,14 @@ typedef struct EState

+	/*
@@ -1287,8 +1295,58 @@ typedef struct PlanState
 
+/*
+ * BloomFilterState
+ *
@@ -2703,6 +2761,39 @@ typedef struct HashState
 
+	/*

 
+/*
+ *	 BloomFilter -
 
 /* ----------------
index 89e051ee824..a08d2556981 100644
@@ -1539,9 +1539,11 @@ group by t1.a,t1.b,t1.c,t1.d,t2.x,t2.y,t2.z;
 
@@ -1554,9 +1556,11 @@ group by t1.a,t1.b,t1.c,t1.d,t2.x,t2.z;
 
index 091ae48a92b..bbdb9526471 100644
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1

 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t2.b, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 EXPLAIN (COSTS OFF)
 
 -- Eager aggregation must not push a partial aggregate onto the inner side of a
 -- SEMI or ANTI join
@@ -599,8 +621,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
+                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
+                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -614,8 +638,10 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
+                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
+                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -629,14 +655,16 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
+                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
+                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x, t1_2.y
-(49 rows)
+(55 rows)
 
@@ -681,8 +709,10 @@ GROUP BY t2.y ORDER BY t2.y;
@@ -696,8 +726,10 @@ GROUP BY t2.y ORDER BY t2.y;
@@ -711,14 +743,16 @@ GROUP BY t2.y ORDER BY t2.y;
 
@@ -765,8 +799,10 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
@@ -777,8 +813,10 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
@@ -789,14 +827,16 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
 
@@ -838,8 +878,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -848,8 +890,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -860,8 +904,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -870,8 +916,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -882,8 +930,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -892,11 +942,13 @@ GROUP BY t1.x ORDER BY t1.x;
 
@@ -1066,8 +1118,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1081,8 +1135,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1096,14 +1152,16 @@ GROUP BY t1.x ORDER BY t1.x;
 
@@ -1166,8 +1224,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1181,8 +1241,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1196,8 +1258,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1211,8 +1275,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1226,14 +1292,16 @@ GROUP BY t1.x ORDER BY t1.x;
 
@@ -1294,8 +1362,10 @@ GROUP BY t1.y ORDER BY t1.y;
@@ -1306,8 +1376,10 @@ GROUP BY t1.y ORDER BY t1.y;
@@ -1318,8 +1390,10 @@ GROUP BY t1.y ORDER BY t1.y;
@@ -1330,8 +1404,10 @@ GROUP BY t1.y ORDER BY t1.y;
@@ -1342,14 +1418,16 @@ GROUP BY t1.y ORDER BY t1.y;
 
@@ -1411,8 +1489,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1421,8 +1501,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1433,8 +1515,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1443,8 +1527,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1455,8 +1541,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1465,8 +1553,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1477,8 +1567,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1487,8 +1579,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1499,8 +1593,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1509,11 +1605,13 @@ GROUP BY t1.x ORDER BY t1.x;
 
@@ -1575,8 +1673,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1585,8 +1685,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1594,8 +1696,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1604,8 +1708,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1613,8 +1719,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1623,8 +1731,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1632,8 +1742,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1642,8 +1754,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1651,8 +1765,10 @@ GROUP BY t3.y ORDER BY t3.y;
@@ -1661,11 +1777,13 @@ GROUP BY t3.y ORDER BY t3.y;
 
@@ -1728,8 +1846,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1743,8 +1863,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1758,8 +1880,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1773,8 +1897,10 @@ GROUP BY t1.x ORDER BY t1.x;
@@ -1788,14 +1914,16 @@ GROUP BY t1.x ORDER BY t1.x;
 
index ed946abed7f..4fccc7c4057 100644

 
 explain (costs off)
 
 explain (costs off)
 select * from onek t1

 SELECT a.f1
 FROM tt4 a
 
 drop table skewedtable;

 explain (costs off)
 select a.* from tenk1 a
 
 reset enable_memoize;

 
 rollback;

 explain (costs off)
 select * from

 select ss1.d1 from
   tenk1 as t1
 
 select * from
 int4_tbl i0 left join
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select ss2.* from
   int4_tbl i41

 
@@ -6883,23 +6937,27 @@ where exists (select 1 from t t4
 
 select t1.a from t t1
@@ -9085,13 +9143,15 @@ select * from
 
@@ -9150,25 +9210,29 @@ select c.*,a.*,ss1.q1,ss2.q1,ss3.* from
 
 -- check processing of postponed quals (bug #9041)
 explain (verbose, costs off)
@@ -9475,8 +9539,10 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                Hash Cond: (t3.b = t2.a)
                ->  Seq Scan on public.join_ut1 t3
                      Output: t3.a, t3.b, t3.c
+                     Bloom Filter 1: keys=(t3.b)
                ->  Hash
                      Output: t2.a
+                     Bloom Filter 1
                      ->  Append
                            ->  Seq Scan on public.join_pt1p1p1 t2_1
                                  Output: t2_1.a
@@ -9484,7 +9550,7 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                            ->  Seq Scan on public.join_pt1p2 t2_2
                                  Output: t2_2.a
                                  Filter: (t1.a = t2_2.a)
-(21 rows)
+(23 rows)
 
@@ -9518,12 +9584,14 @@ select * from fkest f1
 
 alter table fkest add constraint fk
   foreign key (x, x10b, x100) references fkest (x, x10, x100);
@@ -9539,13 +9607,15 @@ select * from fkest f1
    ->  Hash Join
          Hash Cond: (f3.x = f2.x)
          ->  Seq Scan on fkest f3
+               Bloom Filter 1: keys=(x)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on fkest f2
                      Filter: (x100 = 2)
    ->  Hash
          ->  Seq Scan on fkest f1
                Filter: (x100 = 2)
-(11 rows)
+(13 rows)
 
 rollback;
 --
@@ -9598,19 +9668,21 @@ analyze j3;
 
@@ -9631,19 +9703,21 @@ select * from j1 inner join j2 on j1.id > j2.id;
 
@@ -9714,19 +9788,21 @@ select * from j1 cross join j2;

 

 
 select i8.q2, ss.* from
 int8_tbl i8,
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t

 

 

 

 

 

 

 EXPLAIN (COSTS OFF)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 RESET enable_mergejoin;
 
index f6cc1a1029c..cb03966e79b 100644
 
index ca83d9fcc09..dc44871b682 100644
@@ -719,8 +719,10 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
@@ -730,12 +732,14 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
 
@@ -793,12 +797,14 @@ UPDATE joinview SET f3 = f3 + 1, f4 = 7 WHERE f3 = 58

 
 explain (costs off)

 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 explain (costs off)

 
 EXPLAIN (COSTS OFF)

 
 
 EXPLAIN (costs off)
 MERGE INTO rw_view1 t

index 90d9f953b81..5c86619f023 100644

 explain (costs off)
 
 -- InitPlan

 DROP TABLE m;

 

From b4c61a32fac544177239113ff99a93f03b70529e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <[email-masked]>
Date: Sat, 20 Jun 2026 20:20:08 +0000
Subject: [PATCH v3 2/2] PoC: Bloom filter pushdown during path construction

We can't decide which filters to push down during plan creation, it's
too late. At that point we've already created paths with expected
cardinalities, calculated the costs, etc.

If we inject new filters to the scan nodes, the row counts won't match
and it'll be confusing (effectively impossible to decide if a difference
is a misestimate or just do to a pushed-down filter).

It also means we can't consider the effect during the "bottom-up" phase,
e.g. when picking algorithms for the other joins, etc. We would likely
improve the current plan, but it prevents us from picking an alternative
cheaper plan.

To address this, add "expected filters" as a feature of a path, and
propage them during joins etc. This is similar concept to path keys,
tracking "interesting" orderings for a path.

At the scan level, figure out a list of "interesting" filters. This is
done by inspecting the joins the scanned relation participates in, and
picking a limited number of sufficiently selective filters, and then it
generates paths expecting different combinations of filters.

The number of combinations grows with 2^n, so with 3 filters we get 9
paths (8 new ones), with 4 it's 16, etc. And this is for each scan node.
For a query with many joins, it can grow pretty quick.

So we need to be conservative, in order to prevent an explosion of the
number of paths. The patch simply picks a limited number of the most
selective filters (e.g. 3 filters, each eliminating at least 30%
tuples). We could refine how this is decided, of course. For example,
CustomScan could/should have a say in the costing, somehow.

At the join level, we need to consider if a join is matching a filter
expected by the input paths or not.

* A join "matches" a filter of an input path, if it's the join from
  which the filter was derived.

* A join can "satisfy" a filter expected by the outer innput path, if it
  matches it, and if it's a hash join. A join can never satisfy filters
  expected by the inner input.

* A join has to satisfy all filters matching the inner/outer path.

This implies that:

* NestLoop/MergeJoin can never satisfy any filters. The places creating
  these joins have to ignore all paths with such filters. But these
  joins can still propagate all other filters, in case one of the later
  joins happens to be a hashjoin satisfying them.

* HashJois can satisfy filters expected by outer input path, or
  propagate filters not matching the join.

Notes:

* We could also consider the "remaining row count" after a filter, and
  only consider filters if it's above some limit. E.g. it there's less
  than 1000 tuples, there's little point in pushing down additional
  filters. This would help with reducing the impact of path explosion.

* When figuring out interesting filters, we need to be careful about
  outer joins. Outer joins are not a good filter source, because we
  can't eliminate any rows even if the join clause is very selective.

* We still do the pushdown when creating the plan, but it's very
  mechanical - all the decisions have been made earlier. It's not
  possible (allowed) to mismatch - we can't pushdown a filter not
  expected by a scan, and the scan can't expect a filter that has not
  been pushdown by the plan.
---
 src/backend/commands/explain.c                |  49 +-
 src/backend/executor/nodeHashjoin.c           |  19 +-
 src/backend/optimizer/path/allpaths.c         | 463 ++++++++++++++++
 src/backend/optimizer/path/costsize.c         |  17 +
 src/backend/optimizer/path/equivclass.c       | 179 ++++++
 src/backend/optimizer/path/joinpath.c         | 524 +++++++++++++++---
 src/backend/optimizer/plan/createplan.c       | 257 ++++-----
 src/backend/optimizer/util/pathnode.c         | 158 ++++++
 src/backend/utils/misc/guc_parameters.dat     |  20 +
 src/backend/utils/misc/postgresql.conf.sample |   2 +
 src/include/nodes/pathnodes.h                 |  66 +++
 src/include/optimizer/cost.h                  |   2 +
 src/include/optimizer/pathnode.h              |   5 +
 src/include/optimizer/paths.h                 |   6 +
 src/test/regress/expected/eager_aggregate.out | 178 ++----
 src/test/regress/expected/graph_table.out     |  14 +-
 src/test/regress/expected/join.out            | 425 +++++++-------
 src/test/regress/expected/join_hash.out       |  16 +-
 src/test/regress/expected/merge.out           | 106 ++--
 src/test/regress/expected/misc_functions.out  |  14 +-
 .../regress/expected/partition_aggregate.out  |  34 +-
 src/test/regress/expected/partition_join.out  | 452 ++-------------
 src/test/regress/expected/predicate.out       |   8 +-
 src/test/regress/expected/returning.out       |  42 +-
 src/test/regress/expected/stats_ext.out       |  23 +-
 src/test/regress/expected/subselect.out       |  83 +--
 src/test/regress/expected/updatable_views.out |  19 +-
 src/test/regress/expected/window.out          |  15 +-
 src/test/regress/expected/with.out            | 112 ++--
 29 files changed, 2004 insertions(+), 1304 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 8893e36c8aa..0ba1319d79f 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -2589,19 +2589,34 @@ show_upper_qual(List *qual, const char *qlabel,
 
 /*
  * show_bloom_filter_info
- *		Show info about every bloom filter pushed down to a scan node.
+ *		Show info about every Bloom filter pushed down to a scan node.
  *
  * In TEXT format each filter is rendered on a single line, e.g.
  *
- *   Bloom Filter N: (a, b) producer=3 checked=99999 rejected=99990
+ *   Bloom Filter N: keys=(a, b) checked=99999 rejected=99990
  *
- * The checked/rejected fields are omitted outside of ANALYZE). In
- * structured formats we emit a group per filter with the same fields
- * broken out as properties.
+ * The checked/rejected fields are omitted outside of ANALYZE). In structured
+ * formats we emit a group per filter with the same fields broken out as
+ * properties.
  *
- * Called from the per-recipient cases in ExplainNode; the recipient is
- * expected to be a scan node in the current PoC, but nothing here
- * depends on that.
+ * Called from the per-recipient cases in ExplainNode; the recipient is expected
+ * to be a scan node, but nothing here depends on that. We may choose to push
+ * filters to other nodes in the plan.
+ *
+ * This only prints information about the keys, and the checked/rejected counts.
+ * Detailed information about the filter itself (number of bits, number of hash
+ * functions, ...) is available on the producer side.
+ *
+ * XXX It might be a good idea to show the expected filter selectivity too,
+ * not just the one actually observed during execution. That'd make it easier
+ * to reason about the decisions and review the plan changes.
+ *
+ * XXX If we choose other filter types, we'd need some general way to show the
+ * information. I don't know if we should have a "generic" information provided
+ * by all the filters, or if we would need a way to print custom information.
+ * Chances are we'd have a limited number of supported filter types, in which
+ * case we can have a show_ function for each type. Only if users could inject
+ * arbitrary filters, that'd be an issue. But that seems unlikely.
  */
 static void
 show_bloom_filter_info(PlanState *planstate, List *ancestors,
@@ -3599,9 +3614,21 @@ show_hash_info(HashState *hashstate, ExplainState *es)
 	}
 
 	/*
-	 * Show infromation about the bloom filter produced by this Hash node
-	 * (if any). For plain EXPLAIN, the filter is not initialized / built,
-	 * but we still show available plan-time metadata (at least the ID).
+	 * Show infromation about the Bloom filter produced by this Hash node (if
+	 * any). For plain EXPLAIN, the filter is not initialized / built, but we
+	 * still show available plan-time metadata (at least the ID).
+	 *
+	 * Once the filter is built, we show the various filter parameters (number
+	 * of bits, hash functions, ...). Note that even with EXPLAIN ANALYZE the
+	 * filter may not be built, due to the hashjoin delaying the Hash build
+	 * until on the first outer tuple.
+	 *
+	 * XXX We don't show the keys, because those are always the join keys.
+	 *
+	 * XXX I think it makes sense to show the checked/rejected counters both
+	 * here and for the consumer. If/when we allow multiple consumers for the
+	 * filter, then this would show the "summary" while the scan node would
+	 * show just counters for that one consumer.
 	 */
 	if (hashstate->bloom_filter_id > 0)
 	{
diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 8fa7af4cfef..e3467f14739 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -2019,15 +2019,16 @@ ExecHashJoinInitializeWorker(HashJoinState *state,
  * BLOOM FILTER PUSHDOWN
  *
  * The pushdown decision is done in try_push_bloom_filter, when constructing
- * the plan from the selected paths (see createplan.c). It decides which scan
- * node should receive the bloom filter (if any), and what expressions it
- * should use to calculate the hash value.
+ * the plan from the selected paths. The input paths track filters "expected"
+ * by scan nodes included in that path (if any). The planner then ensures all
+ * expected filters are either satisfied (by matching joins) or propagated up.
+ * In a valid plan all expected filters are satisfied.
  *
  * Then at execution time:
  *
  *   - ExecInitHashJoin registers itself in EState.es_bloom_producers
  *     before recursing into child plans, so by the time a recipient's
- *     ExecInit runs, the producer is already discoverable by plan_node_id.
+ *     ExecInit runs, the producer is already discoverable by filter ID.
  *     This registration only happens when there's at least one consumer.
  *     It also sets want_bloom_filter for the Hash node.
  *
@@ -2035,25 +2036,25 @@ ExecHashJoinInitializeWorker(HashJoinState *state,
  *     when HashState.want_bloom_filter is set (so no work happens when
  *     nobody will probe).
  *
- *   - Nodes with non-NIL plan->bloom_filters (and supporting bloom
+ *   - Nodes with non-NIL plan->bloom_filters (and supporting Bloom
  *     filters) call ExecInitBloomFilters() during its own ExecInit,
  *     which looks up the producer node (in the EState), compiles
  *     ExprStates for the hash expressions, etc. The filter state
  *     (BloomFilterState) gets added to ps->bloom_filters (a node may
- *     have multiple bloom filters from different hash joins).
+ *     have multiple Bloom filters from different hash joins).
  *
  *   - The per-tuple loop of the scan node calls ExecBloomFilters() (much
  *     like ExecQual) to test the tuple against every attached filter,
  *     dropping it on the first filter that excludes it. For scan nodes
  *     this call happens in ExecScanExtended.
  *
- * The scan nodes reach the bloom filter via the HashJoinState pointer
+ * The scan nodes reach the Bloom filter via the HashJoinState pointer
  * added to EState.es_bloom_producers, so that the rescans etc. (filter
  * freed + recreated when the hash table is destroyed and rebuilt) are
- * transparent to the consumer. The bloom filter gets reallocated after
+ * transparent to the consumer. The Bloom filter gets reallocated after
  * a rescan, so the pointer to it may change.
  *
- * XXX It's possible the bloom filter gets pushed down to a node that
+ * XXX It's possible the Bloom filter gets pushed down to a node that
  * fails to initialize/use it. It'll be added to the bloom_filters list,
  * but if the node does not call ExecInitBloomFilters, the filter will
  * be unused.
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index c134594a21a..8829c7c3108 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -107,6 +107,9 @@ static void set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel,
 									  RangeTblEntry *rte);
 static void set_plain_rel_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								   RangeTblEntry *rte);
+static List *find_interesting_bloom_filters(PlannerInfo *root,
+											RelOptInfo *rel);
+static void generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel);
 static void set_tablesample_rel_size(PlannerInfo *root, RelOptInfo *rel,
 									 RangeTblEntry *rte);
 static void set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel,
@@ -603,6 +606,16 @@ set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel,
 		!bms_equal(rel->relids, root->all_query_rels))
 		generate_useful_gather_paths(root, rel, false);
 
+	/*
+	 * For plain base relations, consider generating additional scan paths
+	 * that anticipate a Bloom filter being pushed down from a hash join above
+	 * (see find_interesting_bloom_filters).  These paths have reduced row
+	 * estimates and are consumed by join path generation.
+	 */
+	if (rel->reloptkind == RELOPT_BASEREL &&
+		rte->rtekind == RTE_RELATION)
+		generate_expected_filter_paths(root, rel);
+
 	/* Now find the cheapest of the paths for this rel */
 	set_cheapest(rel);
 
@@ -884,6 +897,456 @@ create_plain_partial_paths(PlannerInfo *root, RelOptInfo *rel)
 	add_partial_path(rel, create_seqscan_path(root, rel, NULL, parallel_workers));
 }
 
+/*
+ * bloom_em_matches_anybarevar
+ *	  ec_matches_callback used by find_interesting_bloom_filters: accept any
+ *	  EquivalenceClass member that is a bare Var of the target relation.  The
+ *	  Bloom filter pushdown logic (createplan.c) only supports recipients whose
+ *	  join keys are bare Vars, so we mirror that requirement here.
+ */
+static bool
+bloom_em_matches_anybarevar(PlannerInfo *root, RelOptInfo *rel,
+							EquivalenceClass *ec, EquivalenceMember *em,
+							void *arg)
+{
+	Var		   *var;
+
+	/* We're looking only for bare Var expressions. */
+	if (!IsA(em->em_expr, Var))
+		return false;
+
+	/*
+	 * Is the Var referencing a normal (non-system) attribute in the relation
+	 * we're processing (generating scans for)?
+	 *
+	 * FIXME Can we have (varlevelsup != 0) for baserels? I don't think we can
+	 * have outer referecenses in that place.
+	 */
+	var = (Var *) em->em_expr;
+	if (var->varno != rel->relid ||
+		var->varattno <= 0 ||
+		var->varlevelsup != 0)
+		return false;
+
+	return true;
+}
+
+/*
+ * bloom_filter_recipient_reachable
+ *		Check that a Bloom filter owned by owner_relid and built from
+ *		build_relids could actually be pushed to the owner's scan.
+ *
+ * A pushed-down filter removes owner tuples that have no match on the build
+ * side, so it can only be applied where the join that realizes it drops such
+ * unmatched owner (probe) tuples.  If an outer join null-extends the owner
+ * before it can be joined to the build side, the filter would change the
+ * result and is therefore unusable: at plan time find_bloom_filter_recipient
+ * would refuse to descend into that side and find no recipient (see also
+ * bloom_join_side_preserved, which is checking for this situation).
+ *
+ * Picking such a filter only to throw it away later wastes planner effort,
+ * and we might also ignore some other filters because of that. It's better
+ * to eliminate it right away.
+ *
+ * This is primarily an optimization - we don't want to generate paths that
+ * would ultimately be useless, and possibly not generating paths for other
+ * filters. The correctness is still guaranteed by the propagation logic in
+ * compute_join_expected_filters(), which rejects cases that would carry a
+ * filter across a non-preserved join side. That guarantees we don't pick a
+ * plan with such filters, only to find about the issue in createplan.c.
+ */
+static bool
+bloom_filter_recipient_reachable(PlannerInfo *root, Index owner_relid,
+								 Relids build_relids)
+{
+	ListCell   *lc;
+
+	foreach(lc, root->join_info_list)
+	{
+		SpecialJoinInfo *sjinfo = (SpecialJoinInfo *) lfirst(lc);
+
+		switch (sjinfo->jointype)
+		{
+			case JOIN_LEFT:
+			case JOIN_ANTI:
+
+				/*
+				 * The syntactic RHS is null-extended.  If the owner is on it
+				 * but the build side is reached from the preserved LHS, the
+				 * owner must cross this outer join on its nullable side.
+				 */
+				if (bms_is_member(owner_relid, sjinfo->syn_righthand) &&
+					!bms_is_subset(build_relids, sjinfo->syn_righthand))
+					return false;
+				break;
+			case JOIN_FULL:
+
+				/*
+				 * Both sides are null-extended, so the filter is unusable
+				 * whenever the owner and the build side sit on opposite sides
+				 * of the join.
+				 */
+				if (bms_is_member(owner_relid, sjinfo->syn_lefthand) &&
+					!bms_is_subset(build_relids, sjinfo->syn_lefthand))
+					return false;
+				if (bms_is_member(owner_relid, sjinfo->syn_righthand) &&
+					!bms_is_subset(build_relids, sjinfo->syn_righthand))
+					return false;
+				break;
+			default:
+				/* INNER and SEMI joins never null-extend the owner. */
+				break;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * find_interesting_bloom_filters
+ *	  Identify Bloom filters that a hash join above this scan could push down,
+ *	  and that are selective enough to be worth costing for.
+ *
+ * We look for hashjoinable equality join clauses where this rel's side is a
+ * bare Var, derived both from EquivalenceClasses and from non-EC joininfo
+ * clauses.  Clauses are grouped by the relids on the other ("build") side of
+ * the join; each group becomes a candidate filter whose expected surviving
+ * fraction is estimated as the semijoin selectivity of those clauses.
+ *
+ * A candidate is "interesting" only if it is expected to eliminate at least
+ * bloom_filter_pushdown_threshold of the rel's tuples.  We keep at most
+ * bloom_filter_pushdown_max of the most selective candidates, and return them
+ * as a list of ExpectedFilter nodes.
+ *
+ * XXX This needs to be careful to not interfere with the general selectivity
+ * estimation, performed by clauselist_selectivity(). We'll estimate the filter
+ * selectivity using a made-up sjinfo with JOIN_INNER, which may not match
+ * the actual join. The selectivities must not leak - this is why this function
+ * does not collect the RestrictInfos but only the clauses. If we used the
+ * RestrictInfos, the clauselist_selectivity would cache the incorrect result
+ * in them, and it'd affect the planning in weird ways.
+ *
+ * FIXME Maybe there's a better way to calculate the filter selectivity?
+ */
+static List *
+find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
+{
+	List	   *candidates;
+	List	   *group_relids = NIL; /* parallel: Relids per group */
+	List	   *group_clauses = NIL;	/* parallel: List of clauses */
+	List	   *result = NIL;
+	ListCell   *lc;
+
+	if (!enable_hashjoin_bloom)
+		return NIL;
+
+	if (bloom_filter_pushdown_max <= 0)
+		return NIL;
+
+	if (rel->reloptkind != RELOPT_BASEREL)
+		return NIL;
+
+	/* Collect candidate hashjoinable equality clauses for this rel. */
+	candidates = generate_implied_equalities_for_all_columns(root, rel,
+															 bloom_em_matches_anybarevar,
+															 NULL, NULL);
+
+	foreach(lc, rel->joininfo)
+	{
+		RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
+
+		/* EC-derived clauses are already covered above. */
+		if (rinfo->parent_ec != NULL)
+			continue;
+
+		candidates = lappend(candidates, rinfo->clause);
+	}
+
+	/* Group candidate clauses by their build-side relids. */
+	foreach(lc, candidates)
+	{
+		Node	   *clause = (Node *) lfirst(lc);
+		Node	   *left;
+		Node	   *right;
+		Node	   *ownerexpr;
+		Oid			opno;
+		Relids		clause_relids;
+		Relids		build_relids;
+		int			buildrel;
+		ListCell   *lc2;
+		ListCell   *lc3;
+		bool		found;
+
+		/* strip RestrictInfo (see comment above) */
+		if (IsA(clause, RestrictInfo))
+			clause = (Node *) ((RestrictInfo *) clause)->clause;
+
+		/*
+		 * Only care about (Expr op Expr) clauses. We know one side has to be
+		 * a bare Var node, from the "owner" side (which is the scan node).
+		 * The other side can be arbitrary expression on the other relation.
+		 */
+		if (!is_opclause(clause) ||
+			list_length(((OpExpr *) clause)->args) != 2)
+			continue;
+
+		opno = ((OpExpr *) clause)->opno;
+		left = get_leftop(clause);
+		right = get_rightop(clause);
+
+		/* Identify which side is a bare Var of this rel (the owner side). */
+		/* XXX replace this with a macro shared with bloom_em_matches_anybarevar */
+		if (IsA(left, Var) && ((Var *) left)->varno == rel->relid &&
+			((Var *) left)->varattno > 0 && ((Var *) left)->varlevelsup == 0)
+			ownerexpr = left;
+		else if (IsA(right, Var) && ((Var *) right)->varno == rel->relid &&
+				 ((Var *) right)->varattno > 0 &&
+				 ((Var *) right)->varlevelsup == 0)
+			ownerexpr = right;
+		else
+			continue;
+
+		/* Operator must be hashjoinable on the owner's input type. */
+		if (!op_hashjoinable(opno, exprType(ownerexpr)))
+			continue;
+
+		/*
+		 * The build side must be a single base relation; that's what the
+		 * recipient lookup and our selectivity estimate can handle.
+		 *
+		 * XXX I don't think this restriction is necessary. We can allow the
+		 * build side to be a join. I don't see why that would be a problem.
+		 */
+		clause_relids = pull_varnos(root, (Node *) clause);
+		build_relids = bms_difference(clause_relids, rel->relids);
+		if (!bms_get_singleton_member(build_relids, &buildrel) ||
+			buildrel >= root->simple_rel_array_size ||
+			root->simple_rel_array[buildrel] == NULL ||
+			root->simple_rel_array[buildrel]->reloptkind != RELOPT_BASEREL)
+		{
+			bms_free(build_relids);
+			continue;
+		}
+
+		/* Add to an existing group, or start a new one. */
+		/* XXX Maybe we sould have a HTAB with the relids as a key? But the
+		 * lists should not be that long, I think. */
+		found = false;
+		forboth(lc2, group_relids, lc3, group_clauses)
+		{
+			Relids		grelids = (Relids) lfirst(lc2);
+
+			if (bms_equal(grelids, build_relids))
+			{
+				lfirst(lc3) = lappend((List *) lfirst(lc3), clause);
+				found = true;
+
+				/* added to an existing group, don't keep the relids around */
+				bms_free(build_relids);
+
+				break;
+			}
+		}
+
+		if (!found) 	/* new group */
+		{
+			group_relids = lappend(group_relids, build_relids);
+			group_clauses = lappend(group_clauses, list_make1(clause));
+		}
+	}
+
+	/*
+	 * We have collected all potentially intresting filters. Evaluate selectivity
+	 * of each group and keep only the most interesting filters. Filters have to
+	 * eliminate at least bloom_filter_pushdown_threshold tuples, and we keep
+	 * only bloom_filter_pushdown_max most selective ones.
+	 */
+	{
+		ListCell   *lcr = list_head(group_relids);
+		ListCell   *lcc = list_head(group_clauses);
+
+		while (lcr != NULL && lcc != NULL)
+		{
+			Relids		build_relids = (Relids) lfirst(lcr);
+			List	   *clauses = (List *) lfirst(lcc);
+			SpecialJoinInfo sjinfo;
+			Selectivity sel;
+
+			init_dummy_sjinfo(&sjinfo, rel->relids, build_relids);
+			sjinfo.jointype = JOIN_SEMI;
+
+			sel = clauselist_selectivity(root, clauses, 0, JOIN_SEMI, &sjinfo);
+
+			if ((sel <= 1.0 - bloom_filter_pushdown_threshold) &&
+				(sel > 0.0) &&	/* XXX seems unnecessary */
+				bloom_filter_recipient_reachable(root, rel->relid, build_relids))
+			{
+				ExpectedFilter *f = makeNode(ExpectedFilter);
+
+				f->owner_relid = rel->relid;
+				f->build_relids = build_relids;
+				f->clauses = clauses;
+				f->selectivity = sel;
+				result = lappend(result, f);
+			}
+
+			lcr = lnext(group_relids, lcr);
+			lcc = lnext(group_clauses, lcc);
+		}
+	}
+
+	/*
+	 * We only connsider a limited number of interesting filters, to prevent
+	 * path explosion. If we found too many, keep only the most selective ones
+	 * (with smallest surviving fraction of tuples), to bound the number of
+	 * generated paths.
+	 *
+	 * XXX This also aligns with good join orders - those tend to perform the
+	 * most selective joins first. So we get to build the filters soon, even
+	 * if the hashjoin optimization is not disabled.
+	 */
+	while (list_length(result) > bloom_filter_pushdown_max)
+	{
+		ExpectedFilter *worst = NULL;
+		ListCell   *lcw;
+
+		foreach(lcw, result)
+		{
+			ExpectedFilter *f = (ExpectedFilter *) lfirst(lcw);
+
+			if (worst == NULL || f->selectivity > worst->selectivity)
+				worst = f;
+		}
+		result = list_delete_ptr(result, worst);
+	}
+
+	return result;
+}
+
+/*
+ * generate_expected_filter_paths
+ *		Generate additional scan paths that anticipate one or more pushed-down
+ *		Bloom filters.
+ *
+ * For each non-empty subset of the interesting filters, we clone every eligible
+ * existing scan path, reducing its row estimate by the combined selectivity and
+ * attaching the corresponding ExpectedFilter nodes.
+ *
+ * These paths are kept alongside the regular paths (add_path keeps paths with
+ * differing expected_filters) and are consumed by join path generation;
+ * set_cheapest never selects them.
+ *
+ * XXX We must not clone paths that already have expected filters.
+ *
+ * XXX The cloning is a rather dirty way to copy paths. It does not readjust the
+ * cost in a reasonable way. For example custom scans could do something smart
+ * with the filters, so it should have a chance to deal with that. A cleaner
+ * solution might be to actually pass the filters to the various "create"
+ * function, like create_seqscan_path/... For CustomScan nodes we can probably
+ * do most of this in the set_rel_pathlist_hook, somewhere. Maybe that needs
+ * some helper methods, though. And maybe it will need to pass some of the info
+ * through the callbacks? Not sure, someone has to try that.
+ *
+ * XXX This may need some major changes to work with custom scans. Right now we
+ * only consider filters exactly matching the hash keys, so if the hashjoin is
+ * on (t1.a = t2.a AND t1.b = t2.b), then the filter will be on (a,b). But a
+ * custom scan may prefer "split" filters on each column independently. We'd
+ * need a way for the custom scan to indicate that, and we'd need to apply this
+ * only to the "matching" scan paths (and not to any other scan paths). But
+ * we only look at the paths after selecting the "interesting" filters, so we'd
+ * need to rethink that - we'd need to make the "interesting" filters specific
+ * to a path, or something like that.
+ */
+static void
+generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
+{
+	List	   *filters;
+	List	   *basepaths = NIL;
+	int			nfilters;
+	uint32		combo;
+	ListCell   *lc;
+
+	filters = find_interesting_bloom_filters(root, rel);
+	if (filters == NIL)
+		return;
+
+	nfilters = list_length(filters);
+
+	/*
+	 * Snapshot the existing unparameterized, non-partial scan paths of a
+	 * supported type.  We must snapshot before calling add_path(), which
+	 * mutates rel->pathlist.
+	 */
+	foreach(lc, rel->pathlist)
+	{
+		Path	   *path = (Path *) lfirst(lc);
+
+		/* XXX Is parameterization really a problem? Always? */
+		if (path->param_info != NULL || path->expected_filters != NIL)
+			continue;
+
+		switch (nodeTag(path))
+		{
+			case T_Path:
+			case T_IndexPath:
+			case T_BitmapHeapPath:
+			case T_TidPath:
+			case T_TidRangePath:
+				basepaths = lappend(basepaths, path);
+				break;
+			default:
+				break;
+		}
+	}
+
+	if (basepaths == NIL)
+		return;
+
+	/*
+	 * Generate all combinations of the interesting filters. We do that by
+	 * iterating 1 to (2^n-1), which generates all bitmask in between. Those
+	 * are the subsets.
+	 *
+	 * XXX This is a good demonstration why we need to keep the number of
+	 * filters low
+	 *
+	 * XXX Maybe we should also stop adding filters once the other filters
+	 * already eliminate enought tuples. Say, we know F1 alone eliminates 99%
+	 * tuples. Does it make sense to also consider [F1,F2]? Probably not. We
+	 * could track "maximum" sets, and reject combinations containing one
+	 * of those. We'd need to generate sets of increasing size, the iteration
+	 * does not do that. But that's not hard.
+	 */
+	for (combo = 1; combo < ((uint32) 1 << nfilters); combo++)
+	{
+		List	   *subset = NIL;
+		int			i = 0;
+		ListCell   *lcf;
+
+		foreach(lcf, filters)
+		{
+			if (combo & ((uint32) 1 << i))
+				subset = lappend(subset, lfirst(lcf));
+			i++;
+		}
+
+		/*
+		 * All filtered paths for this combo share the same expected_filters
+		 * list.  That's safe: the list is never modified, and add_path() only
+		 * ever frees the Path node itself, not its expected_filters.
+		 */
+		foreach(lc, basepaths)
+		{
+			Path	   *base = (Path *) lfirst(lc);
+			Path	   *newpath;
+
+			newpath = create_filtered_scan_path(root, base, subset);
+			if (newpath != NULL)
+				add_path(rel, newpath);
+		}
+	}
+}
+
 /*
  * set_tablesample_rel_size
  *	  Set size estimates for a sampled relation
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index c3072a29ccc..8740889094f 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -166,6 +166,23 @@ bool		enable_partition_pruning = true;
 bool		enable_presorted_aggregate = true;
 bool		enable_async_append = true;
 
+/*
+ * Minimum fraction of outer tuples a pushed-down hash-join Bloom filter must
+ * be expected to eliminate for the planner to treat it as "interesting" and
+ * generate filter-aware scan paths.  A value of 0.3 means a filter is only
+ * considered if it is expected to discard at least 30% of the scanned tuples.
+ */
+double		bloom_filter_pushdown_threshold = 0.3;
+
+/*
+ * Upper bound on the number of distinct interesting Bloom filters considered
+ * for a single scan relation.  This bounds the number of additional paths
+ * generated per scan (the planner enumerates non-empty subsets of the
+ * interesting filters, i.e. up to 2^bloom_filter_pushdown_max - 1 extra
+ * paths per base scan path).
+ */
+int			bloom_filter_pushdown_max = 3;
+
 typedef struct
 {
 	PlannerInfo *root;
diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index e3697df51a2..6722b74f401 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -75,6 +75,15 @@ static RestrictInfo *create_join_clause(PlannerInfo *root,
 										EquivalenceMember *leftem,
 										EquivalenceMember *rightem,
 										EquivalenceClass *parent_ec);
+static int	generate_implied_equalities_for_column_ec(PlannerInfo *root,
+													  RelOptInfo *rel,
+													  EquivalenceClass *cur_ec,
+													  ec_matches_callback_type callback,
+													  void *callback_arg,
+													  Relids prohibited_rels,
+													  Relids parent_relids,
+													  bool is_child_rel,
+													  List **result);
 static bool reconsider_outer_join_clause(PlannerInfo *root,
 										 OuterJoinClauseInfo *ojcinfo,
 										 bool outer_on_left);
@@ -3211,6 +3220,107 @@ eclass_member_iterator_next(EquivalenceMemberIterator *it)
 	return NULL;
 }
 
+/*
+ * generate_implied_equalities_for_column_ec
+ *	  Workhorse for generate_implied_equalities_for_column() and
+ *	  generate_implied_equalities_for_all_columns().  Considers a single
+ *	  EquivalenceClass cur_ec: if it has a member matching the target column
+ *	  (as identified by the callback), generate EC-derived joinclauses
+ *	  equating that member to each other-relation member, appending them to
+ *	  *result.  Returns the number of clauses generated.
+ */
+static int
+generate_implied_equalities_for_column_ec(PlannerInfo *root,
+										  RelOptInfo *rel,
+										  EquivalenceClass *cur_ec,
+										  ec_matches_callback_type callback,
+										  void *callback_arg,
+										  Relids prohibited_rels,
+										  Relids parent_relids,
+										  bool is_child_rel,
+										  List **result)
+{
+	EquivalenceMemberIterator it;
+	EquivalenceMember *cur_em;
+	ListCell   *lc2;
+	int			ngenerated = 0;
+
+	/*
+	 * Won't generate joinclauses if const or single-member (the latter test
+	 * covers the volatile case too)
+	 */
+	if (cur_ec->ec_has_const || list_length(cur_ec->ec_members) <= 1)
+		return 0;
+
+	/*
+	 * Scan members, looking for a match to the target column.  Note that
+	 * child EC members are considered, but only when they belong to the
+	 * target relation.  (Unlike regular members, the same expression could be
+	 * a child member of more than one EC.  Therefore, it's potentially
+	 * order-dependent which EC a child relation's target column gets matched
+	 * to.  This is annoying but it only happens in corner cases, so for now we
+	 * live with just reporting the first match.  See also
+	 * get_eclass_for_sort_expr.)
+	 */
+	setup_eclass_member_iterator(&it, cur_ec, rel->relids);
+	while ((cur_em = eclass_member_iterator_next(&it)) != NULL)
+	{
+		if (bms_equal(cur_em->em_relids, rel->relids) &&
+			callback(root, rel, cur_ec, cur_em, callback_arg))
+			break;
+	}
+
+	if (!cur_em)
+		return 0;
+
+	/*
+	 * Found our match.  Scan the other EC members and attempt to generate
+	 * joinclauses.  Ignore children here.
+	 */
+	foreach(lc2, cur_ec->ec_members)
+	{
+		EquivalenceMember *other_em = (EquivalenceMember *) lfirst(lc2);
+		Oid			eq_op;
+		RestrictInfo *rinfo;
+
+		/* Child members should not exist in ec_members */
+		Assert(!other_em->em_is_child);
+
+		/* Make sure it'll be a join to a different rel */
+		if (other_em == cur_em ||
+			bms_overlap(other_em->em_relids, rel->relids))
+			continue;
+
+		/* Forget it if caller doesn't want joins to this rel */
+		if (bms_overlap(other_em->em_relids, prohibited_rels))
+			continue;
+
+		/*
+		 * Also, if this is a child rel, avoid generating a useless join to its
+		 * parent rel(s).
+		 */
+		if (is_child_rel &&
+			bms_overlap(parent_relids, other_em->em_relids))
+			continue;
+
+		eq_op = select_equality_operator(cur_ec,
+										 cur_em->em_datatype,
+										 other_em->em_datatype);
+		if (!OidIsValid(eq_op))
+			continue;
+
+		/* set parent_ec to mark as redundant with other joinclauses */
+		rinfo = create_join_clause(root, cur_ec, eq_op,
+								   cur_em, other_em,
+								   cur_ec);
+
+		*result = lappend(*result, rinfo);
+		ngenerated++;
+	}
+
+	return ngenerated;
+}
+
 /*
  * generate_implied_equalities_for_column
  *	  Create EC-derived joinclauses usable with a specific column.
@@ -3233,6 +3343,10 @@ eclass_member_iterator_next(EquivalenceMemberIterator *it)
  *
  * The caller can pass a Relids set of rels we aren't interested in joining
  * to, so as to save the work of creating useless clauses.
+ *
+ * XXX This could reuse generate_implied_equalities_for_column_ec for the
+ * inner loop, similarly to generate_implied_equalities_for_all_columns, but I
+ * chose to not do that for now. Better keep this as is.
  */
 List *
 generate_implied_equalities_for_column(PlannerInfo *root,
@@ -3353,6 +3467,71 @@ generate_implied_equalities_for_column(PlannerInfo *root,
 	return result;
 }
 
+/*
+ * generate_implied_equalities_for_all_columns
+ *	  Like generate_implied_equalities_for_column, but returns EC-derived
+ *	  joinclauses for *every* column of the relation, rather than stopping at
+ *	  the first column (EquivalenceClass) that yields any clauses.
+ *
+ * generate_implied_equalities_for_column() is designed for parameterized-path
+ * generation, where the goal is to find a single usable joinclause per column
+ * and there is no value in returning clauses for more than one column at a
+ * time.  Some callers, however, are interested in joinclauses on all of the
+ * relation's columns simultaneously (for example, the Bloom filter pushdown
+ * logic, which may push down filters derived from several different columns at
+ * once).  This variant therefore visits all of the relation's
+ * EquivalenceClasses and accumulates clauses from each.
+ *
+ * As with generate_implied_equalities_for_column(), the result for any single
+ * column is a redundant set of clauses equating that column to each of the
+ * other-relation values it is known to be equal to.
+ *
+ * XXX We don't really need the last two arguments, but we keep this as close
+ * to generate_implied_equalities_for_column as possible.
+ */
+List *
+generate_implied_equalities_for_all_columns(PlannerInfo *root,
+											RelOptInfo *rel,
+											ec_matches_callback_type callback,
+											void *callback_arg,
+											Relids prohibited_rels)
+{
+	List	   *result = NIL;
+	bool		is_child_rel = (rel->reloptkind == RELOPT_OTHER_MEMBER_REL);
+	Relids		parent_relids;
+	int			i;
+
+	/* Should be OK to rely on eclass_indexes */
+	Assert(root->ec_merging_done);
+
+	/* Indexes are available only on base or "other" member relations. */
+	Assert(IS_SIMPLE_REL(rel));
+
+	/* If it's a child rel, we'll need to know what its parent(s) are */
+	if (is_child_rel)
+		parent_relids = find_childrel_parents(root, rel);
+	else
+		parent_relids = NULL;	/* not used, but keep compiler quiet */
+
+	i = -1;
+	while ((i = bms_next_member(rel->eclass_indexes, i)) >= 0)
+	{
+		EquivalenceClass *cur_ec = (EquivalenceClass *) list_nth(root->eq_classes, i);
+
+		/* Sanity check eclass_indexes only contain ECs for rel */
+		Assert(is_child_rel || bms_is_subset(rel->relids, cur_ec->ec_relids));
+
+		(void) generate_implied_equalities_for_column_ec(root, rel, cur_ec,
+														 callback, callback_arg,
+														 prohibited_rels,
+														 parent_relids,
+														 is_child_rel,
+														 &result);
+	}
+
+	return result;
+}
+
 /*
  * have_relevant_eclass_joinclause
  *		Detect whether there is an EquivalenceClass that could produce
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index 713283a73aa..5e65fda1419 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -869,6 +869,271 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	return NULL;
 }
 
+/*
+ * bloom_join_side_preserved
+ *		Can a pushed-down Bloom filter be applied below the given side of a
+ *		join of this type without changing the join's result?
+ *
+ * A Bloom filter removes tuples from the scan it is pushed to.  That is only
+ * safe on a side whose unmatched tuples the join would drop anyway: dropping
+ * a tuple early then matches the join's behaviour.  On a null-extended
+ * (preserved-other-side) input it is unsafe, because removing a tuple there
+ * could suppress, or spuriously emit, null-extended rows.
+ *
+ * This is the path-time counterpart of find_bloom_filter_recipient() in
+ * createplan.c, which descends a plan tree toward the recipient scan: it may
+ * only descend into a join's outer (resp. inner) child when this function
+ * returns true for to_outer = true (resp. false).  Keeping the two in sync is
+ * what guarantees that every filter we realize at path time has a reachable
+ * recipient at plan time.
+ */
+bool
+bloom_join_side_preserved(JoinType jointype, bool to_outer)
+{
+	switch (jointype)
+	{
+		case JOIN_INNER:
+			return true;
+		case JOIN_LEFT:
+		case JOIN_SEMI:
+		case JOIN_ANTI:
+			return to_outer;
+		case JOIN_RIGHT:
+		case JOIN_RIGHT_SEMI:
+		case JOIN_RIGHT_ANTI:
+			return !to_outer;
+		case JOIN_FULL:
+			return false;
+		default:
+			return false;
+	}
+}
+
+/*
+ * jointype_realizes_bloom_filter
+ *		Can a hash join of this type build and push a Bloom filter to its
+ *		outer (probe) side?
+ *
+ * This must match the join-type check in try_push_bloom_filter(), so that a
+ * filter we cost for is actually realized at plan time.  Only join types that
+ * drop unmatched outer (probe) tuples are safe, since the filter eliminates
+ * probe tuples lacking an inner match.
+ *
+ * Note JOIN_RIGHT qualifies: it preserves unmatched tuples of the inner (build)
+ * side, not the outer (probe) side, so dropping unmatched probe tuples is still
+ * correct.
+ */
+static bool
+jointype_realizes_bloom_filter(JoinType jointype)
+{
+	switch (jointype)
+	{
+		case JOIN_INNER:
+		case JOIN_RIGHT:
+		case JOIN_SEMI:
+		case JOIN_RIGHT_SEMI:
+		case JOIN_RIGHT_ANTI:
+			return true;
+		default:
+			return false;
+	}
+}
+
+/*
+ * hashjoin_pushes_filter_to
+ *		Would create_hashjoin_plan() be able to push a Bloom filter built from
+ *		these hash clauses down to the scan of owner_relid?
+ *
+ * This mirrors try_push_bloom_filter()'s requirement that every hash key on
+ * the outer side be a bare Var of a single base relation.  'outer_relids' is
+ * the set of relids on the outer (probe) side of the join.
+ */
+static bool
+hashjoin_pushes_filter_to(List *hashclauses, Relids outer_relids,
+						  Index owner_relid)
+{
+	ListCell   *lc;
+
+	if (hashclauses == NIL)
+		return false;
+
+	foreach(lc, hashclauses)
+	{
+		RestrictInfo *ri = (RestrictInfo *) lfirst(lc);
+		Node	   *outerside;
+		Var		   *var;
+
+		if (!is_opclause(ri->clause) ||
+			list_length(((OpExpr *) ri->clause)->args) != 2)
+			return false;
+
+		/* Pick the side that belongs to the outer relation. */
+		if (bms_is_subset(ri->left_relids, outer_relids))
+			outerside = get_leftop(ri->clause);
+		else if (bms_is_subset(ri->right_relids, outer_relids))
+			outerside = get_rightop(ri->clause);
+		else
+			return false;
+
+		if (!IsA(outerside, Var))
+			return false;
+		var = (Var *) outerside;
+		if (var->varno != owner_relid ||
+			var->varattno <= 0 ||
+			var->varlevelsup != 0)
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * compute_join_expected_filters
+ *		Determine the expected Bloom filters a prospective join path should
+ *		carry, applying the propagation and contradiction rules.
+ *
+ * Each filter expected by an input path is either:
+ *
+ *   - propagated upward (its build side is not yet fully joined in), or
+ *
+ *   - realized by this join (a hash join that is the filter's source and can
+ *     push the filter to its outer side): it is dropped from the result, since
+ *     it has now been applied.  When 'realized' is non-NULL, such filters are
+ *     appended to *realized so the caller can record them on the join path and
+ *     later propagate the pushdown decision to the plan, or
+ *
+ *   - contradicted: the join is the filter's source but cannot push it (it is
+ *     not a suitable hash join). In that case the input path cannot be used
+ *     for this join, so *contradicted is set true and NIL is returned.
+ *
+ * 'is_hashjoin'/'hashclauses' describe the join method; hashclauses is only
+ * meaningful for hash joins.
+ */
+static List *
+compute_join_expected_filters(PlannerInfo *root,
+							  Path *outer_path, Path *inner_path,
+							  JoinType jointype, bool is_hashjoin,
+							  List *hashclauses, bool *contradicted,
+							  List **realized)
+{
+	List	   *result = NIL;
+	Relids		outer_relids = outer_path->parent->relids;
+	Relids		inner_relids = inner_path->parent->relids;
+	Relids		join_relids;
+	int			pass;
+
+	*contradicted = false;
+	if (realized != NULL)
+		*realized = NIL;
+
+	/* Fast path: neither input expects any filter. */
+	if (outer_path->expected_filters == NIL &&
+		inner_path->expected_filters == NIL)
+		return NIL;
+
+	join_relids = bms_union(outer_relids, inner_relids);
+
+	/* Examine the filters from both inputs. */
+	for (pass = 0; pass < 2; pass++)
+	{
+		List	   *filters = (pass == 0) ? outer_path->expected_filters
+			: inner_path->expected_filters;
+		ListCell   *lc;
+
+		foreach(lc, filters)
+		{
+			ExpectedFilter *f = (ExpectedFilter *) lfirst(lc);
+			bool		owner_in_outer;
+			Relids		owner_relids;
+			Relids		other_relids;
+
+			owner_in_outer = bms_is_member(f->owner_relid, outer_relids);
+			owner_relids = owner_in_outer ? outer_relids : inner_relids;
+			other_relids = owner_in_outer ? inner_relids : outer_relids;
+
+			if (bms_is_subset(f->build_relids, owner_relids))
+			{
+				/*
+				 * Build side already sits with the owner; this shouldn't
+				 * normally happen (such a filter would have been resolved at a
+				 * lower join), but if it does, just propagate it unchanged.
+				 *
+				 * We still must be able to reach the owner's scan from above,
+				 * so the owner has to be on a side this join preserves (see
+				 * bloom_join_side_preserved); otherwise the filter could not
+				 * be pushed to a recipient and this path must be rejected.
+				 */
+				if (!bloom_join_side_preserved(jointype, owner_in_outer))
+					goto contradiction;
+				result = lappend(result, f);
+			}
+			else if (bms_is_subset(f->build_relids, join_relids))
+			{
+				/* This join is the source of the filter. */
+				if (is_hashjoin &&
+					owner_in_outer &&
+					bms_is_subset(f->build_relids, other_relids) &&
+					jointype_realizes_bloom_filter(jointype) &&
+					hashjoin_pushes_filter_to(hashclauses, outer_relids,
+											  f->owner_relid))
+				{
+					/* Realized by this hash join; drop from propagation. */
+					if (realized != NULL)
+						*realized = lappend(*realized, f);
+					continue;
+				}
+				else
+				{
+					/* Cannot realize the filter here: reject this path. */
+					goto contradiction;
+				}
+			}
+			else
+			{
+				/*
+				 * Build side not yet available; propagate.  As above, the
+				 * filter can only reach its recipient scan if the owner stays
+				 * on a side this join preserves; if not, reject this path so
+				 * we never realize a filter with no recipient at plan time.
+				 */
+				if (!bloom_join_side_preserved(jointype, owner_in_outer))
+					goto contradiction;
+				result = lappend(result, f);
+			}
+		}
+	}
+
+	bms_free(join_relids);
+	return result;
+
+contradiction:
+	*contradicted = true;
+	if (realized != NULL)
+	{
+		list_free(*realized);
+		*realized = NIL;
+	}
+	bms_free(join_relids);
+	list_free(result);
+	return NIL;
+}
+
+/*
+ * set_join_path_expected_filters
+ *		Attach the propagated expected filters to a freshly created join path
+ *		and reduce its row estimate to reflect their combined selectivity.
+ */
+static void
+set_join_path_expected_filters(Path *path, List *filters)
+{
+	if (filters == NIL)
+		return;
+
+	path->expected_filters = filters;
+	path->rows = clamp_row_est(path->rows *
+							   expected_filters_selectivity(filters));
+}
+
 /*
  * try_nestloop_path
  *	  Consider a nestloop join path; if it appears useful, push it into
@@ -967,26 +1232,71 @@ try_nestloop_path(PlannerInfo *root,
 						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
-	if (add_path_precheck(joinrel, workspace.disabled_nodes,
-						  workspace.startup_cost, workspace.total_cost,
-						  pathkeys, required_outer))
-	{
-		add_path(joinrel, (Path *)
-				 create_nestloop_path(root,
-									  joinrel,
-									  jointype,
-									  &workspace,
-									  extra,
-									  outer_path,
-									  inner_path,
-									  extra->restrictlist,
-									  pathkeys,
-									  required_outer));
-	}
-	else
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A
+	 * nestloop never builds a Bloom filter, so if it is the source of any
+	 * expected filter the path is contradicted and must be rejected;
+	 * otherwise the filters propagate to the resulting path.
+	 */
 	{
-		/* Waste no memory when we reject a path here */
-		bms_free(required_outer);
+		bool		contradicted;
+		List	   *jfilters;
+
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, false, NIL,
+												 &contradicted, NULL);
+
+		/*
+		 * Contradicted means the inner/outer paths expect this join to realize
+		 * one of the expected filters, but a nestloop can't do that. So these
+		 * input paths are incompatible with a nestloop.
+		 */
+		if (contradicted)
+		{
+			bms_free(required_outer);
+			return;
+		}
+
+		/*
+		 * If the path expects any filters, it's excluded from the cost pruning
+		 * performed by add_path (so don't bother with add_path_precheck either).
+		 * Once a path has all filters satisfied (or there were no filters), do
+		 * the pruning as usual.
+		 *
+		 * XXX We don't want the "regular" paths without filters to get removed,
+		 * because we need the option to pick from join algorithms. Paths with
+		 * filters would likely win (simply because there are fewer rows), but
+		 * they only work with hashjoins. However, maybe the hashjoin won't work
+		 * for some reason (e.g. it wouldn't fit into work_mem).
+		 *
+		 * XXX Maybe it'd be cleaner to do this in add_path_precheck (i.e. make
+		 * it return true for paths with expected filters).
+		 */
+		if (jfilters != NIL ||
+			add_path_precheck(joinrel, workspace.disabled_nodes,
+							  workspace.startup_cost, workspace.total_cost,
+							  pathkeys, required_outer))
+		{
+			Path	   *nlpath;
+
+			nlpath = (Path *) create_nestloop_path(root,
+												   joinrel,
+												   jointype,
+												   &workspace,
+												   extra,
+												   outer_path,
+												   inner_path,
+												   extra->restrictlist,
+												   pathkeys,
+												   required_outer);
+			set_join_path_expected_filters(nlpath, jfilters);
+			add_path(joinrel, nlpath);
+		}
+		else
+		{
+			/* Waste no memory when we reject a path here */
+			bms_free(required_outer);
+		}
 	}
 }
 
@@ -1160,30 +1470,58 @@ try_mergejoin_path(PlannerInfo *root,
 						   outer_presorted_keys,
 						   extra);
 
-	if (add_path_precheck(joinrel, workspace.disabled_nodes,
-						  workspace.startup_cost, workspace.total_cost,
-						  pathkeys, required_outer))
-	{
-		add_path(joinrel, (Path *)
-				 create_mergejoin_path(root,
-									   joinrel,
-									   jointype,
-									   &workspace,
-									   extra,
-									   outer_path,
-									   inner_path,
-									   extra->restrictlist,
-									   pathkeys,
-									   required_outer,
-									   mergeclauses,
-									   outersortkeys,
-									   innersortkeys,
-									   outer_presorted_keys));
-	}
-	else
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A
+	 * mergejoin never builds a Bloom filter, so it contradicts (and cannot
+	 * use) any input path for which it would be the filter's source.
+	 * Filter-bearing paths bypass the precheck, since their reduced cost
+	 * isn't comparable to ordinary paths.
+	 *
+	 * XXX see the comments in try_nestloop_path
+	 */
 	{
-		/* Waste no memory when we reject a path here */
-		bms_free(required_outer);
+		bool		contradicted;
+		List	   *jfilters;
+
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, false, NIL,
+												 &contradicted, NULL);
+		if (contradicted)
+		{
+			bms_free(required_outer);
+			return;
+		}
+
+		if (jfilters != NIL ||
+			add_path_precheck(joinrel, workspace.disabled_nodes,
+							   workspace.startup_cost, workspace.total_cost,
+							   pathkeys, required_outer))
+		{
+			Path	   *mjpath;
+
+			mjpath = (Path *) create_mergejoin_path(root,
+													joinrel,
+													jointype,
+													&workspace,
+													extra,
+													outer_path,
+													inner_path,
+													extra->restrictlist,
+													pathkeys,
+													required_outer,
+													mergeclauses,
+													outersortkeys,
+													innersortkeys,
+													outer_presorted_keys);
+			set_join_path_expected_filters(mjpath, jfilters);
+			add_path(joinrel, mjpath);
+		}
+		else
+		{
+			/* Waste no memory when we reject a path here */
+			bms_free(required_outer);
+		}
+
 	}
 }
 
@@ -1314,27 +1652,62 @@ try_hashjoin_path(PlannerInfo *root,
 	initial_cost_hashjoin(root, &workspace, jointype, hashclauses,
 						  outer_path, inner_path, extra, false);
 
-	if (add_path_precheck(joinrel, workspace.disabled_nodes,
-						  workspace.startup_cost, workspace.total_cost,
-						  NIL, required_outer))
-	{
-		add_path(joinrel, (Path *)
-				 create_hashjoin_path(root,
-									  joinrel,
-									  jointype,
-									  &workspace,
-									  extra,
-									  outer_path,
-									  inner_path,
-									  false,	/* parallel_hash */
-									  extra->restrictlist,
-									  required_outer,
-									  hashclauses));
-	}
-	else
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A hash
+	 * join builds and pushes down a Bloom filter, so it realizes (and removes
+	 * from propagation) any expected filter for which it is the source; other
+	 * filters propagate upward.  Filter-bearing paths bypass the precheck.
+	 */
 	{
-		/* Waste no memory when we reject a path here */
-		bms_free(required_outer);
+		bool		contradicted;
+		List	   *jfilters;
+		List	   *realized;
+
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, true, hashclauses,
+												 &contradicted, &realized);
+
+		/* XXX Can a hashjoin contradict a filter? Probably not. */
+		if (contradicted)
+		{
+			bms_free(required_outer);
+			return;
+		}
+
+		if (jfilters != NIL ||
+			add_path_precheck(joinrel, workspace.disabled_nodes,
+							  workspace.startup_cost, workspace.total_cost,
+							  NIL, required_outer))
+		{
+			Path	   *hjpath;
+
+			hjpath = (Path *) create_hashjoin_path(root,
+												   joinrel,
+												   jointype,
+												   &workspace,
+												   extra,
+												   outer_path,
+												   inner_path,
+												   false,	/* parallel_hash */
+												   extra->restrictlist,
+												   required_outer,
+												   hashclauses);
+			set_join_path_expected_filters(hjpath, jfilters);
+
+			/*
+			 * Record the filters this hash join realizes, so create_hashjoin_plan
+			 * can push exactly those down (and no others) at plan-creation time.
+			 */
+			((HashPath *) hjpath)->realized_filters = realized;
+
+			add_path(joinrel, hjpath);
+		}
+		else
+		{
+			/* Waste no memory when we reject a path here */
+			bms_free(required_outer);
+			return;
+		}
 	}
 }
 
@@ -2316,6 +2689,33 @@ hash_inner_and_outer(PlannerInfo *root,
 			}
 		}
 
+		/*
+		 * Also consider outer paths that carry expected Bloom filters.  These
+		 * are deliberately excluded from cheapest_startup/total_path and from
+		 * cheapest_parameterized_paths (see set_cheapest), so we must iterate
+		 * the full outer pathlist to find them.  A hash join is able to build
+		 * and push down the filters, so these paths are useful here even when
+		 * they would be contradicted at a non-hash join.
+		 */
+		foreach(lc1, outerrel->pathlist)
+		{
+			Path	   *outerpath = (Path *) lfirst(lc1);
+
+			if (outerpath->expected_filters == NIL)
+				continue;
+
+			if (PATH_PARAM_BY_REL(outerpath, innerrel))
+				continue;
+
+			try_hashjoin_path(root,
+							  joinrel,
+							  outerpath,
+							  cheapest_total_inner,
+							  hashclauses,
+							  jointype,
+							  extra);
+		}
+
 		/*
 		 * If the joinrel is parallel-safe, we may be able to consider a
 		 * partial hash join.
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 7ecb551aae6..51990b98419 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4692,78 +4692,54 @@ create_mergejoin_plan(PlannerInfo *root,
 /*
  * BLOOM FILTER PUSHDOWN
  *
- * When creating a hash join plan, consider building a bloom filter and
- * pushing it down to the outer subtree. For now we only push filters to
- * scan nodes containing all the join keys. When we find such scan node,
- * we append the BloomFilter ID to the node's bloom_filters list, and
- * increment the bloom_consumer_count for the hashjoin.
+ * When a hash join is created as a path, we decide whether it should build a
+ * Bloom filter and push it down to a scan on its outer (probe) side.  That
+ * decision - which filters are selective enough to be worth building, and
+ * which scan they can be pushed to - is made entirely while creating paths
+ * (see find_interesting_bloom_filters and compute_join_expected_filters in the
+ * optimizer); and the chosen filters are recorded on the HashPath as its
+ * realized_filters.  Here we merely propagate that decision into the plan: we
+ * never reconsider whether a filter is worthwhile, and in particular we never
+ * push a filter that was not selected as interesting when creating paths.
  *
- * As setrefs hashn't run yet, the join keys are still the raw Vars.
- * So it's safe to compare var->varno against the scanrelid, and copy
- * the keys verbatim onto the recipient. setrefs will rewrite the Vars
- * later as usual, just like for the recipient's qual.
+ * For each realized filter we locate the scan node for its owner relation in
+ * the outer subtree, append a BloomFilter (built from the hash keys belonging
+ * to that filter) to the scan's bloom_filters list, and increment the
+ * hashjoin's bloom_consumer_count.
  *
- * XXX In most cases there'll be only a single consumer node. To get
- * multiple consumers, we'd need either joins on the same keys, or
- * ability to produce filters for subsets of the join keys (for cases
- * where the join is more complex, and does not map to a single scan
- * node directly). Seems like a possible future improvement.
+ * As setrefs hasn't run yet, the hash keys are still the raw Vars.  So it's
+ * safe to compare var->varno against the scanrelid, and copy the keys verbatim
+ * onto the recipient.  setrefs will rewrite the Vars later as usual, just like
+ * for the recipient's qual.
  *
- * XXX Actually, we could have multiple consumer nodes for partitioned
- * tables, where each partition gets a separate scan. Which seems like
- * something we should support.
+ * XXX In most cases there'll be only a single consumer node.  To get multiple
+ * consumers, we'd need either joins on the same keys, or ability to produce
+ * filters for subsets of the join keys (for cases where the join is more
+ * complex, and does not map to a single scan node directly).  Seems like a
+ * possible future improvement.
  *
- * XXX For simplicity, all outer join keys have to be bare Vars (from
- * the same RTE). We could relax this later, and allow joins on more
- * complex expressions. Not sure if that'll erase some of the benefits,
- * which relies on filter probes being much cheaper hashtable probes.
- * It also doesn't seem like a very common case.
+ * XXX Actually, we could have multiple consumer nodes for partitioned tables,
+ * where each partition gets a separate scan.  Which seems like something we
+ * should support.
  *
- * XXX The recipient node must be one of a small set of scan nodes. We
- * could relax this, and allow pushing to other nodes (e.g. joins or
- * aggregates). Future improvement.
+ * XXX The recipient node must be one of a small set of scan nodes.  We could
+ * relax this, and allow pushing to other nodes (e.g. joins or aggregates).
+ * Future improvement.
  *
- * XXX We don't currently push the same HashJoin to multiple recipients,
- * but multiple HashJoins may attach a filter to the same scan node.
+ * XXX We don't currently push the same HashJoin to multiple recipients, but
+ * multiple HashJoins may attach a filter to the same scan node.
  * --------------------------------------------------------------------------
  */
 
-/*
- * bloom_join_side_preserved
- *		Decide if we can push filter to inner/outer side of a join.
- *
- * Outer joins that emit unmatched outer tuples (LEFT/ANTI/FULL) are
- * skipped: dropping outer tuples there would be incorrect.
- */
-static bool
-bloom_join_side_preserved(JoinType jointype, bool to_outer)
-{
-	switch (jointype)
-	{
-		case JOIN_INNER:
-			return true;
-		case JOIN_LEFT:
-		case JOIN_SEMI:
-		case JOIN_ANTI:
-			return to_outer;
-		case JOIN_RIGHT:
-		case JOIN_RIGHT_SEMI:
-		case JOIN_RIGHT_ANTI:
-			return !to_outer;
-		case JOIN_FULL:
-			return false;
-		default:
-			return false;
-	}
-}
-
 /*
  * find_bloom_filter_recipient
  *		Try to find a scan node to push filter to.
  *
  * We support pushing filter to a subset of scan nodes (could be extended
  * later). We support pushing filters through intermediate nodes (joins,
- * sorts, ...). See bloom_join_side_preserved for joins.
+ * sorts, ...). See bloom_join_side_preserved (joinpath.c) for joins; the
+ * path-time propagation uses the same predicate, so any filter realized while
+ * costing paths is guaranteed a reachable recipient here.
  *
  * XXX We could push filters through more nodes - e.g. aggregates if the
  * hash keys match GROUP BY keys (are a subset of).
@@ -4771,6 +4747,12 @@ bloom_join_side_preserved(JoinType jointype, bool to_outer)
  * XXX We could do pushdown to parallel parts of a query. But we'd need
  * a different way to communicate if a filter is built etc. (the worker
  * won't have access to the hashjoin state).
+ *
+ * XXX Not sure this handles partitioned tables correctly. Those will be below
+ * Append node, and we don't push through those. But the scans will still expect
+ * the filter, I think. Even if we pushed through Append node, it probably won't
+ * work because we expect a single consumer. But we'll have one consumer per
+ * scan of a partition.
  */
 static Plan *
 find_bloom_filter_recipient(Plan *plan, Index target_relid)
@@ -4842,142 +4824,92 @@ find_bloom_filter_recipient(Plan *plan, Index target_relid)
 
 /*
  * try_push_bloom_filter
- *		Attempt to pushdown a bloom filter for the current hashjoin.
+ *		Push down the bloom filter the planner decided this hashjoin should
+ *		build, recording it on the recipient scan node.
  *
- * The filter pushdown happens during plan creation, i.e. after the plan was
- * already selected. That is not entirely optimal, and it has a couple of
- * annoying consequences.
+ * 'realized_filters' is the list of ExpectedFilter nodes the HashPath was
+ * found to realize while creating paths.  We do not reconsider that decision
+ * here; if it is empty, this hashjoin pushes nothing.  Otherwise we turn it
+ * into a concrete BloomFilter attached to the scan of the owner relation.
  *
- * The main disadvantage is that injecting the filter to a scan node may
- * significantly alter the number of tuples produced by that scan node. If a
- * filter eliminates 99% of the rows, the scan produces 1/100 of the rows it
- * was planned with. It would not affect the scan itself, but if there are
- * other nodes (between the scan and the join), maybe we'd have planned them
- * differently if we knew about the lower cardinality?
+ * A hash join builds a single bloom filter, populated with the combined hash
+ * value of all of its hash keys (see ExecHashTableInsert / bloom_add_element
+ * in nodeHash.c).  The recipient must therefore probe with the matching full
+ * set of outer hash keys, so we build the filter from all of them.  All
+ * realized filters of one hash join share the same owner relation (the outer
+ * side of every hash clause is a bare Var of that relation), so a single
+ * pushed-down filter covers them all.
  *
- * Similarly, it's confusing in the explain. That is, we'll get "rows=N"
- * with the planner cardinality (before the filter was pushed down), but
- * then in EXPLAIN ANALYZE it'll get much lower values. It'd be easy to
- * confuse with inaccurate estimates.
- *
- * It'd be better to know about the filter earlier, when constructing the scan
- * path. But that's not quite feasible with our bottom-up planner. When planing
- * the scan, we don't know which of the joins above it will be hashjoins, or
- * if it can pushdown the filter. We'd have to speculate, or maybe build more
- * paths with/without expectation of the bloom filter pushdown. But that seems
- * not great, as it'd add overhead for everyone.
+ * Note the pushdown still happens during plan creation, i.e. after the plan was
+ * already selected.  The selectivity of the filter was, however, accounted for
+ * while creating paths (the affected scan paths carry reduced row estimates),
+ * so the plan-time row counts already reflect the expected elimination.
  */
 static void
-try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan)
+try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan,
+					  List *realized_filters)
 {
-	List	   *hashkeys = hj->hashkeys;
-	List	   *hashops = hj->hashoperators;
-	List	   *hashcolls = hj->hashcollations;
-	ListCell   *lc;
-	Index		target_relid = 0;
+	ExpectedFilter *f;
+	Index		owner_relid;
 	Plan	   *recipient;
 	BloomFilter *bf;
 
-	/* bail out if feature disabled. */
-	if (!enable_hashjoin_bloom)
-		return;
-
-	/* XXX shouldn't really happen, I think */
-	if (hashkeys == NIL)
+	/* Nothing to do unless the planner chose to realize a filter here. */
+	if (realized_filters == NIL)
 		return;
 
-	Assert(list_length(hashkeys) == list_length(hashops));
-	Assert(list_length(hashkeys) == list_length(hashcolls));
-
 	/*
-	 * Pushdown is unsafe for join types that emit unmatched outer tuples
-	 * (LEFT/ANTI/FULL): we'd risk dropping outer tuples the join would
-	 * otherwise have emitted (possibly NULL-extended).
+	 * The feature must have been enabled when paths were built; otherwise no
+	 * filter would have been realized.
 	 */
-	switch (hj->join.jointype)
-	{
-		case JOIN_INNER:
-		case JOIN_RIGHT:
-		case JOIN_SEMI:
-		case JOIN_RIGHT_SEMI:
-		case JOIN_RIGHT_ANTI:
-			/* these join types are OK */
-			break;
-		default:
-			return;
-	}
+	Assert(enable_hashjoin_bloom);
+	Assert(hj->hashkeys != NIL);
+	Assert(list_length(hj->hashkeys) == list_length(hj->hashoperators));
+	Assert(list_length(hj->hashkeys) == list_length(hj->hashcollations));
 
 	/*
-	 * All hashkeys must be bare Vars referencing the same base RTE.
-	 *
-	 * XXX We could be a bit less strict, and check that at least some of the
-	 * hashkeys are bare Vars, not all of them. So with joins on multiple
-	 * expressions we'd have better chance to push a filter down. Doesn't
-	 * seem worth it, at least for now.
+	 * All realized filters share the same owner relation (every hash clause's
+	 * outer side is a bare Var of that relation).
 	 */
-	foreach(lc, hashkeys)
-	{
-		Node	   *k = (Node *) lfirst(lc);
-		Var		   *var;
-
-		/* not a plain Var */
-		if (!IsA(k, Var))
-			return;
+	f = (ExpectedFilter *) linitial(realized_filters);
+	owner_relid = f->owner_relid;
 
-		var = (Var *) k;
+#ifdef USE_ASSERT_CHECKING
+	{
+		ListCell   *lc;
 
-		/*
-		 * Reject outer references, whole-row or system columns, and
-		 * special varnos (not sure we can get them here, though).
-		 */
-		if ((var->varlevelsup != 0) ||
-			(var->varattno <= 0) ||
-			IS_SPECIAL_VARNO(var->varno))
-			return;
-
-		/* make sure all the vars are for the same relid */
-		if (target_relid == 0)
-			target_relid = var->varno;
-		else if (var->varno != target_relid)
-			return;
+		foreach(lc, realized_filters)
+			Assert(((ExpectedFilter *) lfirst(lc))->owner_relid == owner_relid);
 	}
-
-	/* should have found at least one var */
-	Assert(target_relid != 0);
+#endif
 
 	/*
-	 * See if we can find the scan node for target_relid. It certainly is
-	 * in the plan somewhere, but it may not be able to pushdown the filter
-	 * to it (because of a join or so).
+	 * Locate the scan node for the owner relation in the outer subtree.  The
+	 * path machinery guaranteed such a recipient exists (the filter could not
+	 * have been realized otherwise), but stay defensive.
 	 */
-	recipient = find_bloom_filter_recipient(outer_plan, target_relid);
+	recipient = find_bloom_filter_recipient(outer_plan, owner_relid);
 	if (recipient == NULL)
 		return;
 
 	/*
-	 * If we found a recipient, assign the filter an ID. We'll use it to
-	 * register the filter in ExecRegisterBloomFilterProducer, and then
-	 * for lookups in LookupBloomFilterProducer during execution.
+	 * Assign the filter an ID.  We'll use it to register the filter in
+	 * ExecRegisterBloomFilterProducer, and then for lookups in
+	 * LookupBloomFilterProducer during execution.
 	 *
 	 * XXX We can't use plan_node_id, as it's not assigned yet, that only
-	 * happens in set_plan_refs. Also, if we ever allow multiple filters
-	 * per hashtable (e.g. for different subsets of keys), it's not work.
+	 * happens in set_plan_refs.
 	 */
 	hj->bloom_filter_id = ++root->glob->lastBloomFilterId;
 
 	bf = makeNode(BloomFilter);
-	bf->filter_exprs = (List *) copyObject(hashkeys);
-	bf->hashops = list_copy(hashops);
-	bf->hashcollations = list_copy(hashcolls);
+	bf->filter_exprs = (List *) copyObject(hj->hashkeys);
+	bf->hashops = list_copy(hj->hashoperators);
+	bf->hashcollations = list_copy(hj->hashcollations);
 	bf->producer_id = hj->bloom_filter_id;

-	/*
-	 * XXX We've manged to push the filter to the scan node, but maybe
-	 * we should wait with updating bloom_consumer_count when it actually
-	 * initializes the filters in ExecInit()?
-	 */
 	hj->bloom_consumer_count++;
 }
 
@@ -5152,11 +5084,14 @@ create_hashjoin_plan(PlannerInfo *root,
 	copy_generic_path_info(&join_plan->join.plan, &best_path->jpath.path);
 
 	/*
-	 * Try to push the bloom filter for the hashtable down to nodes in the outer
-	 * subtree. If a suitable scan node exists, add the filter to bloom_filters,
-	 * and bump our bloom_consumer_count.
+	 * Propagate the bloom filter pushdown decision made while creating paths:
+	 * if this hash join was found to realize one or more bloom filters, push
+	 * the corresponding filter down to the recipient scan in the outer
+	 * subtree, and bump our bloom_consumer_count.  No filter that was not
+	 * selected as interesting during path creation is pushed here.
 	 */
-	try_push_bloom_filter(root, join_plan, outer_plan);
+	try_push_bloom_filter(root, join_plan, outer_plan,
+						  best_path->realized_filters);
 
 	return join_plan;
 }
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 73518c8f870..9cd9188a1cf 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -286,6 +286,16 @@ set_cheapest(RelOptInfo *parent_rel)
 		Path	   *path = (Path *) lfirst(p);
 		int			cmp;
 
+		/*
+		 * Paths that expect a pushed-down Bloom filter are speculative: their
+		 * rows/cost estimates assume a hash join above will build and push a
+		 * filter to them.  They must never be chosen as the cheapest startup,
+		 * total, or parameterized path; they are only consumed explicitly by
+		 * join path generation (see joinpath.c).  Skip them here.
+		 */
+		if (path->expected_filters != NIL)
+			continue;
+
 		if (path->param_info)
 		{
 			/* Parameterized path, so add it to parameterized_paths */
@@ -383,6 +393,129 @@ set_cheapest(RelOptInfo *parent_rel)
 	parent_rel->cheapest_parameterized_paths = parameterized_paths;
 }
 
+/*
+ * expected_filters_equal
+ *	  Return true if the two lists of ExpectedFilter nodes denote the same
+ *	  set of expected Bloom filters (order-independent).
+ */
+bool
+expected_filters_equal(List *a, List *b)
+{
+	ListCell   *lc;
+
+	if (a == NIL && b == NIL)
+		return true;
+	if (list_length(a) != list_length(b))
+		return false;
+
+	foreach(lc, a)
+	{
+		ExpectedFilter *fa = (ExpectedFilter *) lfirst(lc);
+		ListCell   *lc2;
+		bool		found = false;
+
+		foreach(lc2, b)
+		{
+			ExpectedFilter *fb = (ExpectedFilter *) lfirst(lc2);
+
+			if (fa->owner_relid == fb->owner_relid &&
+				bms_equal(fa->build_relids, fb->build_relids) &&
+				equal(fa->clauses, fb->clauses))
+			{
+				found = true;
+				break;
+			}
+		}
+		if (!found)
+			return false;
+	}
+	return true;
+}
+
+/*
+ * expected_filters_selectivity
+ *	  Combined surviving fraction of a set of expected filters, assuming
+ *	  independence.  Returns a value in (0, 1].
+ */
+double
+expected_filters_selectivity(List *filters)
+{
+	double		sel = 1.0;
+	ListCell   *lc;
+
+	foreach(lc, filters)
+	{
+		ExpectedFilter *f = (ExpectedFilter *) lfirst(lc);
+
+		sel *= f->selectivity;
+	}
+
+	/* clamp to a sane range */
+	if (sel < 0.0)
+		sel = 0.0;
+	if (sel > 1.0)
+		sel = 1.0;
+
+	return sel;
+}
+
+/*
+ * create_filtered_scan_path
+ *	  Build a copy of a base-relation scan path that additionally expects the
+ *	  given set of pushed-down Bloom filters.
+ *
+ * The clone shares all substructure with the original path (parent,
+ * pathtarget, clauses, etc.); only the rows estimate is reduced to reflect
+ * the filters' combined selectivity, and expected_filters is set.  This is
+ * safe because create_plan() treats the clone identically to the original
+ * (it ignores expected_filters), and add_path() may freely pfree the clone.
+ *
+ * Only the plain scan path node types that can receive a pushed-down filter
+ * are supported (matching find_bloom_filter_recipient in createplan.c).
+ * Returns NULL for unsupported path types.
+ *
+ * XXX This should probably adjust the CPU cost in some way. It assumes the
+ * filter checks are free, which does not seem right.
+ */
+Path *
+create_filtered_scan_path(PlannerInfo *root, Path *subpath, List *filters)
+{
+	Path	   *newpath;
+	size_t		sz;
+
+	switch (nodeTag(subpath))
+	{
+		case T_Path:
+			/* plain seqscan/samplescan etc. */
+			sz = sizeof(Path);
+			break;
+		case T_IndexPath:
+			sz = sizeof(IndexPath);
+			break;
+		case T_BitmapHeapPath:
+			sz = sizeof(BitmapHeapPath);
+			break;
+		case T_TidPath:
+			sz = sizeof(TidPath);
+			break;
+		case T_TidRangePath:
+			sz = sizeof(TidRangePath);
+			break;
+		default:
+			/* unsupported scan path type */
+			return NULL;
+	}
+
+	newpath = (Path *) palloc(sz);
+	memcpy(newpath, subpath, sz);
+
+	newpath->expected_filters = filters;
+	newpath->rows = clamp_row_est(subpath->rows *
+								  expected_filters_selectivity(filters));
+
+	return newpath;
+}
+
 /*
  * add_path
  *	  Consider a potential implementation path for the specified parent rel,
@@ -485,6 +618,17 @@ add_path(RelOptInfo *parent_rel, Path *new_path)
 		PathKeysComparison keyscmp;
 		BMS_Comparison outercmp;
 
+		/*
+		 * Paths carrying different sets of expected Bloom filters serve
+		 * different purposes (each may be consumed by a different parent join,
+		 * or none at all), and their cost/row estimates aren't directly
+		 * comparable.  So if the two paths don't expect the same filters, keep
+		 * both and don't let either dominate the other.
+		 */
+		if (!expected_filters_equal(new_path->expected_filters,
+									old_path->expected_filters))
+			continue;
+
 		/*
 		 * Do a fuzzy cost comparison with standard fuzziness limit.
 		 */
@@ -702,6 +846,20 @@ add_path_precheck(RelOptInfo *parent_rel, int disabled_nodes,
 		Path	   *old_path = (Path *) lfirst(p1);
 		PathKeysComparison keyscmp;
 
+		/*
+		 * Paths carrying expected Bloom filters serve a different purpose and
+		 * are not directly cost-comparable with ordinary paths, exactly as in
+		 * add_path (which keeps both when the expected filter sets differ).
+		 * The candidates submitted to this precheck never carry expected
+		 * filters of their own, so any filter-bearing old path is a
+		 * non-comparable speculative path and must not be allowed to dominate
+		 * (and thereby suppress) the new path.  Skipping them here also
+		 * guarantees that a join relation always retains at least one ordinary,
+		 * filter-free path to serve as cheapest_total_path.
+		 */
+		if (old_path->expected_filters != NIL)
+			continue;
+
 		/*
 		 * Since the pathlist is sorted by disabled_nodes and then by
 		 * total_cost, we can stop looking once we reach a path with more
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index c9dcb294d4d..9ea40bcb798 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -380,6 +380,26 @@
   max => 'BLCKSZ',
 },
 
+{ name => 'bloom_filter_pushdown_max', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER',
+  short_desc => 'Maximum number of pushed-down hash join bloom filters considered per scan.',
+  long_desc => 'Bounds how many interesting bloom filters the planner enumerates subsets of when building filter-aware scan paths.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'bloom_filter_pushdown_max',
+  boot_val => '3',
+  min => '0',
+  max => '10',
+},
+
+{ name => 'bloom_filter_pushdown_threshold', type => 'real', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER',
+  short_desc => 'Minimum fraction of tuples a pushed-down hash join bloom filter must be expected to eliminate.',
+  long_desc => 'A bloom filter is only considered during planning if it is expected to discard at least this fraction of the scanned tuples.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'bloom_filter_pushdown_threshold',
+  boot_val => '0.3',
+  min => '0.0',
+  max => '1.0',
+},
+
 { name => 'bonjour', type => 'bool', context => 'PGC_POSTMASTER', group => 'CONN_AUTH_SETTINGS',
   short_desc => 'Enables advertising the server via Bonjour.',
   variable => 'enable_bonjour',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 34f98b42ff6..faf01bf7e43 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -485,6 +485,8 @@
 
 # - Other Planner Options -
 
+#bloom_filter_pushdown_max = 3          # range 0-10
+#bloom_filter_pushdown_threshold = 0.3  # range 0.0-1.0
 #default_statistics_target = 100        # range 1-10000
 #constraint_exclusion = partition       # on, off, or partition
 #cursor_tuple_fraction = 0.1            # range 0.0-1.0
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 69f9ad2d5e3..f2f21f4ade1 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -1836,6 +1836,54 @@ typedef struct GroupByOrdering
 	List	   *clauses;
 } GroupByOrdering;
 
+/*
+ * ExpectedFilter
+ *
+ * Represents the planner's assumption that a scan path will receive a Bloom
+ * filter pushed down at plan-creation time by some hash join above it (see
+ * the bloom filter pushdown logic in createplan.c and nodeHashjoin.c).  When
+ * a scan participates in a hashjoinable equality join, the hash join may build
+ * a Bloom filter on the inner ("build") side keys and push it to the scan on
+ * the outer ("probe") side, eliminating outer tuples that cannot match.
+ *
+ * Because that pushdown happens after path selection, the row/cost estimates
+ * of the affected paths would normally ignore the filter's selectivity.  To
+ * account for it during planning, we generate additional scan paths that carry
+ * one or more ExpectedFilter nodes and whose rows/cost reflect the expected
+ * elimination.  These filters are propagated up the join tree much like
+ * pathkeys, until the hash join that is their "source" realizes them.
+ *
+ * 'owner_relid' is the base relation the filter would be pushed down to.
+ * 'build_relids' is the set of base relids on the other (build) side of the
+ *		join clause(s); the filter is "sourced" by the join that first brings
+ *		those relids together with the owner.
+ * 'clauses' is the list of hashjoinable equality RestrictInfos defining the
+ *		filter keys (the owner side of each clause is a bare Var of owner_relid).
+ * 'selectivity' is the expected surviving fraction of owner rows (in (0,1]).
+ *
+ * XXX The "owner_relid" may be a bit misleading, particularly if we allow
+ * pushing a filter to multiple nodes (e.g. scans on a partition). In that case
+ * we'd have multiple "owners", but ownership suggests there's just one. And
+ * some places already use "consumer" when referencing to the scan nodes, so
+ * maybe we should just use that?
+ *
+ * XXX What if we allow pushdown to non-scan nodes, e.g. above a join when
+ * pushdown to a scan is not possible (e.g. because the join clause is complex
+ * and references multiple relations)? Or maybe we could push to ForeignScan,
+ * and I'm not sure if those have a valid relid in all cases.
+ */
+typedef struct ExpectedFilter
+{
+	pg_node_attr(no_read, no_query_jumble)
+
+	NodeTag		type;
+
+	Index		owner_relid;
+	Relids		build_relids;
+	List	   *clauses pg_node_attr(copy_as_scalar, equal_as_scalar);
+	Selectivity selectivity;
+} ExpectedFilter;
+
 /*
  * VolatileFunctionStatus -- allows nodes to cache their
  * contain_volatile_functions properties. VOLATILITY_UNKNOWN means not yet
@@ -2012,6 +2060,14 @@ typedef struct Path
 
 	/* sort ordering of path's output; a List of PathKey nodes; see above */
 	List	   *pathkeys;
+
+	/*
+	 * Bloom filters this path expects to receive from some hash join above
+	 * it (a List of ExpectedFilter nodes; see below).  An empty list means
+	 * the path makes no such assumption.  The path's rows/cost estimates
+	 * already reflect the expected selectivity of these filters.
+	 */
+	List	   *expected_filters;
 } Path;
 
 /* Macro for extracting a path's parameterization relids; beware double eval */
@@ -2493,6 +2549,16 @@ typedef struct HashPath
 	List	   *path_hashclauses;	/* join clauses used for hashing */
 	int			num_batches;	/* number of batches expected */
 	Cardinality inner_rows_total;	/* total inner rows expected */
+
+	/*
+	 * Bloom filters this hash join "realizes": expected filters (see
+	 * ExpectedFilter) carried by an input path for which this hash join is the
+	 * source, and which it can push down to the scan of the owner relation.
+	 * The pushdown decision is made here, while building paths; create_plan()
+	 * merely propagates it to the plan (see create_hashjoin_plan).  NIL means
+	 * this hash join pushes no filter.
+	 */
+	List	   *realized_filters;
 } HashPath;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index 7339979c008..35ef6bddab2 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -71,6 +71,8 @@ extern PGDLLIMPORT bool enable_parallel_hash;
 extern PGDLLIMPORT bool enable_partition_pruning;
 extern PGDLLIMPORT bool enable_presorted_aggregate;
 extern PGDLLIMPORT bool enable_async_append;
+extern PGDLLIMPORT double bloom_filter_pushdown_threshold;
+extern PGDLLIMPORT int bloom_filter_pushdown_max;
 extern PGDLLIMPORT int constraint_exclusion;
 
 extern double index_pages_fetched(double tuples_fetched, BlockNumber pages,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index e8db321f92b..0dcae2ae3b3 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -64,6 +64,11 @@ extern bool add_partial_path_precheck(RelOptInfo *parent_rel,
 									  int disabled_nodes, Cost startup_cost,
 									  Cost total_cost, List *pathkeys);
 
+extern bool expected_filters_equal(List *a, List *b);
+extern double expected_filters_selectivity(List *filters);
+extern Path *create_filtered_scan_path(PlannerInfo *root, Path *subpath,
+									   List *filters);
+
 extern Path *create_seqscan_path(PlannerInfo *root, RelOptInfo *rel,
 								 Relids required_outer, int parallel_workers);
 extern Path *create_samplescan_path(PlannerInfo *root, RelOptInfo *rel,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index 17f2099ec3b..636761f2e94 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -104,6 +104,7 @@ extern void add_paths_to_joinrel(PlannerInfo *root, RelOptInfo *joinrel,
 								 RelOptInfo *outerrel, RelOptInfo *innerrel,
 								 JoinType jointype, SpecialJoinInfo *sjinfo,
 								 List *restrictlist);
+extern bool bloom_join_side_preserved(JoinType jointype, bool to_outer);
 
 /*
  * joinrels.c
@@ -198,6 +199,11 @@ extern List *generate_implied_equalities_for_column(PlannerInfo *root,
 													ec_matches_callback_type callback,
 													void *callback_arg,
 													Relids prohibited_rels);
+extern List *generate_implied_equalities_for_all_columns(PlannerInfo *root,
+														 RelOptInfo *rel,
+														 ec_matches_callback_type callback,
+														 void *callback_arg,
+														 Relids prohibited_rels);
 extern bool have_relevant_eclass_joinclause(PlannerInfo *root,
 											RelOptInfo *rel1, RelOptInfo *rel2);
 extern bool has_relevant_eclass_joinclause(PlannerInfo *root,
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index bbdb9526471..90578629061 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -147,15 +147,15 @@ GROUP BY t1.a ORDER BY t1.a;
                            Group Key: t2.b
                            ->  Hash Join
                                  Output: t2.c, t2.b, t3.c
-                                 Hash Cond: (t3.a = t2.a)
-                                 ->  Seq Scan on public.eager_agg_t3 t3
-                                       Output: t3.a, t3.b, t3.c
-                                       Bloom Filter 1: keys=(t3.a)
+                                 Hash Cond: (t2.a = t3.a)
+                                 ->  Seq Scan on public.eager_agg_t2 t2
+                                       Output: t2.a, t2.b, t2.c
+                                       Bloom Filter 1: keys=(t2.a)
                                  ->  Hash
-                                       Output: t2.c, t2.b, t2.a
+                                       Output: t3.c, t3.a
                                        Bloom Filter 1
-                                       ->  Seq Scan on public.eager_agg_t2 t2
-                                             Output: t2.c, t2.b, t2.a
+                                       ->  Seq Scan on public.eager_agg_t3 t3
+                                             Output: t3.c, t3.a
 (29 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
@@ -209,15 +209,15 @@ GROUP BY t1.a ORDER BY t1.a;
                                  Sort Key: t2.b
                                  ->  Hash Join
                                        Output: t2.c, t2.b, t3.c
-                                       Hash Cond: (t3.a = t2.a)
-                                       ->  Seq Scan on public.eager_agg_t3 t3
-                                             Output: t3.a, t3.b, t3.c
-                                             Bloom Filter 1: keys=(t3.a)
+                                       Hash Cond: (t2.a = t3.a)
+                                       ->  Seq Scan on public.eager_agg_t2 t2
+                                             Output: t2.a, t2.b, t2.c
+                                             Bloom Filter 1: keys=(t2.a)
                                        ->  Hash
-                                             Output: t2.c, t2.b, t2.a
+                                             Output: t3.c, t3.a
                                              Bloom Filter 1
-                                             ->  Seq Scan on public.eager_agg_t2 t2
-                                                   Output: t2.c, t2.b, t2.a
+                                             ->  Seq Scan on public.eager_agg_t3 t3
+                                                   Output: t3.c, t3.a
 (32 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
@@ -261,16 +261,14 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
-                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
-                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(20 rows)
+(18 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -309,13 +307,11 @@ GROUP BY t2.b ORDER BY t2.b;
                Hash Cond: (t2.b = t1.b)
                ->  Seq Scan on public.eager_agg_t2 t2
                      Output: t2.a, t2.b, t2.c
-                     Bloom Filter 1: keys=(t2.b)
                ->  Hash
                      Output: t1.b
-                     Bloom Filter 1
                      ->  Seq Scan on public.eager_agg_t1 t1
                            Output: t1.b
-(17 rows)
+(15 rows)
 
 SELECT t2.b, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -460,12 +456,12 @@ GROUP BY t1.a ORDER BY t1.a;
    ->  Sort
          Sort Key: t1.a
          ->  Hash Join
-               Hash Cond: (t2.b = t1.b)
-               ->  Seq Scan on eager_agg_t2 t2
+               Hash Cond: (t1.b = t2.b)
+               ->  Seq Scan on eager_agg_t1 t1
                      Bloom Filter 1: keys=(b)
                ->  Hash
                      Bloom Filter 1
-                     ->  Seq Scan on eager_agg_t1 t1
+                     ->  Seq Scan on eager_agg_t2 t2
 (11 rows)
 
 EXPLAIN (COSTS OFF)
@@ -480,12 +476,12 @@ GROUP BY t1.a ORDER BY t1.a;
    ->  Sort
          Sort Key: t1.a
          ->  Hash Join
-               Hash Cond: (t2.b = t1.b)
-               ->  Seq Scan on eager_agg_t2 t2
+               Hash Cond: (t1.b = t2.b)
+               ->  Seq Scan on eager_agg_t1 t1
                      Bloom Filter 1: keys=(b)
                ->  Hash
                      Bloom Filter 1
-                     ->  Seq Scan on eager_agg_t1 t1
+                     ->  Seq Scan on eager_agg_t2 t2
 (11 rows)
 
 -- Eager aggregation must not push a partial aggregate onto the inner side of a
@@ -552,14 +548,16 @@ GROUP BY t2.b ORDER BY t2.b;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b)
                ->  Hash
                      Output: t2.b, (PARTIAL count(*))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL count(*)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t2.b, count(*)
   FROM eager_agg_t2 t2
@@ -621,10 +619,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
-                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -638,10 +634,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
-                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -655,16 +649,14 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
-                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x, t1_2.y
-(55 rows)
+(49 rows)
 
 SELECT t1.x, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -709,10 +701,8 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
-                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -726,10 +716,8 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
-                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -743,16 +731,14 @@ GROUP BY t2.y ORDER BY t2.y;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
-                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.y, t1_2.x
-(55 rows)
+(49 rows)
 
 SELECT t2.y, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -799,10 +785,8 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.x, t2.y
-                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.x)), (PARTIAL count(*)), (PARTIAL avg(t1.x))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.x), PARTIAL count(*), PARTIAL avg(t1.x)
                                  Group Key: t1.x
@@ -813,10 +797,8 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.x, t2_1.y
-                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.x)), (PARTIAL count(*)), (PARTIAL avg(t1_1.x))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.x), PARTIAL count(*), PARTIAL avg(t1_1.x)
                                  Group Key: t1_1.x
@@ -827,16 +809,14 @@ GROUP BY t2.x HAVING avg(t1.x) > 5 ORDER BY t2.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.x, t2_2.y
-                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.x)), (PARTIAL count(*)), (PARTIAL avg(t1_2.x))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.x), PARTIAL count(*), PARTIAL avg(t1_2.x)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x
-(50 rows)
+(44 rows)
 
 SELECT t2.x, sum(t1.x), count(*)
   FROM eager_agg_tab1 t1
@@ -878,10 +858,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab1_p1 t1
                            Output: t1.x
-                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.x, (PARTIAL sum((t2.y + t3.y)))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.x, PARTIAL sum((t2.y + t3.y))
                                  Group Key: t2.x
@@ -890,10 +868,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p1 t2
                                              Output: t2.y, t2.x
-                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
-                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab1_p1 t3
                                                    Output: t3.y, t3.x
          ->  Finalize HashAggregate
@@ -904,10 +880,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab1_p2 t1_1
                            Output: t1_1.x
-                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y)))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y))
                                  Group Key: t2_1.x
@@ -916,10 +890,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p2 t2_1
                                              Output: t2_1.y, t2_1.x
-                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
-                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab1_p2 t3_1
                                                    Output: t3_1.y, t3_1.x
          ->  Finalize HashAggregate
@@ -930,10 +902,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                            Output: t1_2.x
-                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y)))
-                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y))
                                  Group Key: t2_2.x
@@ -942,13 +912,11 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab1_p3 t2_2
                                              Output: t2_2.y, t2_2.x
-                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
-                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab1_p3 t3_2
                                                    Output: t3_2.y, t3_2.x
-(82 rows)
+(70 rows)
 
 SELECT t1.x, sum(t2.y + t3.y)
   FROM eager_agg_tab1 t1
@@ -1118,10 +1086,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2.y = t1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p1 t2
                            Output: t2.y
-                           Bloom Filter 1: keys=(t2.y)
                      ->  Hash
                            Output: t1.x, (PARTIAL sum(t1.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t1.x, PARTIAL sum(t1.y), PARTIAL count(*)
                                  Group Key: t1.x
@@ -1135,10 +1101,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_1.y = t1_1.x)
                      ->  Seq Scan on public.eager_agg_tab2_p2 t2_1
                            Output: t2_1.y
-                           Bloom Filter 2: keys=(t2_1.y)
                      ->  Hash
                            Output: t1_1.x, (PARTIAL sum(t1_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t1_1.x, PARTIAL sum(t1_1.y), PARTIAL count(*)
                                  Group Key: t1_1.x
@@ -1152,16 +1116,14 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on public.eager_agg_tab2_p3 t2_2
                            Output: t2_2.y
-                           Bloom Filter 3: keys=(t2_2.y)
                      ->  Hash
                            Output: t1_2.x, (PARTIAL sum(t1_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t1_2.x, PARTIAL sum(t1_2.y), PARTIAL count(*)
                                  Group Key: t1_2.x
                                  ->  Seq Scan on public.eager_agg_tab1_p3 t1_2
                                        Output: t1_2.x, t1_2.y
-(55 rows)
+(49 rows)
 
 SELECT t1.x, sum(t1.y), count(*)
   FROM eager_agg_tab1 t1
@@ -1224,10 +1186,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
-                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1241,10 +1201,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
-                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1258,10 +1216,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
-                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1275,10 +1231,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
-                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1292,16 +1246,14 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
-                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
-                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(89 rows)
+(79 rows)
 
 SELECT t1.x, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1362,10 +1314,8 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.y, t1.x
-                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1376,10 +1326,8 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.y, t1_1.x
-                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1390,10 +1338,8 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.y, t1_2.x
-                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1404,10 +1350,8 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.y, t1_3.x
-                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1418,16 +1362,14 @@ GROUP BY t1.y ORDER BY t1.y;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.y, t1_4.x
-                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
-                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(77 rows)
+(67 rows)
 
 SELECT t1.y, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1489,10 +1431,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
-                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.x, (PARTIAL sum((t2.y + t3.y))), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.x, PARTIAL sum((t2.y + t3.y)), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1501,10 +1441,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p1 t2
                                              Output: t2.y, t2.x
-                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
-                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab_ml_p1 t3
                                                    Output: t3.y, t3.x
          ->  Finalize HashAggregate
@@ -1515,10 +1453,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
-                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y))), (PARTIAL count(*))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y)), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1527,10 +1463,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t2_1
                                              Output: t2_1.y, t2_1.x
-                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
-                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t3_1
                                                    Output: t3_1.y, t3_1.x
          ->  Finalize HashAggregate
@@ -1541,10 +1475,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
-                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y))), (PARTIAL count(*))
-                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y)), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1553,10 +1485,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t2_2
                                              Output: t2_2.y, t2_2.x
-                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
-                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t3_2
                                                    Output: t3_2.y, t3_2.x
          ->  Finalize HashAggregate
@@ -1567,10 +1497,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
-                           Bloom Filter 8: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, t3_3.x, (PARTIAL sum((t2_3.y + t3_3.y))), (PARTIAL count(*))
-                           Bloom Filter 8
                            ->  Partial HashAggregate
                                  Output: t2_3.x, t3_3.x, PARTIAL sum((t2_3.y + t3_3.y)), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1579,10 +1507,8 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_3.x = t3_3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t2_3
                                              Output: t2_3.y, t2_3.x
-                                             Bloom Filter 7: keys=(t2_3.x)
                                        ->  Hash
                                              Output: t3_3.y, t3_3.x
-                                             Bloom Filter 7
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t3_3
                                                    Output: t3_3.y, t3_3.x
          ->  Finalize HashAggregate
@@ -1593,10 +1519,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
-                           Bloom Filter 10: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, t3_4.x, (PARTIAL sum((t2_4.y + t3_4.y))), (PARTIAL count(*))
-                           Bloom Filter 10
                            ->  Partial HashAggregate
                                  Output: t2_4.x, t3_4.x, PARTIAL sum((t2_4.y + t3_4.y)), PARTIAL count(*)
                                  Group Key: t2_4.x
@@ -1605,13 +1529,11 @@ GROUP BY t1.x ORDER BY t1.x;
                                        Hash Cond: (t2_4.x = t3_4.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                              Output: t2_4.y, t2_4.x
-                                             Bloom Filter 9: keys=(t2_4.x)
                                        ->  Hash
                                              Output: t3_4.y, t3_4.x
-                                             Bloom Filter 9
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t3_4
                                                    Output: t3_4.y, t3_4.x
-(134 rows)
+(114 rows)
 
 SELECT t1.x, sum(t2.y + t3.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1673,10 +1595,8 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
-                           Bloom Filter 2: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, t3.y, t3.x, (PARTIAL sum((t2.y + t3.y))), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2.x, t3.y, t3.x, PARTIAL sum((t2.y + t3.y)), PARTIAL count(*)
                                  Group Key: t2.x, t3.y, t3.x
@@ -1685,10 +1605,8 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2.x = t3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p1 t2
                                              Output: t2.y, t2.x
-                                             Bloom Filter 1: keys=(t2.x)
                                        ->  Hash
                                              Output: t3.y, t3.x
-                                             Bloom Filter 1
                                              ->  Seq Scan on public.eager_agg_tab_ml_p1 t3
                                                    Output: t3.y, t3.x
                ->  Hash Join
@@ -1696,10 +1614,8 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
-                           Bloom Filter 4: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, t3_1.y, t3_1.x, (PARTIAL sum((t2_1.y + t3_1.y))), (PARTIAL count(*))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_1.x, t3_1.y, t3_1.x, PARTIAL sum((t2_1.y + t3_1.y)), PARTIAL count(*)
                                  Group Key: t2_1.x, t3_1.y, t3_1.x
@@ -1708,10 +1624,8 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_1.x = t3_1.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t2_1
                                              Output: t2_1.y, t2_1.x
-                                             Bloom Filter 3: keys=(t2_1.x)
                                        ->  Hash
                                              Output: t3_1.y, t3_1.x
-                                             Bloom Filter 3
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t3_1
                                                    Output: t3_1.y, t3_1.x
                ->  Hash Join
@@ -1719,10 +1633,8 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
-                           Bloom Filter 6: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, t3_2.y, t3_2.x, (PARTIAL sum((t2_2.y + t3_2.y))), (PARTIAL count(*))
-                           Bloom Filter 6
                            ->  Partial HashAggregate
                                  Output: t2_2.x, t3_2.y, t3_2.x, PARTIAL sum((t2_2.y + t3_2.y)), PARTIAL count(*)
                                  Group Key: t2_2.x, t3_2.y, t3_2.x
@@ -1731,10 +1643,8 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_2.x = t3_2.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t2_2
                                              Output: t2_2.y, t2_2.x
-                                             Bloom Filter 5: keys=(t2_2.x)
                                        ->  Hash
                                              Output: t3_2.y, t3_2.x
-                                             Bloom Filter 5
                                              ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t3_2
                                                    Output: t3_2.y, t3_2.x
                ->  Hash Join
@@ -1742,10 +1652,8 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
-                           Bloom Filter 8: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, t3_3.y, t3_3.x, (PARTIAL sum((t2_3.y + t3_3.y))), (PARTIAL count(*))
-                           Bloom Filter 8
                            ->  Partial HashAggregate
                                  Output: t2_3.x, t3_3.y, t3_3.x, PARTIAL sum((t2_3.y + t3_3.y)), PARTIAL count(*)
                                  Group Key: t2_3.x, t3_3.y, t3_3.x
@@ -1754,10 +1662,8 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_3.x = t3_3.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t2_3
                                              Output: t2_3.y, t2_3.x
-                                             Bloom Filter 7: keys=(t2_3.x)
                                        ->  Hash
                                              Output: t3_3.y, t3_3.x
-                                             Bloom Filter 7
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t3_3
                                                    Output: t3_3.y, t3_3.x
                ->  Hash Join
@@ -1765,10 +1671,8 @@ GROUP BY t3.y ORDER BY t3.y;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
-                           Bloom Filter 10: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, t3_4.y, t3_4.x, (PARTIAL sum((t2_4.y + t3_4.y))), (PARTIAL count(*))
-                           Bloom Filter 10
                            ->  Partial HashAggregate
                                  Output: t2_4.x, t3_4.y, t3_4.x, PARTIAL sum((t2_4.y + t3_4.y)), PARTIAL count(*)
                                  Group Key: t2_4.x, t3_4.y, t3_4.x
@@ -1777,13 +1681,11 @@ GROUP BY t3.y ORDER BY t3.y;
                                        Hash Cond: (t2_4.x = t3_4.x)
                                        ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                              Output: t2_4.y, t2_4.x
-                                             Bloom Filter 9: keys=(t2_4.x)
                                        ->  Hash
                                              Output: t3_4.y, t3_4.x
-                                             Bloom Filter 9
                                              ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t3_4
                                                    Output: t3_4.y, t3_4.x
-(122 rows)
+(102 rows)
 
 SELECT t3.y, sum(t2.y + t3.y), count(*)
   FROM eager_agg_tab_ml t1
@@ -1846,10 +1748,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1.x = t2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p1 t1
                            Output: t1.x
-                           Bloom Filter 1: keys=(t1.x)
                      ->  Hash
                            Output: t2.x, (PARTIAL sum(t2.y)), (PARTIAL count(*))
-                           Bloom Filter 1
                            ->  Partial HashAggregate
                                  Output: t2.x, PARTIAL sum(t2.y), PARTIAL count(*)
                                  Group Key: t2.x
@@ -1863,10 +1763,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_1.x = t2_1.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s1 t1_1
                            Output: t1_1.x
-                           Bloom Filter 2: keys=(t1_1.x)
                      ->  Hash
                            Output: t2_1.x, (PARTIAL sum(t2_1.y)), (PARTIAL count(*))
-                           Bloom Filter 2
                            ->  Partial HashAggregate
                                  Output: t2_1.x, PARTIAL sum(t2_1.y), PARTIAL count(*)
                                  Group Key: t2_1.x
@@ -1880,10 +1778,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_2.x = t2_2.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p2_s2 t1_2
                            Output: t1_2.x
-                           Bloom Filter 3: keys=(t1_2.x)
                      ->  Hash
                            Output: t2_2.x, (PARTIAL sum(t2_2.y)), (PARTIAL count(*))
-                           Bloom Filter 3
                            ->  Partial HashAggregate
                                  Output: t2_2.x, PARTIAL sum(t2_2.y), PARTIAL count(*)
                                  Group Key: t2_2.x
@@ -1897,10 +1793,8 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_3.x = t2_3.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s1 t1_3
                            Output: t1_3.x
-                           Bloom Filter 4: keys=(t1_3.x)
                      ->  Hash
                            Output: t2_3.x, (PARTIAL sum(t2_3.y)), (PARTIAL count(*))
-                           Bloom Filter 4
                            ->  Partial HashAggregate
                                  Output: t2_3.x, PARTIAL sum(t2_3.y), PARTIAL count(*)
                                  Group Key: t2_3.x
@@ -1914,16 +1808,14 @@ GROUP BY t1.x ORDER BY t1.x;
                      Hash Cond: (t1_4.x = t2_4.x)
                      ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t1_4
                            Output: t1_4.x
-                           Bloom Filter 5: keys=(t1_4.x)
                      ->  Hash
                            Output: t2_4.x, (PARTIAL sum(t2_4.y)), (PARTIAL count(*))
-                           Bloom Filter 5
                            ->  Partial HashAggregate
                                  Output: t2_4.x, PARTIAL sum(t2_4.y), PARTIAL count(*)
                                  Group Key: t2_4.x
                                  ->  Seq Scan on public.eager_agg_tab_ml_p3_s2 t2_4
                                        Output: t2_4.y, t2_4.x
-(89 rows)
+(79 rows)
 
 SELECT t1.x, sum(t2.y), count(*)
   FROM eager_agg_tab_ml t1
diff --git a/src/test/regress/expected/graph_table.out b/src/test/regress/expected/graph_table.out
index 70d986e8ab0..14fbdcc645a 100644
--- a/src/test/regress/expected/graph_table.out
+++ b/src/test/regress/expected/graph_table.out
@@ -250,8 +250,8 @@ SELECT * FROM x1, GRAPH_TABLE (myshop MATCH (c IS customers WHERE c.address = 'U
 SELECT x1.a, g.* FROM x1, GRAPH_TABLE (myshop MATCH (x1 IS customers WHERE x1.address = 'US')-[IS customer_orders]->(o IS orders) COLUMNS (x1.name AS customer_name, x1.customer_id AS cid, o.order_id)) g;
  a | customer_name | cid | order_id 
 ---+---------------+-----+----------
- 1 | customer1     |   1 |        1
  2 | customer1     |   1 |        1
+ 1 | customer1     |   1 |        1
 (2 rows)
 
 -- lateral reference with multi-label pattern, which is rewritten as UNION of
@@ -407,8 +407,8 @@ SELECT * FROM GRAPH_TABLE (g1 MATCH (a IS vl1 | vl2) COLUMNS (a.vname, a.vprop1)
 SELECT src, conn, dest, lprop1, vprop2, vprop1 FROM GRAPH_TABLE (g1 MATCH (a IS vl1)-[b IS el1]->(c IS vl2 | vl3) COLUMNS (a.vname AS src, b.ename AS conn, c.vname AS dest, c.lprop1, c.vprop2, c.vprop1));
  src | conn | dest |  lprop1  | vprop2 | vprop1 
 -----+------+------+----------+--------+--------
- v12 | e122 | v21  | vl2_prop |   1100 |   1010
  v11 | e121 | v22  | vl2_prop |   1200 |   1020
+ v12 | e122 | v21  | vl2_prop |   1100 |   1010
  v11 | e131 | v33  | vl3_prop |        |   2030
  v11 | e132 | v31  | vl3_prop |        |   2010
 (4 rows)
@@ -417,16 +417,16 @@ SELECT src, conn, dest, lprop1, vprop2, vprop1 FROM GRAPH_TABLE (g1 MATCH (a IS
 SELECT * FROM GRAPH_TABLE (g1 MATCH (v1 IS vl2)-[conn]-(v2) COLUMNS (v1.vname AS v1name, conn.ename AS cname, v2.vname AS v2name));
  v1name | cname | v2name 
 --------+-------+--------
- v21    | e122  | v12
  v22    | e121  | v11
+ v21    | e122  | v12
  v22    | e231  | v32
 (3 rows)
 
 SELECT * FROM GRAPH_TABLE (g1 MATCH (v1 IS vl2)-(v2) COLUMNS (v1.vname AS v1name, v2.vname AS v2name));
  v1name | v2name 
 --------+--------
- v21    | v12
  v22    | v11
+ v21    | v12
  v22    | v32
 (3 rows)
 
@@ -487,8 +487,8 @@ LINE 1: SELECT * FROM GRAPH_TABLE (g1 MATCH (WHERE b.eprop1 = 10001)...
 SELECT * FROM GRAPH_TABLE (g1 MATCH (src)-[conn]->(dest) COLUMNS (src.vname AS svname, conn.ename AS cename, dest.vname AS dvname, src.vprop1 AS svp1, src.vprop2 AS svp2, src.lprop1 AS slp1, dest.vprop1 AS dvp1, dest.vprop2 AS dvp2, dest.lprop1 AS dlp1, conn.eprop1 AS cep1, conn.lprop2 AS clp2));
  svname | cename | dvname | svp1 | svp2 |   slp1   | dvp1 | dvp2 |   dlp1   | cep1  |  clp2  
 --------+--------+--------+------+------+----------+------+------+----------+-------+--------
- v12    | e122   | v21    |   20 |      |          | 1010 | 1100 | vl2_prop | 10002 |       
  v11    | e121   | v22    |   10 |      |          | 1020 | 1200 | vl2_prop | 10001 |       
+ v12    | e122   | v21    |   20 |      |          | 1010 | 1100 | vl2_prop | 10002 |       
  v11    | e131   | v33    |   10 |      |          | 2030 |      | vl3_prop | 10003 |       
  v11    | e132   | v31    |   10 |      |          | 2010 |      | vl3_prop | 10004 |       
  v22    | e231   | v32    | 1020 | 1200 | vl2_prop | 2020 |      | vl3_prop |       | 100050
@@ -498,8 +498,8 @@ SELECT * FROM GRAPH_TABLE (g1 MATCH (src)-[conn]->(dest) COLUMNS (src.vname AS s
 SELECT * FROM GRAPH_TABLE (g1 MATCH (src IS vl1 | vl2 | vl3)-[conn]->(dest) COLUMNS (src.vname AS svname, conn.ename AS cename, dest.vname AS dvname));
  svname | cename | dvname 
 --------+--------+--------
- v12    | e122   | v21
  v11    | e121   | v22
+ v12    | e122   | v21
  v11    | e131   | v33
  v11    | e132   | v31
  v22    | e231   | v32
@@ -519,8 +519,8 @@ SELECT vn FROM all_vertices EXCEPT (SELECT svn FROM all_connected_vertices UNION
 SELECT sn, cn, dn FROM GRAPH_TABLE (g1 MATCH (src IS l1)-[conn IS l1]->(dest IS l1) COLUMNS (src.elname AS sn, conn.elname AS cn, dest.elname AS dn));
  sn  |  cn  | dn  
 -----+------+-----
- v12 | e122 | v21
  v11 | e121 | v22
+ v12 | e122 | v21
  v11 | e131 | v33
  v11 | e132 | v31
  v22 | e231 | v32
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 4fccc7c4057..a04e99f1ed4 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -218,13 +218,13 @@ SELECT t1.a, t2.e
   WHERE t1.a = t2.d;
  a | e  
 ---+----
- 0 |   
  1 | -1
  2 |  2
- 2 |  4
  3 | -3
+ 2 |  4
  5 | -5
  5 | -5
+ 0 |   
 (7 rows)
 
 --
@@ -1573,13 +1573,13 @@ SELECT *
   FROM J1_TBL INNER JOIN J2_TBL USING (i);
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 -- Same as above, slightly different syntax
@@ -1587,13 +1587,13 @@ SELECT *
   FROM J1_TBL JOIN J2_TBL USING (i);
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
@@ -1681,35 +1681,35 @@ SELECT *
   FROM J1_TBL NATURAL JOIN J2_TBL;
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL t1 (a, b, c) NATURAL JOIN J2_TBL t2 (a, d);
  a | b |   c   | d  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL t1 (a, b, c) NATURAL JOIN J2_TBL t2 (d, a);
  a | b |  c   | d 
 ---+---+------+---
- 0 |   | zero |  
  2 | 3 | two  | 2
  4 | 1 | four | 2
+ 0 |   | zero |  
 (3 rows)
 
 -- mismatch number of columns
@@ -1718,13 +1718,13 @@ SELECT *
   FROM J1_TBL t1 (a, b) NATURAL JOIN J2_TBL t2 (a);
  a | b |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 --
@@ -1734,22 +1734,22 @@ SELECT *
   FROM J1_TBL JOIN J2_TBL ON (J1_TBL.i = J2_TBL.i);
  i | j |   t   | i | k  
 ---+---+-------+---+----
- 0 |   | zero  | 0 |   
  1 | 4 | one   | 1 | -1
  2 | 3 | two   | 2 |  2
- 2 | 3 | two   | 2 |  4
  3 | 2 | three | 3 | -3
+ 2 | 3 | two   | 2 |  4
  5 | 0 | five  | 5 | -5
  5 | 0 | five  | 5 | -5
+ 0 |   | zero  | 0 |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL JOIN J2_TBL ON (J1_TBL.i = J2_TBL.k);
  i | j |  t   | i | k 
 ---+---+------+---+---
- 0 |   | zero |   | 0
  2 | 3 | two  | 2 | 2
  4 | 1 | four | 2 | 4
+ 0 |   | zero |   | 0
 (3 rows)
 
 --
@@ -1909,8 +1909,8 @@ select * from tenk1 a, tenk1 b
 where exists(select * from tenk1 c
              where b.twothousand = c.twothousand and b.fivethous <> c.fivethous)
       and a.tenthous = b.tenthous and a.tenthous < 5000;
-                    QUERY PLAN                    
---------------------------------------------------
+                  QUERY PLAN                   
+-----------------------------------------------
  Hash Semi Join
    Hash Cond: (b.twothousand = c.twothousand)
    Join Filter: (b.fivethous <> c.fivethous)
@@ -1918,15 +1918,13 @@ where exists(select * from tenk1 c
          Hash Cond: (b.tenthous = a.tenthous)
          ->  Seq Scan on tenk1 b
                Bloom Filter 1: keys=(tenthous)
-               Bloom Filter 2: keys=(twothousand)
          ->  Hash
                Bloom Filter 1
                ->  Seq Scan on tenk1 a
                      Filter: (tenthous < 5000)
    ->  Hash
-         Bloom Filter 2
          ->  Seq Scan on tenk1 c
-(15 rows)
+(13 rows)
 
 --
 -- More complicated constructs
@@ -2604,14 +2602,12 @@ select * from int4_tbl t1
                Join Filter: (t2.f1 > 0)
                Filter: (t3.f1 IS NULL)
                ->  Seq Scan on int4_tbl t2
-                     Bloom Filter 1: keys=(f1)
                ->  Materialize
                      ->  Seq Scan on int4_tbl t3
          ->  Seq Scan on tenk1 t4
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on int4_tbl t1
-(15 rows)
+(13 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -2630,15 +2626,13 @@ select * from int4_tbl t1
                Join Filter: (t2.f1 > 0)
                Filter: (t2.f1 <> COALESCE(t3.f1, '-1'::integer))
                ->  Seq Scan on int4_tbl t2
-                     Bloom Filter 1: keys=(f1)
                ->  Materialize
                      ->  Seq Scan on int4_tbl t3
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on int4_tbl t1
    ->  Materialize
          ->  Seq Scan on tenk1 t4
-(16 rows)
+(14 rows)
 
 explain (costs off)
 select * from onek t1
@@ -3126,17 +3120,17 @@ set enable_memoize to off;
 explain (costs off)
 select count(*) from tenk1 a, tenk1 b
   where a.hundred = b.thousand and (b.fivethous % 10) < 10;
-                         QUERY PLAN                         
-------------------------------------------------------------
+                            QUERY PLAN                            
+------------------------------------------------------------------
  Aggregate
    ->  Hash Join
-         Hash Cond: (a.hundred = b.thousand)
-         ->  Index Only Scan using tenk1_hundred on tenk1 a
-               Bloom Filter 1: keys=(hundred)
+         Hash Cond: (b.thousand = a.hundred)
+         ->  Seq Scan on tenk1 b
+               Filter: ((fivethous % 10) < 10)
+               Bloom Filter 1: keys=(thousand)
          ->  Hash
                Bloom Filter 1
-               ->  Seq Scan on tenk1 b
-                     Filter: ((fivethous % 10) < 10)
+               ->  Index Only Scan using tenk1_hundred on tenk1 a
 (9 rows)
 
 select count(*) from tenk1 a, tenk1 b
@@ -3180,13 +3174,11 @@ ORDER BY 1;
                Hash Cond: (b.f1 = c.f1)
                Filter: (COALESCE(c.f1, 0) = 0)
                ->  Seq Scan on tt3 b
-                     Bloom Filter 1: keys=(f1)
                ->  Hash
                      ->  Seq Scan on tt3 c
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on tt4 a
-(15 rows)
+(13 rows)
 
 SELECT a.f1
 FROM tt4 a
@@ -3224,12 +3216,10 @@ where t1.filt = 5;
  Hash Join
    Hash Cond: (t2.val = t1.val)
    ->  Seq Scan on skewedtable t2
-         Bloom Filter 1: keys=(val)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on skewedtable t1
                Filter: (filt = 5)
-(8 rows)
+(6 rows)
 
 drop table skewedtable;
 --
@@ -3243,11 +3233,9 @@ where unique1 in (select unique2 from tenk1 b);
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
-         Bloom Filter 1: keys=(unique1)
    ->  Hash
-         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(7 rows)
+(5 rows)
 
 -- sadly, this is not an antijoin
 explain (costs off)
@@ -3269,11 +3257,9 @@ where exists (select 1 from tenk1 b where a.unique1 = b.unique2);
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
-         Bloom Filter 1: keys=(unique1)
    ->  Hash
-         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(7 rows)
+(5 rows)
 
 explain (costs off)
 select a.* from tenk1 a
@@ -3306,17 +3292,15 @@ select 1 from tenk1
 where (hundred, thousand) in (select twothousand, twothousand from onek);
                    QUERY PLAN                    
 -------------------------------------------------
- Hash Join
-   Hash Cond: (tenk1.hundred = onek.twothousand)
-   ->  Seq Scan on tenk1
-         Filter: (hundred = thousand)
-         Bloom Filter 1: keys=(hundred)
+ Hash Right Semi Join
+   Hash Cond: (onek.twothousand = tenk1.hundred)
+   ->  Seq Scan on onek
+         Bloom Filter 1: keys=(twothousand)
    ->  Hash
          Bloom Filter 1
-         ->  HashAggregate
-               Group Key: onek.twothousand
-               ->  Seq Scan on onek
-(10 rows)
+         ->  Seq Scan on tenk1
+               Filter: (hundred = thousand)
+(8 rows)
 
 reset enable_memoize;
 --
@@ -3333,19 +3317,17 @@ where t2.a is null;
  Hash Right Anti Join
    Hash Cond: (t2.b = t1.unique1)
    ->  Seq Scan on tbl_anti t2
-         Bloom Filter 1: keys=(b)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(7 rows)
+(5 rows)
 
 -- this is an antijoin, as t2.a is non-null for any matching row
 explain (costs off)
 select * from tenk1 t1 left join
   (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
 where t2.a is null;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                 
+-------------------------------------------
  Hash Right Anti Join
    Hash Cond: (t2.b = t1.unique1)
    ->  Merge Left Join
@@ -3353,22 +3335,20 @@ where t2.a is null;
          ->  Sort
                Sort Key: t2.c
                ->  Seq Scan on tbl_anti t2
-                     Bloom Filter 1: keys=(b)
          ->  Sort
                Sort Key: t3.c
                ->  Seq Scan on tbl_anti t3
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(14 rows)
+(12 rows)
 
 -- this is not an antijoin, as t3.a can be nulled by t2/t3 join
 explain (costs off)
 select * from tenk1 t1 left join
   (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
 where t3.a is null;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                 
+-------------------------------------------
  Hash Right Join
    Hash Cond: (t2.b = t1.unique1)
    Filter: (t3.a IS NULL)
@@ -3377,14 +3357,12 @@ where t3.a is null;
          ->  Sort
                Sort Key: t2.c
                ->  Seq Scan on tbl_anti t2
-                     Bloom Filter 1: keys=(b)
          ->  Sort
                Sort Key: t3.c
                ->  Seq Scan on tbl_anti t3
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(15 rows)
+(13 rows)
 
 rollback;
 --
@@ -3398,11 +3376,9 @@ where exists (select 1 from tenk1 b where a.unique1 = b.unique2 group by b.uniqu
  Hash Semi Join
    Hash Cond: (a.unique1 = b.unique2)
    ->  Seq Scan on tenk1 a
-         Bloom Filter 1: keys=(unique1)
    ->  Hash
-         Bloom Filter 1
          ->  Index Only Scan using tenk1_unique2 on tenk1 b
-(7 rows)
+(5 rows)
 
 --
 -- regression test for proper handling of outer joins within antijoins
@@ -3572,14 +3548,16 @@ create temp table tidv (idv mycomptype);
 create index on tidv (idv);
 explain (costs off)
 select a.idv, b.idv from tidv a, tidv b where a.idv = b.idv;
-                        QUERY PLAN                        
-----------------------------------------------------------
- Merge Join
-   Merge Cond: (a.idv = b.idv)
-   ->  Index Only Scan using tidv_idv_idx on tidv a
-   ->  Materialize
-         ->  Index Only Scan using tidv_idv_idx on tidv b
-(5 rows)
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (a.idv = b.idv)
+   ->  Seq Scan on tidv a
+         Bloom Filter 1: keys=(idv)
+   ->  Hash
+         Bloom Filter 1
+         ->  Seq Scan on tidv b
+(7 rows)
 
 set enable_mergejoin = 0;
 set enable_hashjoin = 0;
@@ -4019,13 +3997,11 @@ where q1 = thousand or q2 = thousand;
                ->  Seq Scan on q2
          ->  Bitmap Heap Scan on tenk1
                Recheck Cond: ((q1.q1 = thousand) OR (q2.q2 = thousand))
-               Bloom Filter 1: keys=(twothousand)
                ->  Bitmap Index Scan on tenk1_thous_tenthous
                      Index Cond: (thousand = ANY (ARRAY[q1.q1, q2.q2]))
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on int4_tbl
-(14 rows)
+(12 rows)
 
 explain (costs off)
 select * from
@@ -4042,13 +4018,11 @@ where thousand = (q1 + q2);
                ->  Seq Scan on q2
          ->  Bitmap Heap Scan on tenk1
                Recheck Cond: (thousand = (q1.q1 + q2.q2))
-               Bloom Filter 1: keys=(twothousand)
                ->  Bitmap Index Scan on tenk1_thous_tenthous
                      Index Cond: (thousand = (q1.q1 + q2.q2))
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on int4_tbl
-(14 rows)
+(12 rows)
 
 --
 -- test ability to generate a suitable plan for a star-schema query
@@ -4154,10 +4128,8 @@ where t1.unique1 < i4.f1;
                      Hash Cond: (t2.ten = t1.tenthous)
                      ->  Seq Scan on public.tenk1 t2
                            Output: t2.unique1, t2.unique2, t2.two, t2.four, t2.ten, t2.twenty, t2.hundred, t2.thousand, t2.twothousand, t2.fivethous, t2.tenthous, t2.odd, t2.even, t2.stringu1, t2.stringu2, t2.string4
-                           Bloom Filter 1: keys=(t2.ten)
                      ->  Hash
                            Output: t1.tenthous, t1.unique1
-                           Bloom Filter 1
                            ->  Nested Loop
                                  Output: t1.tenthous, t1.unique1
                                  ->  Subquery Scan on ss0
@@ -4173,7 +4145,7 @@ where t1.unique1 < i4.f1;
          ->  Seq Scan on public.int8_tbl i8
                Output: i8.q1, i8.q2
                Filter: (i8.q1 = ((64)::information_schema.cardinal_number)::integer)
-(35 rows)
+(33 rows)
 
 select ss1.d1 from
   tenk1 as t1
@@ -5270,7 +5242,6 @@ order by i0.f1, x;
                Output: i1.f1, i2.q1, i2.q2, '123'::bigint
                ->  Seq Scan on public.int4_tbl i1
                      Output: i1.f1
-                     Bloom Filter 1: keys=(i1.f1)
                ->  Materialize
                      Output: i2.q1, i2.q2
                      ->  Seq Scan on public.int8_tbl i2
@@ -5278,10 +5249,9 @@ order by i0.f1, x;
                            Filter: (123 = i2.q2)
          ->  Hash
                Output: i0.f1
-               Bloom Filter 1
                ->  Seq Scan on public.int4_tbl i0
                      Output: i0.f1
-(21 rows)
+(19 rows)
 
 select * from
 int4_tbl i0 left join
@@ -5335,10 +5305,8 @@ select t1.* from
                            Hash Cond: (i8.q1 = i8b2.q1)
                            ->  Seq Scan on public.int8_tbl i8
                                  Output: i8.q1, i8.q2
-                                 Bloom Filter 1: keys=(i8.q1)
                            ->  Hash
                                  Output: i8b2.q1, (NULL::integer)
-                                 Bloom Filter 1
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, NULL::integer
                      ->  Hash
@@ -5349,7 +5317,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(32 rows)
+(30 rows)
 
 select t1.* from
   text_tbl t1
@@ -5400,12 +5368,10 @@ select t1.* from
                                  Output: i8b2.q1, NULL::integer
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, i8b2.q2
-                                       Bloom Filter 1: keys=(i8b2.q1)
                                  ->  Materialize
                                        ->  Seq Scan on public.int4_tbl i4b2
                            ->  Hash
                                  Output: i8.q1, i8.q2
-                                 Bloom Filter 1
                                  ->  Seq Scan on public.int8_tbl i8
                                        Output: i8.q1, i8.q2
                      ->  Hash
@@ -5416,7 +5382,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(36 rows)
+(34 rows)
 
 select t1.* from
   text_tbl t1
@@ -5469,16 +5435,12 @@ select t1.* from
                                  Hash Cond: (i8b2.q1 = i4b2.f1)
                                  ->  Seq Scan on public.int8_tbl i8b2
                                        Output: i8b2.q1, i8b2.q2
-                                       Bloom Filter 1: keys=(i8b2.q1)
-                                       Bloom Filter 2: keys=(i8b2.q1)
                                  ->  Hash
                                        Output: i4b2.f1
-                                       Bloom Filter 1
                                        ->  Seq Scan on public.int4_tbl i4b2
                                              Output: i4b2.f1
                            ->  Hash
                                  Output: i8.q1, i8.q2
-                                 Bloom Filter 2
                                  ->  Seq Scan on public.int8_tbl i8
                                        Output: i8.q1, i8.q2
                      ->  Hash
@@ -5489,7 +5451,7 @@ select t1.* from
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(41 rows)
+(37 rows)
 
 select t1.* from
   text_tbl t1
@@ -5840,17 +5802,15 @@ where ss1.c2 = 0;
                            Filter: (i43.f1 = 0)
                ->  Seq Scan on public.int4_tbl i41
                      Output: i41.f1
-                     Bloom Filter 1: keys=(i41.f1)
          ->  Hash
                Output: i42.f1
-               Bloom Filter 1
                ->  Seq Scan on public.int4_tbl i42
                      Output: i42.f1
    ->  Limit
          Output: (i41.f1), (i8.q1), (i8.q2), (i42.f1), (i43.f1), ((42))
          ->  Seq Scan on public.text_tbl
                Output: i41.f1, i8.q1, i8.q2, i42.f1, i43.f1, (42)
-(27 rows)
+(25 rows)
 
 select ss2.* from
   int4_tbl i41
@@ -5976,19 +5936,19 @@ explain (costs off)
 select a.unique1, b.unique2
   from onek a left join onek b on a.unique1 = b.unique2
   where (b.unique2, random() > 0) = any (select q1, random() > 0 from int8_tbl c where c.q1 < b.unique1);
-                                                        QUERY PLAN                                                        
---------------------------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                           
+--------------------------------------------------------------------------------------------------------------------------------
  Hash Join
-   Hash Cond: (b.unique2 = a.unique1)
-   ->  Seq Scan on onek b
-         Filter: (ANY ((unique2 = (SubPlan any_1).col1) AND ((random() > '0'::double precision) = (SubPlan any_1).col2)))
-         Bloom Filter 1: keys=(unique2)
-         SubPlan any_1
-           ->  Seq Scan on int8_tbl c
-                 Filter: (q1 < b.unique1)
+   Hash Cond: (a.unique1 = b.unique2)
+   ->  Index Only Scan using onek_unique1 on onek a
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
          Bloom Filter 1
-         ->  Index Only Scan using onek_unique1 on onek a
+         ->  Seq Scan on onek b
+               Filter: (ANY ((unique2 = (SubPlan any_1).col1) AND ((random() > '0'::double precision) = (SubPlan any_1).col2)))
+               SubPlan any_1
+                 ->  Seq Scan on int8_tbl c
+                       Filter: (q1 < b.unique1)
 (11 rows)
 
 select a.unique1, b.unique2
@@ -6142,16 +6102,14 @@ explain (costs off)
 select id from a where id in (
 	select b.id from b left join c on b.id = c.id
 );
-            QUERY PLAN             
------------------------------------
+         QUERY PLAN         
+----------------------------
  Hash Join
    Hash Cond: (a.id = b.id)
    ->  Seq Scan on a
-         Bloom Filter 1: keys=(id)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on b
-(7 rows)
+(5 rows)
 
 -- check optimization with oddly-nested outer joins
 explain (costs off)
@@ -6574,18 +6532,16 @@ explain (costs off)
 select c.id, ss.a from c
   left join (select d.a from onerow, d left join b on d.a = b.id) ss
   on c.id = ss.a;
-               QUERY PLAN               
-----------------------------------------
+           QUERY PLAN           
+--------------------------------
  Hash Right Join
    Hash Cond: (d.a = c.id)
    ->  Nested Loop
          ->  Seq Scan on onerow
          ->  Seq Scan on d
-               Bloom Filter 1: keys=(a)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on c
-(9 rows)
+(7 rows)
 
 -- check the case when the placeholder relates to an outer join and its
 -- inner in the press field but actually uses only the outer side of the join
@@ -8254,33 +8210,32 @@ JOIN (
 		)
 	) _t2t3t4
 ON sj_t1.id = _t2t3t4.id;
-                                     QUERY PLAN                                      
--------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Nested Loop
-   Join Filter: (sj_t3.id = sj_t1.id)
+   Join Filter: (sj_t1.id = sj_t3.id)
    ->  Nested Loop
-         Join Filter: (sj_t2.id = sj_t3.id)
-         ->  Nested Loop Semi Join
+         Join Filter: (sj_t3.id = sj_t2_1.id)
+         ->  Nested Loop
+               Join Filter: (sj_t2.id = sj_t3.id)
                ->  Nested Loop
-                     ->  HashAggregate
-                           Group Key: sj_t3.id
+                     ->  Unique
+                           ->  Nested Loop
+                                 ->  Index Only Scan using sj_t3_a_id_idx on sj_t3 sj_t3_1
+                                       Index Cond: (a = 1)
+                                 ->  Seq Scan on sj_t4 sj_t4_1
+                     ->  Index Only Scan using sj_t2_id_idx on sj_t2
+                           Index Cond: (id = sj_t3_1.id)
+               ->  Materialize
+                     ->  Unique
                            ->  Nested Loop
+                                 ->  Index Only Scan using sj_t3_a_id_idx on sj_t3
+                                       Index Cond: (a = 1)
                                  ->  Seq Scan on sj_t4
-                                 ->  Materialize
-                                       ->  Bitmap Heap Scan on sj_t3
-                                             Recheck Cond: (a = 1)
-                                             ->  Bitmap Index Scan on sj_t3_a_id_idx
-                                                   Index Cond: (a = 1)
-                     ->  Index Only Scan using sj_t2_id_idx on sj_t2 sj_t2_1
-                           Index Cond: (id = sj_t3.id)
-               ->  Nested Loop
-                     ->  Index Only Scan using sj_t3_a_id_idx on sj_t3 sj_t3_1
-                           Index Cond: ((a = 1) AND (id = sj_t3.id))
-                     ->  Seq Scan on sj_t4 sj_t4_1
-         ->  Index Only Scan using sj_t2_id_idx on sj_t2
-               Index Cond: (id = sj_t2_1.id)
+         ->  Index Only Scan using sj_t2_id_idx on sj_t2 sj_t2_1
+               Index Cond: (id = sj_t2.id)
    ->  Seq Scan on sj_t1
-(24 rows)
+(23 rows)
 
 --
 -- Test RowMarks-related code
@@ -9143,15 +9098,13 @@ select * from
                                  Output: b.q1, COALESCE(b.q2, '42'::bigint)
                ->  Seq Scan on public.int8_tbl d
                      Output: d.q1, COALESCE((COALESCE(b.q2, '42'::bigint)), d.q2)
-                     Bloom Filter 1: keys=(d.q1)
          ->  Hash
                Output: c.q1, c.q2
-               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl c
                      Output: c.q1, c.q2
    ->  Result
          Output: (COALESCE((COALESCE(b.q2, '42'::bigint)), d.q2))
-(26 rows)
+(24 rows)
 
 -- another case requiring nested PlaceHolderVars
 explain (verbose, costs off)
@@ -9210,29 +9163,25 @@ select c.*,a.*,ss1.q1,ss2.q1,ss3.* from
                            Join Filter: (b.q1 < b2.f1)
                            ->  Seq Scan on public.int8_tbl b
                                  Output: b.q1, b.q2
-                                 Bloom Filter 1: keys=(b.q1)
                            ->  Materialize
                                  Output: b2.f1
                                  ->  Seq Scan on public.int4_tbl b2
                                        Output: b2.f1
                      ->  Hash
                            Output: a.q1, a.q2
-                           Bloom Filter 1
                            ->  Seq Scan on public.int8_tbl a
                                  Output: a.q1, a.q2
                ->  Seq Scan on public.int8_tbl d
                      Output: d.q1, COALESCE((COALESCE(b.q2, (b2.f1)::bigint)), d.q2)
-                     Bloom Filter 2: keys=(d.q1)
          ->  Hash
                Output: c.q1, c.q2
-               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl c
                      Output: c.q1, c.q2
    ->  Materialize
          Output: i.f1
          ->  Seq Scan on public.int4_tbl i
                Output: i.f1
-(38 rows)
+(34 rows)
 
 -- check processing of postponed quals (bug #9041)
 explain (verbose, costs off)
@@ -9539,10 +9488,8 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                Hash Cond: (t3.b = t2.a)
                ->  Seq Scan on public.join_ut1 t3
                      Output: t3.a, t3.b, t3.c
-                     Bloom Filter 1: keys=(t3.b)
                ->  Hash
                      Output: t2.a
-                     Bloom Filter 1
                      ->  Append
                            ->  Seq Scan on public.join_pt1p1p1 t2_1
                                  Output: t2_1.a
@@ -9550,7 +9497,7 @@ select t1.b, ss.phv from join_ut1 t1 left join lateral
                            ->  Seq Scan on public.join_pt1p2 t2_2
                                  Output: t2_2.a
                                  Filter: (t1.a = t2_2.a)
-(23 rows)
+(21 rows)
 
 select t1.b, ss.phv from join_ut1 t1 left join lateral
               (select t2.a as t2a, t3.a t3a, least(t1.a, t2.a, t3.a) phv
@@ -9600,22 +9547,21 @@ select * from fkest f1
   join fkest f2 on (f1.x = f2.x and f1.x10 = f2.x10b and f1.x100 = f2.x100)
   join fkest f3 on f1.x = f3.x
   where f1.x100 = 2;
-                     QUERY PLAN                      
------------------------------------------------------
+                          QUERY PLAN                           
+---------------------------------------------------------------
  Hash Join
    Hash Cond: ((f2.x = f1.x) AND (f2.x10b = f1.x10))
-   ->  Hash Join
-         Hash Cond: (f3.x = f2.x)
-         ->  Seq Scan on fkest f3
-               Bloom Filter 1: keys=(x)
-         ->  Hash
-               Bloom Filter 1
-               ->  Seq Scan on fkest f2
-                     Filter: (x100 = 2)
+   ->  Nested Loop
+         ->  Seq Scan on fkest f2
+               Filter: (x100 = 2)
+               Bloom Filter 1: keys=(x, x10b)
+         ->  Index Scan using fkest_x_x10_x100_idx on fkest f3
+               Index Cond: (x = f2.x)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on fkest f1
                Filter: (x100 = 2)
-(13 rows)
+(12 rows)
 
 rollback;
 --
@@ -9668,21 +9614,19 @@ analyze j3;
 -- ensure join is properly marked as unique
 explain (verbose, costs off)
 select * from j1 inner join j2 on j1.id = j2.id;
-              QUERY PLAN              
---------------------------------------
+            QUERY PLAN             
+-----------------------------------
  Hash Join
    Output: j1.id, j2.id
    Inner Unique: true
    Hash Cond: (j1.id = j2.id)
    ->  Seq Scan on public.j1
          Output: j1.id
-         Bloom Filter 1: keys=(j1.id)
    ->  Hash
          Output: j2.id
-         Bloom Filter 1
          ->  Seq Scan on public.j2
                Output: j2.id
-(12 rows)
+(10 rows)
 
 -- ensure join is not unique when not an equi-join
 explain (verbose, costs off)
@@ -9707,17 +9651,16 @@ select * from j1 inner join j3 on j1.id = j3.id;
 --------------------------------------
  Hash Join
    Output: j1.id, j3.id
-   Inner Unique: true
-   Hash Cond: (j3.id = j1.id)
-   ->  Seq Scan on public.j3
-         Output: j3.id
-         Bloom Filter 1: keys=(j3.id)
-   ->  Hash
+   Hash Cond: (j1.id = j3.id)
+   ->  Seq Scan on public.j1
          Output: j1.id
+         Bloom Filter 1: keys=(j1.id)
+   ->  Hash
+         Output: j3.id
          Bloom Filter 1
-         ->  Seq Scan on public.j1
-               Output: j1.id
-(12 rows)
+         ->  Seq Scan on public.j3
+               Output: j3.id
+(11 rows)
 
 -- ensure left join is marked as unique
 explain (verbose, costs off)
@@ -9788,64 +9731,70 @@ select * from j1 cross join j2;
 -- ensure a natural join is marked as unique
 explain (verbose, costs off)
 select * from j1 natural join j2;
-              QUERY PLAN              
---------------------------------------
+            QUERY PLAN             
+-----------------------------------
  Hash Join
    Output: j1.id
    Inner Unique: true
    Hash Cond: (j1.id = j2.id)
    ->  Seq Scan on public.j1
          Output: j1.id
-         Bloom Filter 1: keys=(j1.id)
    ->  Hash
          Output: j2.id
-         Bloom Filter 1
          ->  Seq Scan on public.j2
                Output: j2.id
-(12 rows)
+(10 rows)
 
 -- ensure a distinct clause allows the inner to become unique
 explain (verbose, costs off)
 select * from j1
 inner join (select distinct id from j3) j3 on j1.id = j3.id;
-               QUERY PLAN                
------------------------------------------
- Nested Loop
+                  QUERY PLAN                   
+-----------------------------------------------
+ Hash Join
    Output: j1.id, j3.id
    Inner Unique: true
-   Join Filter: (j1.id = j3.id)
-   ->  Unique
+   Hash Cond: (j1.id = j3.id)
+   ->  Seq Scan on public.j1
+         Output: j1.id
+         Bloom Filter 1: keys=(j1.id)
+   ->  Hash
          Output: j3.id
-         ->  Sort
+         Bloom Filter 1
+         ->  Unique
                Output: j3.id
-               Sort Key: j3.id
-               ->  Seq Scan on public.j3
+               ->  Sort
                      Output: j3.id
-   ->  Seq Scan on public.j1
-         Output: j1.id
-(13 rows)
+                     Sort Key: j3.id
+                     ->  Seq Scan on public.j3
+                           Output: j3.id
+(17 rows)
 
 -- ensure group by clause allows the inner to become unique
 explain (verbose, costs off)
 select * from j1
 inner join (select id from j3 group by id) j3 on j1.id = j3.id;
-               QUERY PLAN                
------------------------------------------
- Nested Loop
+                  QUERY PLAN                   
+-----------------------------------------------
+ Hash Join
    Output: j1.id, j3.id
    Inner Unique: true
-   Join Filter: (j1.id = j3.id)
-   ->  Group
+   Hash Cond: (j1.id = j3.id)
+   ->  Seq Scan on public.j1
+         Output: j1.id
+         Bloom Filter 1: keys=(j1.id)
+   ->  Hash
          Output: j3.id
-         Group Key: j3.id
-         ->  Sort
+         Bloom Filter 1
+         ->  Group
                Output: j3.id
-               Sort Key: j3.id
-               ->  Seq Scan on public.j3
+               Group Key: j3.id
+               ->  Sort
                      Output: j3.id
-   ->  Seq Scan on public.j1
-         Output: j1.id
-(14 rows)
+                     Sort Key: j3.id
+                     ->  Seq Scan on public.j3
+                           Output: j3.id
+(18 rows)
 
 drop table j1;
 drop table j2;
@@ -9959,14 +9908,16 @@ create index j2_id1_idx on j2 (id1) where id1 % 1000 = 1;
 explain (costs off) select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
 where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
-               QUERY PLAN                
------------------------------------------
- Merge Join
-   Merge Cond: (j1.id1 = j2.id1)
-   Join Filter: (j2.id2 = j1.id2)
-   ->  Index Scan using j1_id1_idx on j1
-   ->  Index Scan using j2_id1_idx on j2
-(5 rows)
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((j2.id1 = j1.id1) AND (j2.id2 = j1.id2))
+   ->  Seq Scan on j1
+         Filter: ((id1 % 1000) = 1)
+   ->  Seq Scan on j2
+         Filter: ((id1 % 1000) = 1)
+(7 rows)
 
 select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -9981,15 +9932,16 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
 explain (costs off) select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
 where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
-                     QUERY PLAN                     
-----------------------------------------------------
- Merge Join
-   Merge Cond: (j1.id1 = j2.id1)
-   Join Filter: (j2.id2 = j1.id2)
-   ->  Index Scan using j1_id1_idx on j1
-   ->  Index Scan using j2_id1_idx on j2
-         Index Cond: (id1 = ANY ('{1}'::integer[]))
-(6 rows)
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((j2.id1 = j1.id1) AND (j2.id2 = j1.id2))
+   ->  Seq Scan on j1
+         Filter: ((id1 % 1000) = 1)
+   ->  Seq Scan on j2
+         Filter: ((id1 = ANY ('{1}'::integer[])) AND ((id1 % 1000) = 1))
+(7 rows)
 
 select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -10004,15 +9956,16 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
 explain (costs off) select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
 where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 >= any (array[1,5]);
-                      QUERY PLAN                       
--------------------------------------------------------
- Merge Join
-   Merge Cond: (j1.id1 = j2.id1)
-   Join Filter: (j2.id2 = j1.id2)
-   ->  Index Scan using j1_id1_idx on j1
-   ->  Index Scan using j2_id1_idx on j2
-         Index Cond: (id1 >= ANY ('{1,5}'::integer[]))
-(6 rows)
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((j2.id1 = j1.id1) AND (j2.id2 = j1.id2))
+   ->  Seq Scan on j1
+         Filter: ((id1 % 1000) = 1)
+   ->  Seq Scan on j2
+         Filter: ((id1 >= ANY ('{1,5}'::integer[])) AND ((id1 % 1000) = 1))
+(7 rows)
 
 select * from j1
 inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
diff --git a/src/test/regress/expected/join_hash.out b/src/test/regress/expected/join_hash.out
index 0a8ade8b961..21900564149 100644
--- a/src/test/regress/expected/join_hash.out
+++ b/src/test/regress/expected/join_hash.out
@@ -90,17 +90,15 @@ set local work_mem = '4MB';
 set local hash_mem_multiplier = 1.0;
 explain (costs off)
   select count(*) from simple r join simple s using (id);
-               QUERY PLAN                
------------------------------------------
+               QUERY PLAN               
+----------------------------------------
  Aggregate
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
-               Bloom Filter 1: keys=(id)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on simple s
-(8 rows)
+(6 rows)
 
 select count(*) from simple r join simple s using (id);
  count 
@@ -205,17 +203,15 @@ set local work_mem = '128kB';
 set local hash_mem_multiplier = 1.0;
 explain (costs off)
   select count(*) from simple r join simple s using (id);
-               QUERY PLAN                
------------------------------------------
+               QUERY PLAN               
+----------------------------------------
  Aggregate
    ->  Hash Join
          Hash Cond: (r.id = s.id)
          ->  Seq Scan on simple r
-               Bloom Filter 1: keys=(id)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on simple s
-(8 rows)
+(6 rows)
 
 select count(*) from simple r join simple s using (id);
  count 
diff --git a/src/test/regress/expected/merge.out b/src/test/regress/expected/merge.out
index c5aa11cd249..40461acf17c 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -39,18 +39,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	DELETE;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
-   ->  Merge Join
-         Merge Cond: (t.tid = s.sid)
-         ->  Sort
-               Sort Key: t.tid
-               ->  Seq Scan on target t
-         ->  Sort
-               Sort Key: s.sid
+   ->  Hash Join
+         Hash Cond: (t.tid = s.sid)
+         ->  Seq Scan on target t
+               Bloom Filter 1: keys=(tid)
+         ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on source s
-(9 rows)
+(8 rows)
 
 --
 -- Errors
@@ -1640,42 +1639,38 @@ SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a
 WHEN MATCHED THEN
 	UPDATE SET b = t.b + 1');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                      explain_merge                                       
+------------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=50
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=8388608 hashes=10 memory=1024kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- only updates to selected tuples
 SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a
 WHEN MATCHED AND t.a < 10 THEN
 	UPDATE SET b = t.b + 1');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                      explain_merge                                       
+------------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=5 skipped=45
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=8388608 hashes=10 memory=1024kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- updates + deletes
 SELECT explain_merge('
@@ -1684,21 +1679,19 @@ WHEN MATCHED AND t.a < 10 THEN
 	UPDATE SET b = t.b + 1
 WHEN MATCHED AND t.a >= 10 AND t.a <= 20 THEN
 	DELETE');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                      explain_merge                                       
+------------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=5 deleted=5 skipped=40
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=8388608 hashes=10 memory=1024kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- only inserts
 SELECT explain_merge('
@@ -1795,21 +1788,20 @@ SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a AND t.a < -1000
 WHEN MATCHED AND t.a < 10 THEN
 	DO NOTHING');
-                             explain_merge                             
------------------------------------------------------------------------
+                                      explain_merge                                      
+-----------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
-   ->  Merge Join (actual rows=0.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=0.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=0.00 loops=1)
+         Hash Cond: (s.a = t.a)
+         ->  Seq Scan on ex_msource s (actual rows=1.00 loops=1)
+               Bloom Filter 1: keys=(a) checked=0 rejected=0 (0.0%)
+         ->  Hash (actual rows=0.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=8388608 hashes=10 memory=1024kB checked=0 rejected=0
                ->  Seq Scan on ex_mtarget t (actual rows=0.00 loops=1)
                      Filter: (a < '-1000'::integer)
                      Rows Removed by Filter: 54
-         ->  Sort (never executed)
-               Sort Key: s.a
-               ->  Seq Scan on ex_msource s (never executed)
-(12 rows)
+(11 rows)
 
 DROP TABLE ex_msource, ex_mtarget;
 DROP FUNCTION explain_merge(text);
diff --git a/src/test/regress/expected/misc_functions.out b/src/test/regress/expected/misc_functions.out
index b52528870ef..7fff6a720aa 100644
--- a/src/test/regress/expected/misc_functions.out
+++ b/src/test/regress/expected/misc_functions.out
@@ -614,14 +614,16 @@ CREATE FUNCTION my_gen_series(int, int) RETURNS SETOF integer
   SUPPORT test_support_func;
 EXPLAIN (COSTS OFF)
 SELECT * FROM tenk1 a JOIN my_gen_series(1,1000) g ON a.unique1 = g;
-               QUERY PLAN               
-----------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  Hash Join
-   Hash Cond: (g.g = a.unique1)
-   ->  Function Scan on my_gen_series g
+   Hash Cond: (a.unique1 = g.g)
+   ->  Seq Scan on tenk1 a
+         Bloom Filter 1: keys=(unique1)
    ->  Hash
-         ->  Seq Scan on tenk1 a
-(5 rows)
+         Bloom Filter 1
+         ->  Function Scan on my_gen_series g
+(7 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT * FROM tenk1 a JOIN my_gen_series(1,10) g ON a.unique1 = g;
diff --git a/src/test/regress/expected/partition_aggregate.out b/src/test/regress/expected/partition_aggregate.out
index be56036461b..c30304b99c7 100644
--- a/src/test/regress/expected/partition_aggregate.out
+++ b/src/test/regress/expected/partition_aggregate.out
@@ -460,29 +460,23 @@ SELECT t1.x, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                ->  Hash Join
                      Hash Cond: (t1.x = t2.y)
                      ->  Seq Scan on pagg_tab1_p1 t1
-                           Bloom Filter 1: keys=(x)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 t2
          ->  HashAggregate
                Group Key: t1_1.x
                ->  Hash Join
                      Hash Cond: (t1_1.x = t2_1.y)
                      ->  Seq Scan on pagg_tab1_p2 t1_1
-                           Bloom Filter 2: keys=(x)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 t2_1
          ->  HashAggregate
                Group Key: t1_2.x
                ->  Hash Join
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on pagg_tab2_p3 t2_2
-                           Bloom Filter 3: keys=(y)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on pagg_tab1_p3 t1_2
-(30 rows)
+(24 rows)
 
 SELECT t1.x, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2.y GROUP BY t1.x ORDER BY 1, 2, 3;
  x  | sum  | count 
@@ -539,29 +533,23 @@ SELECT t2.y, sum(t1.y), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                ->  Hash Join
                      Hash Cond: (t1.x = t2.y)
                      ->  Seq Scan on pagg_tab1_p1 t1
-                           Bloom Filter 1: keys=(x)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 t2
          ->  HashAggregate
                Group Key: t2_1.y
                ->  Hash Join
                      Hash Cond: (t1_1.x = t2_1.y)
                      ->  Seq Scan on pagg_tab1_p2 t1_1
-                           Bloom Filter 2: keys=(x)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 t2_1
          ->  HashAggregate
                Group Key: t2_2.y
                ->  Hash Join
                      Hash Cond: (t2_2.y = t1_2.x)
                      ->  Seq Scan on pagg_tab2_p3 t2_2
-                           Bloom Filter 3: keys=(y)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on pagg_tab1_p3 t1_2
-(30 rows)
+(24 rows)
 
 -- When GROUP BY clause does not match; partial aggregation is performed for each partition.
 -- Also test GroupAggregate paths by disabling hash aggregates.
@@ -584,9 +572,7 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t1.x = t2.y)
                                  ->  Seq Scan on pagg_tab1_p1 t1
-                                       Bloom Filter 1: keys=(x)
                                  ->  Hash
-                                       Bloom Filter 1
                                        ->  Seq Scan on pagg_tab2_p1 t2
                ->  Partial GroupAggregate
                      Group Key: t1_1.y
@@ -595,9 +581,7 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t1_1.x = t2_1.y)
                                  ->  Seq Scan on pagg_tab1_p2 t1_1
-                                       Bloom Filter 2: keys=(x)
                                  ->  Hash
-                                       Bloom Filter 2
                                        ->  Seq Scan on pagg_tab2_p2 t2_1
                ->  Partial GroupAggregate
                      Group Key: t1_2.y
@@ -606,11 +590,9 @@ SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2
                            ->  Hash Join
                                  Hash Cond: (t2_2.y = t1_2.x)
                                  ->  Seq Scan on pagg_tab2_p3 t2_2
-                                       Bloom Filter 3: keys=(y)
                                  ->  Hash
-                                       Bloom Filter 3
                                        ->  Seq Scan on pagg_tab1_p3 t1_2
-(40 rows)
+(34 rows)
 
 SELECT t1.y, sum(t1.x), count(*) FROM pagg_tab1 t1, pagg_tab2 t2 WHERE t1.x = t2.y GROUP BY t1.y HAVING avg(t1.x) > 10 ORDER BY 1, 2, 3;
  y  | sum  | count 
@@ -656,11 +638,9 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP B
                      ->  Hash Right Join
                            Hash Cond: (b_2.y = a_2.x)
                            ->  Seq Scan on pagg_tab2_p3 b_2
-                                 Bloom Filter 1: keys=(y)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on pagg_tab1_p3 a_2
-(28 rows)
+(26 rows)
 
 SELECT b.y, sum(a.y) FROM pagg_tab1 a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP BY b.y ORDER BY 1 NULLS LAST;
  y  | sum  
@@ -687,18 +667,14 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP
                ->  Hash Right Join
                      Hash Cond: (a.x = b.y)
                      ->  Seq Scan on pagg_tab1_p1 a
-                           Bloom Filter 1: keys=(x)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on pagg_tab2_p1 b
          ->  HashAggregate
                Group Key: b_1.y
                ->  Hash Right Join
                      Hash Cond: (a_1.x = b_1.y)
                      ->  Seq Scan on pagg_tab1_p2 a_1
-                           Bloom Filter 2: keys=(x)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on pagg_tab2_p2 b_1
          ->  HashAggregate
                Group Key: b_2.y
@@ -707,7 +683,7 @@ SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP
                      ->  Seq Scan on pagg_tab2_p3 b_2
                      ->  Hash
                            ->  Seq Scan on pagg_tab1_p3 a_2
-(28 rows)
+(24 rows)
 
 SELECT b.y, sum(a.y) FROM pagg_tab1 a RIGHT JOIN pagg_tab2 b ON a.x = b.y GROUP BY b.y ORDER BY 1 NULLS LAST;
  y  | sum  
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 1906b3641a3..38643d41fd7 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -36,28 +36,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -83,28 +77,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a =
          ->  Hash Join
                Hash Cond: (t1_1.a = t2_1.a)
                ->  Seq Scan on prt1_p1 t1_1
-                     Bloom Filter 1: keys=(a)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = b)
          ->  Hash Join
                Hash Cond: (t1_2.a = t2_2.a)
                ->  Seq Scan on prt1_p2 t1_2
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = b)
          ->  Hash Join
                Hash Cond: (t1_3.a = t2_3.a)
                ->  Seq Scan on prt1_p3 t1_3
-                     Bloom Filter 3: keys=(a)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt2_p3 t2_3
                            Filter: (a = b)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -214,17 +202,13 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHE
          ->  Hash Right Join
                Hash Cond: (t1_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t1_1
-                     Bloom Filter 1: keys=(a)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = 0)
          ->  Hash Right Join
                Hash Cond: (t1_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t1_2
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = 0)
          ->  Nested Loop Left Join
@@ -232,7 +216,7 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHE
                      Filter: (a = 0)
                ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
                      Index Cond: (a = t2_3.b)
-(24 rows)
+(20 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -299,12 +283,10 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.a <
          Hash Cond: (t2.b = t1.a)
          ->  Seq Scan on prt2_p2 t2
                Filter: (b > 250)
-               Bloom Filter 1: keys=(b)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on prt1_p2 t1
                      Filter: ((a < 450) AND (b = 0))
-(11 rows)
+(9 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.a < 450 AND t2.b > 250 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -400,18 +382,14 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0)
                Hash Cond: (t1_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t1_1
                      Filter: (b = 0)
-                     Bloom Filter 1: keys=(a)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
                            Filter: (a = 0)
          ->  Hash Semi Join
                Hash Cond: (t1_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t1_2
                      Filter: (b = 0)
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
                            Filter: (a = 0)
          ->  Nested Loop Semi Join
@@ -421,7 +399,7 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0)
                ->  Materialize
                      ->  Seq Scan on prt2_p3 t2_3
                            Filter: (a = 0)
-(28 rows)
+(24 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t2.b FROM prt2 t2 WHERE t2.a = 0) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -537,25 +515,19 @@ SELECT t1.a, ss.t2a, ss.t2c FROM prt1 t1 LEFT JOIN LATERAL
                      ->  Hash Join
                            Hash Cond: (t2_1.a = t3_1.b)
                            ->  Seq Scan on prt1_p1 t2_1
-                                 Bloom Filter 1: keys=(a)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on prt2_p1 t3_1
                      ->  Hash Join
                            Hash Cond: (t2_2.a = t3_2.b)
                            ->  Seq Scan on prt1_p2 t2_2
-                                 Bloom Filter 2: keys=(a)
                            ->  Hash
-                                 Bloom Filter 2
                                  ->  Seq Scan on prt2_p2 t3_2
                      ->  Hash Join
                            Hash Cond: (t2_3.a = t3_3.b)
                            ->  Seq Scan on prt1_p3 t2_3
-                                 Bloom Filter 3: keys=(a)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on prt2_p3 t3_3
-(32 rows)
+(26 rows)
 
 SELECT t1.a, ss.t2a, ss.t2c FROM prt1 t1 LEFT JOIN LATERAL
 			  (SELECT t2.a AS t2a, t3.a AS t3a, t2.b t2b, t2.c t2c, least(t1.a,t2.a,t3.a) FROM prt1 t2 JOIN prt2 t3 ON (t2.a = t3.b)) ss
@@ -756,41 +728,29 @@ SELECT * FROM prt1 t1 JOIN prt1 t2 ON t1.a = t2.a WHERE t1.a IN (SELECT a FROM p
          ->  Hash Join
                Hash Cond: (t1_1.a = t2_1.a)
                ->  Seq Scan on prt1_p1 t1_1
-                     Bloom Filter 1: keys=(a)
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t2_1
          ->  Hash
-               Bloom Filter 2
                ->  Seq Scan on prt1_p1 t3_1
    ->  Hash Semi Join
          Hash Cond: (t1_2.a = t3_2.a)
          ->  Hash Join
                Hash Cond: (t1_2.a = t2_2.a)
                ->  Seq Scan on prt1_p2 t1_2
-                     Bloom Filter 3: keys=(a)
-                     Bloom Filter 4: keys=(a)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_p2 t2_2
          ->  Hash
-               Bloom Filter 4
                ->  Seq Scan on prt1_p2 t3_2
    ->  Hash Semi Join
          Hash Cond: (t1_3.a = t3_3.a)
          ->  Hash Join
                Hash Cond: (t1_3.a = t2_3.a)
                ->  Seq Scan on prt1_p3 t1_3
-                     Bloom Filter 5: keys=(a)
-                     Bloom Filter 6: keys=(a)
                ->  Hash
-                     Bloom Filter 5
                      ->  Seq Scan on prt1_p3 t2_3
          ->  Hash
-               Bloom Filter 6
                ->  Seq Scan on prt1_p3 t3_3
-(40 rows)
+(28 rows)
 
 --
 -- partitioned by expression
@@ -861,9 +821,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_1.b = t1_1.a)
                      ->  Seq Scan on prt2_p1 t2_1
-                           Bloom Filter 1: keys=(b)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on prt1_p1 t1_1
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p1_ab2 on prt1_e_p1 t3_1
@@ -873,9 +831,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_2.b = t1_2.a)
                      ->  Seq Scan on prt2_p2 t2_2
-                           Bloom Filter 2: keys=(b)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on prt1_p2 t1_2
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p2_ab2 on prt1_e_p2 t3_2
@@ -885,14 +841,12 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                ->  Hash Join
                      Hash Cond: (t2_3.b = t1_3.a)
                      ->  Seq Scan on prt2_p3 t2_3
-                           Bloom Filter 3: keys=(b)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on prt1_p3 t1_3
                                  Filter: (b = 0)
                ->  Index Scan using iprt1_e_p3_ab2 on prt1_e_p3 t3_3
                      Index Cond: (((a + b) / 2) = t2_3.b)
-(39 rows)
+(33 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t3 WHERE t1.a = t2.b AND t1.a = (t3.a + t3.b)/2 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -917,9 +871,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_1.b = t1_1.a)
                            ->  Seq Scan on prt2_p1 t2_1
-                                 Bloom Filter 1: keys=(b)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_p1 t1_1
                                        Filter: (b = 0)
          ->  Hash Right Join
@@ -929,9 +881,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_2.b = t1_2.a)
                            ->  Seq Scan on prt2_p2 t2_2
-                                 Bloom Filter 2: keys=(b)
                            ->  Hash
-                                 Bloom Filter 2
                                  ->  Seq Scan on prt1_p2 t1_2
                                        Filter: (b = 0)
          ->  Hash Right Join
@@ -941,12 +891,10 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                      ->  Hash Right Join
                            Hash Cond: (t2_3.b = t1_3.a)
                            ->  Seq Scan on prt2_p3 t2_3
-                                 Bloom Filter 3: keys=(b)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_p3 t1_3
                                        Filter: (b = 0)
-(39 rows)
+(33 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) LEFT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -976,9 +924,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_1.a = ((t3_1.a + t3_1.b) / 2))
                      ->  Seq Scan on prt1_p1 t1_1
-                           Bloom Filter 1: keys=(a)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on prt1_e_p1 t3_1
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p1_b on prt2_p1 t2_1
@@ -987,9 +933,7 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_2.a = ((t3_2.a + t3_2.b) / 2))
                      ->  Seq Scan on prt1_p2 t1_2
-                           Bloom Filter 2: keys=(a)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on prt1_e_p2 t3_2
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p2_b on prt2_p2 t2_2
@@ -998,14 +942,12 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Hash Right Join
                      Hash Cond: (t1_3.a = ((t3_3.a + t3_3.b) / 2))
                      ->  Seq Scan on prt1_p3 t1_3
-                           Bloom Filter 3: keys=(a)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on prt1_e_p3 t3_3
                                  Filter: (c = 0)
                ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
                      Index Cond: (b = t1_3.a)
-(36 rows)
+(30 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) RIGHT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t3.c = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -1263,9 +1205,7 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_6.b = ((t1_9.a + t1_9.b) / 2))
                            ->  Seq Scan on prt2_p1 t1_6
-                                 Bloom Filter 1: keys=(b)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_e_p1 t1_9
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p1_a on prt1_p1 t1_3
@@ -1278,9 +1218,7 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_7.b = ((t1_10.a + t1_10.b) / 2))
                            ->  Seq Scan on prt2_p2 t1_7
-                                 Bloom Filter 2: keys=(b)
                            ->  Hash
-                                 Bloom Filter 2
                                  ->  Seq Scan on prt1_e_p2 t1_10
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p2_a on prt1_p2 t1_4
@@ -1293,15 +1231,13 @@ SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (
                      ->  Hash Semi Join
                            Hash Cond: (t1_8.b = ((t1_11.a + t1_11.b) / 2))
                            ->  Seq Scan on prt2_p3 t1_8
-                                 Bloom Filter 3: keys=(b)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_e_p3 t1_11
                                        Filter: (c = 0)
          ->  Index Scan using iprt1_p3_a on prt1_p3 t1_5
                Index Cond: (a = t1_8.b)
                Filter: (b = 0)
-(47 rows)
+(41 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -1631,41 +1567,29 @@ SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM plt1 t1, pl
                      ->  Hash Join
                            Hash Cond: ((t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                            ->  Seq Scan on plt1_p1 t1_1
-                                 Bloom Filter 1: keys=(b, c)
-                                 Bloom Filter 2: keys=(c)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on plt2_p1 t2_1
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on plt1_e_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.c = ltrim(t3_2.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                            ->  Seq Scan on plt1_p2 t1_2
-                                 Bloom Filter 3: keys=(b, c)
-                                 Bloom Filter 4: keys=(c)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on plt2_p2 t2_2
                      ->  Hash
-                           Bloom Filter 4
                            ->  Seq Scan on plt1_e_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.c = ltrim(t3_3.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_3.b = t2_3.b) AND (t1_3.c = t2_3.c))
                            ->  Seq Scan on plt1_p3 t1_3
-                                 Bloom Filter 5: keys=(b, c)
-                                 Bloom Filter 6: keys=(c)
                            ->  Hash
-                                 Bloom Filter 5
                                  ->  Seq Scan on plt2_p3 t2_3
                      ->  Hash
-                           Bloom Filter 6
                            ->  Seq Scan on plt1_e_p3 t3_3
-(44 rows)
+(32 rows)
 
 SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM plt1 t1, plt2 t2, plt1_e t3 WHERE t1.b = t2.b AND t1.c = t2.c AND ltrim(t3.c, 'A') = t1.c GROUP BY t1.c, t2.c, t3.c ORDER BY t1.c, t2.c, t3.c;
          avg          |         avg          |          avg          |  c   |  c   |   c   
@@ -1713,29 +1637,23 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
          ->  Hash Join
                Hash Cond: (t3_1.a = t2_1.b)
                ->  Seq Scan on prt1_p1 t3_1
-                     Bloom Filter 1: keys=(a)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt2_p1 t2_1
          ->  Hash Join
                Hash Cond: (t3_2.a = t2_2.b)
                ->  Seq Scan on prt1_p2 t3_2
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt2_p2 t2_2
          ->  Hash Join
                Hash Cond: (t3_3.a = t2_3.b)
                ->  Seq Scan on prt1_p3 t3_3
-                     Bloom Filter 3: keys=(a)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt2_p3 t2_3
    ->  Hash
          ->  Result
                Replaces: Scan on prt1
                One-Time Filter: false
-(28 rows)
+(22 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 FULL JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
@@ -1797,41 +1715,29 @@ SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM pht1 t1, ph
                      ->  Hash Join
                            Hash Cond: ((t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                            ->  Seq Scan on pht1_p1 t1_1
-                                 Bloom Filter 1: keys=(b, c)
-                                 Bloom Filter 2: keys=(c)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on pht2_p1 t2_1
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on pht1_e_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.c = ltrim(t3_2.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                            ->  Seq Scan on pht1_p2 t1_2
-                                 Bloom Filter 3: keys=(b, c)
-                                 Bloom Filter 4: keys=(c)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on pht2_p2 t2_2
                      ->  Hash
-                           Bloom Filter 4
                            ->  Seq Scan on pht1_e_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.c = ltrim(t3_3.c, 'A'::text))
                      ->  Hash Join
                            Hash Cond: ((t1_3.b = t2_3.b) AND (t1_3.c = t2_3.c))
                            ->  Seq Scan on pht1_p3 t1_3
-                                 Bloom Filter 5: keys=(b, c)
-                                 Bloom Filter 6: keys=(c)
                            ->  Hash
-                                 Bloom Filter 5
                                  ->  Seq Scan on pht2_p3 t2_3
                      ->  Hash
-                           Bloom Filter 6
                            ->  Seq Scan on pht1_e_p3 t3_3
-(44 rows)
+(32 rows)
 
 SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM pht1 t1, pht2 t2, pht1_e t3 WHERE t1.b = t2.b AND t1.c = t2.c AND ltrim(t3.c, 'A') = t1.c GROUP BY t1.c, t2.c, t3.c ORDER BY t1.c, t2.c, t3.c;
          avg          |         avg          |         avg          |  c   |  c   |   c   
@@ -1861,28 +1767,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 -- test default partition behavior for list
 ALTER TABLE plt1 DETACH PARTITION plt1_p3;
@@ -1903,28 +1803,22 @@ SELECT avg(t1.a), avg(t2.b), t1.c, t2.c FROM plt1 t1 RIGHT JOIN plt2 t2 ON t1.c
                ->  Hash Join
                      Hash Cond: (t2_1.c = t1_1.c)
                      ->  Seq Scan on plt2_p1 t2_1
-                           Bloom Filter 1: keys=(c)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on plt1_p1 t1_1
                                  Filter: ((a % 25) = 0)
                ->  Hash Join
                      Hash Cond: (t2_2.c = t1_2.c)
                      ->  Seq Scan on plt2_p2 t2_2
-                           Bloom Filter 2: keys=(c)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on plt1_p2 t1_2
                                  Filter: ((a % 25) = 0)
                ->  Hash Join
                      Hash Cond: (t2_3.c = t1_3.c)
                      ->  Seq Scan on plt2_p3 t2_3
-                           Bloom Filter 3: keys=(c)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on plt1_p3 t1_3
                                  Filter: ((a % 25) = 0)
-(29 rows)
+(23 rows)
 
 --
 -- multiple levels of partitioning
@@ -1960,9 +1854,7 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_l_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_l_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
@@ -1984,7 +1876,7 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1
                ->  Hash
                      ->  Seq Scan on prt1_l_p3_p1 t1_5
                            Filter: (b = 0)
-(30 rows)
+(28 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_l t1, prt2_l t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2463,25 +2355,19 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt4_n t2, prt2 t3 WHERE t1.a = t2.a
                ->  Hash Join
                      Hash Cond: (t1_1.a = t3_1.b)
                      ->  Seq Scan on prt1_p1 t1_1
-                           Bloom Filter 1: keys=(a)
                      ->  Hash
-                           Bloom Filter 1
                            ->  Seq Scan on prt2_p1 t3_1
                ->  Hash Join
                      Hash Cond: (t1_2.a = t3_2.b)
                      ->  Seq Scan on prt1_p2 t1_2
-                           Bloom Filter 2: keys=(a)
                      ->  Hash
-                           Bloom Filter 2
                            ->  Seq Scan on prt2_p2 t3_2
                ->  Hash Join
                      Hash Cond: (t1_3.a = t3_3.b)
                      ->  Seq Scan on prt1_p3 t1_3
-                           Bloom Filter 3: keys=(a)
                      ->  Hash
-                           Bloom Filter 3
                            ->  Seq Scan on prt2_p3 t3_3
-(29 rows)
+(23 rows)
 
 -- partitionwise join can not be applied if there are no equi-join conditions
 -- between partition keys
@@ -2753,28 +2639,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2800,28 +2680,22 @@ SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -2847,28 +2721,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -2898,28 +2766,22 @@ SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -2986,28 +2848,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3033,28 +2889,22 @@ SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Semi Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -3080,28 +2930,22 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3157,28 +3001,22 @@ SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: (b = 0)
          ->  Hash Right Anti Join
                Hash Cond: (t2_3.b = t1_3.a)
                ->  Seq Scan on prt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(b)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on prt1_adv_p3 t1_3
                            Filter: (b = 0)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM prt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM prt2_adv t2 WHERE t1.a = t2.b) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -3264,32 +3102,24 @@ SELECT t1.b, t1.c, t2.a, t2.c, t3.a, t3.c FROM prt2_adv t1 LEFT JOIN prt1_adv t2
          ->  Hash Right Join
                Hash Cond: (t2_2.a = t1_2.b)
                ->  Seq Scan on prt1_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Hash Join
                            Hash Cond: (t3_2.a = t1_2.b)
                            ->  Seq Scan on prt1_adv_p2 t3_2
-                                 Bloom Filter 1: keys=(a)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on prt2_adv_p2 t1_2
                                        Filter: (a = 0)
          ->  Hash Right Join
                Hash Cond: (t2_3.a = t1_3.b)
                ->  Seq Scan on prt1_adv_p3 t2_3
-                     Bloom Filter 4: keys=(a)
                ->  Hash
-                     Bloom Filter 4
                      ->  Hash Join
                            Hash Cond: (t3_3.a = t1_3.b)
                            ->  Seq Scan on prt1_adv_p3 t3_3
-                                 Bloom Filter 3: keys=(a)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on prt2_adv_p3 t1_3
                                        Filter: (a = 0)
-(39 rows)
+(31 rows)
 
 SELECT t1.b, t1.c, t2.a, t2.c, t3.a, t3.c FROM prt2_adv t1 LEFT JOIN prt1_adv t2 ON (t1.b = t2.a) INNER JOIN prt1_adv t3 ON (t1.b = t3.a) WHERE t1.a = 0 ORDER BY t1.b, t2.a, t3.a;
   b  |  c   |  a  |  c   |  a  |  c   
@@ -3459,20 +3289,16 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_2
                            Filter: (b = 0)
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_1
                            Filter: (b = 0)
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3564,32 +3390,24 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a, t3.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2
          ->  Hash Right Join
                Hash Cond: (t3_1.a = t1_1.a)
                ->  Seq Scan on prt3_adv_p1 t3_1
-                     Bloom Filter 2: keys=(a)
                ->  Hash
-                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: (t2_2.b = t1_1.a)
                            ->  Seq Scan on prt2_adv_p2 t2_2
-                                 Bloom Filter 1: keys=(b)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on prt1_adv_p2 t1_1
                                        Filter: (b = 0)
          ->  Hash Right Join
                Hash Cond: (t3_2.a = t1_2.a)
                ->  Seq Scan on prt3_adv_p2 t3_2
-                     Bloom Filter 4: keys=(a)
                ->  Hash
-                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: (t2_1.b = t1_2.a)
                            ->  Seq Scan on prt2_adv_p1 t2_1
-                                 Bloom Filter 3: keys=(b)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on prt1_adv_p1 t1_2
                                        Filter: (b = 0)
-(31 rows)
+(23 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a, t3.c FROM prt1_adv t1 LEFT JOIN prt2_adv t2 ON (t1.a = t2.b) LEFT JOIN prt3_adv t3 ON (t1.a = t3.a) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a;
   a  |  c   |  b  |  c   |  a  |  c   
@@ -3631,20 +3449,16 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: ((a < 300) AND (b = 0))
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: ((a < 300) AND (b = 0))
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.a < 300 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3674,20 +3488,16 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: (t2_1.b = t1_1.a)
                ->  Seq Scan on prt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on prt1_adv_p1 t1_1
                            Filter: ((a >= 100) AND (a < 300) AND (b = 0))
          ->  Hash Join
                Hash Cond: (t2_2.b = t1_2.a)
                ->  Seq Scan on prt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on prt1_adv_p2 t1_2
                            Filter: ((a >= 100) AND (a < 300) AND (b = 0))
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1_adv t1 INNER JOIN prt2_adv t2 ON (t1.a = t2.b) WHERE t1.a >= 100 AND t1.a < 300 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -3728,28 +3538,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3771,28 +3575,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -3814,28 +3612,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3859,28 +3651,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -3945,28 +3731,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -3988,28 +3768,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -4031,28 +3805,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4102,28 +3870,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -4336,28 +4098,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4379,28 +4135,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a
          ->  Hash Right Semi Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Semi Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a | b |  c   
@@ -4422,28 +4172,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   
@@ -4468,28 +4212,22 @@ SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t
          ->  Hash Right Anti Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1_null t1_1
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Anti Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3_null t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.* FROM plt1_adv t1 WHERE NOT EXISTS (SELECT 1 FROM plt2_adv t2 WHERE t1.a = t2.a AND t1.c = t2.c) AND t1.b < 10 ORDER BY t1.a;
  a  | b  |  c   
@@ -4565,28 +4303,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4662,28 +4394,22 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
-(27 rows)
+(21 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4705,25 +4431,19 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Right Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_1
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_2
                            Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                ->  Seq Scan on plt2_adv_p3 t2_3
-                     Bloom Filter 3: keys=(a, c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on plt1_adv_p3 t1_3
                            Filter: (b < 10)
          ->  Nested Loop Left Join
@@ -4731,7 +4451,7 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
                ->  Seq Scan on plt1_adv_extra t1_4
                      Filter: (b < 10)
                ->  Seq Scan on plt2_adv_extra t2_4
-(32 rows)
+(26 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   
@@ -4805,43 +4525,31 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
          ->  Hash Right Join
                Hash Cond: ((t3_1.a = t1_1.a) AND (t3_1.c = t1_1.c))
                ->  Seq Scan on plt1_adv_p1 t3_1
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                            ->  Seq Scan on plt2_adv_p1 t2_1
-                                 Bloom Filter 1: keys=(a, c)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on plt1_adv_p1 t1_1
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_2.a = t1_2.a) AND (t3_2.c = t1_2.c))
                ->  Seq Scan on plt1_adv_p2 t3_2
-                     Bloom Filter 4: keys=(a, c)
                ->  Hash
-                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                            ->  Seq Scan on plt2_adv_p2 t2_2
-                                 Bloom Filter 3: keys=(a, c)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on plt1_adv_p2 t1_2
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_3.a = t1_3.a) AND (t3_3.c = t1_3.c))
                ->  Seq Scan on plt1_adv_p3 t3_3
-                     Bloom Filter 6: keys=(a, c)
                ->  Hash
-                     Bloom Filter 6
                      ->  Hash Right Join
                            Hash Cond: ((t2_3.a = t1_3.a) AND (t2_3.c = t1_3.c))
                            ->  Seq Scan on plt2_adv_p3 t2_3
-                                 Bloom Filter 5: keys=(a, c)
                            ->  Hash
-                                 Bloom Filter 5
                                  ->  Seq Scan on plt1_adv_p3 t1_3
                                        Filter: (b < 10)
          ->  Nested Loop Left Join
@@ -4852,7 +4560,7 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
                            Filter: (b < 10)
                      ->  Seq Scan on plt2_adv_extra t2_4
                ->  Seq Scan on plt1_adv_extra t3_4
-(53 rows)
+(41 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) LEFT JOIN plt1_adv t3 ON (t1.a = t3.a AND t1.c = t3.c) WHERE t1.b < 10 ORDER BY t1.a;
  a  |  c   | a |  c   | a |  c   
@@ -4888,20 +4596,16 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p1 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_1
                            Filter: (b < 10)
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -4983,32 +4687,24 @@ SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2
          ->  Hash Right Join
                Hash Cond: ((t3_1.a = t1_1.a) AND (t3_1.c = t1_1.c))
                ->  Seq Scan on plt3_adv_p1 t3_1
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Hash Right Join
                            Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                            ->  Seq Scan on plt2_adv_p2 t2_2
-                                 Bloom Filter 1: keys=(a, c)
                            ->  Hash
-                                 Bloom Filter 1
                                  ->  Seq Scan on plt1_adv_p2 t1_1
                                        Filter: (b < 10)
          ->  Hash Right Join
                Hash Cond: ((t3_2.a = t1_2.a) AND (t3_2.c = t1_2.c))
                ->  Seq Scan on plt3_adv_p2 t3_2
-                     Bloom Filter 4: keys=(a, c)
                ->  Hash
-                     Bloom Filter 4
                      ->  Hash Right Join
                            Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                            ->  Seq Scan on plt2_adv_p1 t2_1
-                                 Bloom Filter 3: keys=(a, c)
                            ->  Hash
-                                 Bloom Filter 3
                                  ->  Seq Scan on plt1_adv_p1 t1_2
                                        Filter: (b < 10)
-(31 rows)
+(23 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) LEFT JOIN plt3_adv t3 ON (t1.a = t3.a AND t1.c = t3.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   | a |  c   
@@ -5037,20 +4733,16 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_2.a) AND (t2_1.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p1_null t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p1 t1_2
                            Filter: (b < 10)
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_1.a) AND (t2_2.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p2 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p2 t1_1
                            Filter: (b < 10)
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -5075,12 +4767,10 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p2 t2
-               Bloom Filter 1: keys=(a, c)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p2 t1
                      Filter: (b < 10)
-(10 rows)
+(8 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -5119,20 +4809,16 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p3 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p3 t1_1
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p4 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p4 t1_2
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IN ('0003', '0004', '0005') AND t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -5151,12 +4837,10 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Right Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p4 t2
-               Bloom Filter 1: keys=(a, c)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p4 t1
                      Filter: ((c IS NULL) AND (b < 10))
-(10 rows)
+(8 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IS NULL AND t1.b < 10 ORDER BY t1.a;
  a  | c | a | c 
@@ -5178,20 +4862,16 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a =
          ->  Hash Join
                Hash Cond: ((t2_1.a = t1_1.a) AND (t2_1.c = t1_1.c))
                ->  Seq Scan on plt2_adv_p3 t2_1
-                     Bloom Filter 1: keys=(a, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on plt1_adv_p3 t1_1
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.c = t1_2.c))
                ->  Seq Scan on plt2_adv_p4 t2_2
-                     Bloom Filter 2: keys=(a, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on plt1_adv_p4 t1_2
                            Filter: ((b < 10) AND (c = ANY ('{0003,0004,0005}'::text[])))
-(19 rows)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 INNER JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IN ('0003', '0004', '0005') AND t1.b < 10 ORDER BY t1.a;
  a |  c   | a |  c   
@@ -5210,12 +4890,10 @@ SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a =
    ->  Hash Right Join
          Hash Cond: ((t2.a = t1.a) AND (t2.c = t1.c))
          ->  Seq Scan on plt2_adv_p4 t2
-               Bloom Filter 1: keys=(a, c)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on plt1_adv_p4 t1
                      Filter: ((c IS NULL) AND (b < 10))
-(10 rows)
+(8 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c FROM plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.a = t2.a AND t1.c = t2.c) WHERE t1.c IS NULL AND t1.b < 10 ORDER BY t1.a;
  a  | c | a | c 
@@ -5337,16 +5015,12 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                Hash Cond: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b))
                ->  Seq Scan on alpha_neg_p1 t1_1
                      Filter: ((b >= 125) AND (b < 225))
-                     Bloom Filter 1: keys=(a, b)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on beta_neg_p1 t2_1
          ->  Hash Join
                Hash Cond: ((t2_2.a = t1_2.a) AND (t2_2.b = t1_2.b))
                ->  Seq Scan on beta_neg_p2 t2_2
-                     Bloom Filter 2: keys=(a, b)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on alpha_neg_p2 t1_2
                            Filter: ((b >= 125) AND (b < 225))
          ->  Hash Join
@@ -5363,7 +5037,7 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                                  Filter: ((b >= 125) AND (b < 225))
                            ->  Seq Scan on alpha_pos_p3 t1_6
                                  Filter: ((b >= 125) AND (b < 225))
-(33 rows)
+(29 rows)
 
 SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2.b) WHERE t1.b >= 125 AND t1.b < 225 ORDER BY t1.a, t1.b;
  a  |  b  |  c   | a  |  b  |  c   
@@ -5476,18 +5150,14 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                Hash Cond: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                ->  Seq Scan on alpha_neg_p1 t1_1
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
-                     Bloom Filter 1: keys=(a, b, c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on beta_neg_p1 t2_1
                            Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
          ->  Hash Join
                Hash Cond: ((t1_2.a = t2_2.a) AND (t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                ->  Seq Scan on alpha_neg_p2 t1_2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
-                     Bloom Filter 2: keys=(a, b, c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on beta_neg_p2 t2_2
                            Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
          ->  Nested Loop
@@ -5502,7 +5172,7 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
                ->  Seq Scan on beta_pos_p3 t2_4
                      Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
-(33 rows)
+(29 rows)
 
 SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2.b AND t1.c = t2.c) WHERE ((t1.b >= 100 AND t1.b < 110) OR (t1.b >= 200 AND t1.b < 210)) AND ((t2.b >= 100 AND t2.b < 110) OR (t2.b >= 200 AND t2.b < 210)) AND t1.c IN ('0004', '0009') ORDER BY t1.a, t1.b;
  a  |  b  |  c   | a  |  b  |  c   
@@ -5646,25 +5316,19 @@ EXPLAIN (COSTS OFF) SELECT * FROM pht1 p1 JOIN pht1 p2 USING (c) LIMIT 1000;
          ->  Hash Join
                Hash Cond: (p1_1.c = p2_1.c)
                ->  Seq Scan on pht1_p1 p1_1
-                     Bloom Filter 1: keys=(c)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on pht1_p1 p2_1
          ->  Hash Join
                Hash Cond: (p1_2.c = p2_2.c)
                ->  Seq Scan on pht1_p2 p1_2
-                     Bloom Filter 2: keys=(c)
                ->  Hash
-                     Bloom Filter 2
                      ->  Seq Scan on pht1_p2 p2_2
          ->  Hash Join
                Hash Cond: (p1_3.c = p2_3.c)
                ->  Seq Scan on pht1_p3 p1_3
-                     Bloom Filter 3: keys=(c)
                ->  Hash
-                     Bloom Filter 3
                      ->  Seq Scan on pht1_p3 p2_3
-(23 rows)
+(17 rows)
 
 RESET enable_mergejoin;
 SET max_parallel_workers_per_gather = 1;
diff --git a/src/test/regress/expected/predicate.out b/src/test/regress/expected/predicate.out
index 079f6422fdc..feae77cb840 100644
--- a/src/test/regress/expected/predicate.out
+++ b/src/test/regress/expected/predicate.out
@@ -748,16 +748,14 @@ SELECT id FROM dist_tab WHERE row_nn IS DISTINCT FROM ROW(1, 5)::dist_row_t;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT * FROM dist_tab t1 JOIN dist_tab t2 ON t1.val_nn IS NOT DISTINCT FROM t2.val_nn;
-              QUERY PLAN               
----------------------------------------
+              QUERY PLAN              
+--------------------------------------
  Hash Join
    Hash Cond: (t1.val_nn = t2.val_nn)
    ->  Seq Scan on dist_tab t1
-         Bloom Filter 1: keys=(val_nn)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on dist_tab t2
-(7 rows)
+(5 rows)
 
 SELECT * FROM dist_tab t1 JOIN dist_tab t2 ON t1.val_nn IS NOT DISTINCT FROM t2.val_nn;
  id | val_nn | val_null | row_nn | id | val_nn | val_null | row_nn 
diff --git a/src/test/regress/expected/returning.out b/src/test/regress/expected/returning.out
index dc44871b682..50cd3be8030 100644
--- a/src/test/regress/expected/returning.out
+++ b/src/test/regress/expected/returning.out
@@ -713,33 +713,31 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
    Update on pg_temp.foo foo_1
    ->  Hash Join
          Output: foo_2.f1, (foo_2.f3 + 1), joinme.ctid, foo_2.ctid, joinme_1.ctid, joinme.other, foo_1.tableoid, foo_1.ctid, foo_2.tableoid
-         Hash Cond: (foo_1.f2 = joinme.f2j)
-         ->  Hash Join
-               Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, joinme_1.ctid, joinme_1.f2j
-               Hash Cond: (joinme_1.f2j = foo_1.f2)
-               ->  Seq Scan on pg_temp.joinme joinme_1
-                     Output: joinme_1.ctid, joinme_1.f2j
-                     Bloom Filter 1: keys=(joinme_1.f2j)
-               ->  Hash
-                     Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
-                     Bloom Filter 1
-                     ->  Seq Scan on pg_temp.foo foo_1
-                           Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
+         Hash Cond: (joinme_1.f2j = foo_1.f2)
+         ->  Seq Scan on pg_temp.joinme joinme_1
+               Output: joinme_1.ctid, joinme_1.f2j
+               Bloom Filter 2: keys=(joinme_1.f2j)
          ->  Hash
-               Output: joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+               Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+               Bloom Filter 2
                ->  Hash Join
-                     Output: joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
-                     Hash Cond: (joinme.f2j = foo_2.f2)
+                     Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+                     Hash Cond: (joinme.f2j = foo_1.f2)
                      ->  Seq Scan on pg_temp.joinme
                            Output: joinme.ctid, joinme.other, joinme.f2j
-                           Bloom Filter 2: keys=(joinme.f2j)
+                           Bloom Filter 1: keys=(joinme.f2j)
                      ->  Hash
-                           Output: foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
-                           Bloom Filter 2
-                           ->  Seq Scan on pg_temp.foo foo_2
-                                 Output: foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
-                                 Filter: (foo_2.f3 = 57)
-(31 rows)
+                           Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+                           Bloom Filter 1
+                           ->  Nested Loop
+                                 Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+                                 Join Filter: (foo_1.f2 = foo_2.f2)
+                                 ->  Seq Scan on pg_temp.foo foo_2
+                                       Output: foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
+                                       Filter: (foo_2.f3 = 57)
+                                 ->  Seq Scan on pg_temp.foo foo_1
+                                       Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
+(29 rows)
 
 UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
   RETURNING old.*, new.*, *, new.f3 - old.f3 AS delta_f3;
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index 11cf56fdba4..07854247020 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -3610,17 +3610,16 @@ ANALYZE sb_1, sb_2;
 -- bucket size is quite big because there are possibly many correlations.
 EXPLAIN (COSTS OFF) -- Choose merge join
 SELECT * FROM sb_1 a, sb_2 b WHERE a.x = b.x AND a.y = b.y AND a.z = b.z;
-                         QUERY PLAN                          
--------------------------------------------------------------
- Merge Join
-   Merge Cond: ((a.z = b.z) AND (a.x = b.x) AND (a.y = b.y))
-   ->  Sort
-         Sort Key: a.z, a.x, a.y
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((b.x = a.x) AND (b.y = a.y) AND (b.z = a.z))
+   ->  Seq Scan on sb_2 b
+         Bloom Filter 1: keys=(x, y, z)
+   ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on sb_1 a
-   ->  Sort
-         Sort Key: b.z, b.x, b.y
-         ->  Seq Scan on sb_2 b
-(8 rows)
+(7 rows)
 
 -- The ndistinct extended statistics on (x, y, z) provides more reliable value
 -- of bucket size.
@@ -3633,11 +3632,9 @@ SELECT * FROM sb_1 a, sb_2 b WHERE a.x = b.x AND a.y = b.y AND a.z = b.z;
  Hash Join
    Hash Cond: ((a.x = b.x) AND (a.y = b.y) AND (a.z = b.z))
    ->  Seq Scan on sb_1 a
-         Bloom Filter 1: keys=(x, y, z)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on sb_2 b
-(7 rows)
+(5 rows)
 
 -- Check that the Hash Join bucket size estimator detects equal clauses correctly.
 SET enable_nestloop = 'off';
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index 5c7d49050db..3519942030b 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -406,11 +406,9 @@ select * from int4_tbl o where exists
  Hash Semi Join
    Hash Cond: (o.f1 = i.f1)
    ->  Seq Scan on int4_tbl o
-         Bloom Filter 1: keys=(f1)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on int4_tbl i
-(7 rows)
+(5 rows)
 
 explain (costs off)
 select * from int4_tbl o where not exists
@@ -1673,7 +1671,7 @@ select * from int4_tbl where
 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Nested Loop Semi Join
    Output: int4_tbl.f1
-   Join Filter: (CASE WHEN (ANY (int4_tbl.f1 = (hashed SubPlan any_1).col1)) THEN int4_tbl.f1 ELSE NULL::integer END = b.ten)
+   Join Filter: (b.ten = CASE WHEN (ANY (int4_tbl.f1 = (hashed SubPlan any_1).col1)) THEN int4_tbl.f1 ELSE NULL::integer END)
    ->  Seq Scan on public.int4_tbl
          Output: int4_tbl.f1
    ->  Seq Scan on public.tenk1 b
@@ -2180,13 +2178,11 @@ order by t1.ten;
                Hash Cond: (t2.fivethous = t1.unique1)
                ->  Seq Scan on public.tenk1 t2
                      Output: t2.unique1, t2.unique2, t2.two, t2.four, t2.ten, t2.twenty, t2.hundred, t2.thousand, t2.twothousand, t2.fivethous, t2.tenthous, t2.odd, t2.even, t2.stringu1, t2.stringu2, t2.string4
-                     Bloom Filter 1: keys=(t2.fivethous)
                ->  Hash
                      Output: t1.ten, t1.unique1
-                     Bloom Filter 1
                      ->  Seq Scan on public.tenk1 t1
                            Output: t1.ten, t1.unique1
-(17 rows)
+(15 rows)
 
 select t1.ten, sum(x) from
   tenk1 t1 left join lateral (
@@ -2281,19 +2277,15 @@ order by 1, 2;
                Hash Cond: (t2.q2 = t3.q2)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
-                     Bloom Filter 1: keys=(t2.q2)
-                     Bloom Filter 2: keys=(t2.q2)
                ->  Hash
                      Output: t3.q2
-                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t3
                            Output: t3.q2
          ->  Hash
                Output: t1.q1, t1.q2
-               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1, t1.q2
-(23 rows)
+(19 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2336,16 +2328,14 @@ order by 1, 2;
                Output: t2.q2, ((t2.q1 + 1))
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
-                     Bloom Filter 1: keys=(t2.q2)
                ->  Seq Scan on public.int8_tbl t3
                      Output: t3.q2, (t2.q1 + 1)
                      Filter: (t2.q2 = t3.q2)
          ->  Hash
                Output: t1.q1, t1.q2
-               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1, t1.q2
-(19 rows)
+(17 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2390,19 +2380,15 @@ order by 1, 2;
                Hash Cond: (t2.q2 = t3.q1)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
-                     Bloom Filter 1: keys=(t2.q2)
-                     Bloom Filter 2: keys=(t2.q1)
                ->  Hash
                      Output: t3.q1
-                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t3
                            Output: t3.q1
          ->  Hash
                Output: t1.q1
-               Bloom Filter 2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1
-(23 rows)
+(19 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2455,16 +2441,14 @@ order by 1, 2;
                Output: t2.q1, (t2.q2)
                ->  Seq Scan on public.int8_tbl t2
                      Output: t2.q1, t2.q2
-                     Bloom Filter 1: keys=(t2.q1)
                ->  Seq Scan on public.int8_tbl t3
                      Output: t3.q1, t2.q2
                      Filter: (t2.q2 = t3.q1)
          ->  Hash
                Output: t1.q1
-               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q1
-(19 rows)
+(17 rows)
 
 select t1.q1, x from
   int8_tbl t1 left join
@@ -2528,21 +2512,19 @@ order by 1, 2, 3;
                      Hash Cond: (t2.q1 = t3.q2)
                      ->  Seq Scan on public.int8_tbl t2
                            Output: t2.q1, t2.q2
-                           Bloom Filter 1: keys=(t2.q2)
                      ->  Hash
                            Output: t3.q2, (COALESCE(t3.q1, t3.q1))
                            ->  Seq Scan on public.int8_tbl t3
                                  Output: t3.q2, COALESCE(t3.q1, t3.q1)
                ->  Hash
                      Output: t4.q1, t4.q2
-                     Bloom Filter 1
                      ->  Seq Scan on public.int8_tbl t4
                            Output: t4.q1, t4.q2
          ->  Hash
                Output: t1.q2
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q2
-(28 rows)
+(26 rows)
 
 select ss2.* from
   int8_tbl t1 left join
@@ -2610,13 +2592,11 @@ order by 1, 2, 3;
                                  Output: t3.q2, COALESCE(t3.q1, t3.q1)
                ->  Seq Scan on public.int8_tbl t4
                      Output: t4.q1, t4.q2, (COALESCE(t3.q1, t3.q1))
-                     Bloom Filter 1: keys=(t4.q1)
          ->  Hash
                Output: t1.q2
-               Bloom Filter 1
                ->  Seq Scan on public.int8_tbl t1
                      Output: t1.q2
-(26 rows)
+(24 rows)
 
 select ss2.* from
   int8_tbl t1 left join
@@ -2917,13 +2897,11 @@ select * from tenk1 A where hundred in (select hundred from tenk2 B where B.odd
  Hash Join
    Hash Cond: ((a.odd = b.odd) AND (a.hundred = b.hundred))
    ->  Seq Scan on tenk1 a
-         Bloom Filter 1: keys=(odd, hundred)
    ->  Hash
-         Bloom Filter 1
          ->  HashAggregate
                Group Key: b.odd, b.hundred
                ->  Seq Scan on tenk2 b
-(9 rows)
+(7 rows)
 
 explain (costs off)
 select * from tenk1 A where exists
@@ -2988,13 +2966,11 @@ ON B.hundred in (SELECT c.hundred FROM tenk2 C WHERE c.odd = b.odd);
          ->  Hash Join
                Hash Cond: ((b.odd = c.odd) AND (b.hundred = c.hundred))
                ->  Seq Scan on tenk2 b
-                     Bloom Filter 1: keys=(odd, hundred)
                ->  Hash
-                     Bloom Filter 1
                      ->  HashAggregate
                            Group Key: c.odd, c.hundred
                            ->  Seq Scan on tenk2 c
-(12 rows)
+(10 rows)
 
 -- we can pull up the sublink into the inner JoinExpr.
 explain (costs off)
@@ -3009,15 +2985,13 @@ WHERE a.thousand < 750;
          Hash Cond: (a.hundred = c.hundred)
          ->  Seq Scan on tenk1 a
                Filter: (thousand < 750)
-               Bloom Filter 1: keys=(hundred)
          ->  Hash
-               Bloom Filter 1
                ->  HashAggregate
                      Group Key: c.odd, c.hundred
                      ->  Seq Scan on tenk2 c
    ->  Hash
          ->  Seq Scan on tenk2 b
-(14 rows)
+(12 rows)
 
 -- we can pull up the aggregate sublink into RHS of a left join.
 explain (costs off)
@@ -3154,11 +3128,9 @@ WHERE a.ten IN (VALUES (1), (2));
    Hash Cond: (a.ten = c.ten)
    ->  Seq Scan on onek a
          Filter: (ten = ANY ('{1,2}'::integer[]))
-         Bloom Filter 1: keys=(ten)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on tenk1 c
-(8 rows)
+(6 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT c.unique1,c.ten FROM tenk1 c JOIN onek a USING (ten)
@@ -3169,11 +3141,9 @@ WHERE c.ten IN (VALUES (1), (2));
    Hash Cond: (c.ten = a.ten)
    ->  Seq Scan on tenk1 c
          Filter: (ten = ANY ('{1,2}'::integer[]))
-         Bloom Filter 1: keys=(ten)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on onek a
-(8 rows)
+(6 rows)
 
 -- Constant expressions are simplified
 EXPLAIN (COSTS OFF)
@@ -3494,15 +3464,14 @@ WHERE id NOT IN (
    Hash Cond: (not_null_tab.id = t2.id)
    ->  Seq Scan on not_null_tab
    ->  Hash
-         ->  Merge Join
-               Merge Cond: (t1.id = t2.id)
-               ->  Sort
-                     Sort Key: t1.id
-                     ->  Seq Scan on not_null_tab t1
-               ->  Sort
-                     Sort Key: t2.id
+         ->  Hash Join
+               Hash Cond: (t1.id = t2.id)
+               ->  Seq Scan on not_null_tab t1
+                     Bloom Filter 1: keys=(id)
+               ->  Hash
+                     Bloom Filter 1
                      ->  Seq Scan on not_null_tab t2
-(12 rows)
+(11 rows)
 
 -- ANTI JOIN: outer side is defined NOT NULL, inner side is forced nonnullable
 -- by qual clause
@@ -3543,11 +3512,8 @@ WHERE id NOT IN (
 );
                    QUERY PLAN                    
 -------------------------------------------------
- Merge Anti Join
-   Merge Cond: (not_null_tab.id = t1.id)
-   ->  Sort
-         Sort Key: not_null_tab.id
-         ->  Seq Scan on not_null_tab
+ Merge Right Anti Join
+   Merge Cond: (t1.id = not_null_tab.id)
    ->  Nested Loop Left Join
          ->  Merge Join
                Merge Cond: (t1.id = t2.id)
@@ -3559,6 +3525,9 @@ WHERE id NOT IN (
                      ->  Seq Scan on null_tab t2
          ->  Materialize
                ->  Seq Scan on null_tab t3
+   ->  Sort
+         Sort Key: not_null_tab.id
+         ->  Seq Scan on not_null_tab
 (16 rows)
 
 -- ANTI JOIN: outer side is defined NOT NULL and is not nulled by outer join,
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 7d4af80faf6..13025cf93c5 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -623,13 +623,11 @@ MERGE INTO rw_view1 t
          Hash Cond: (base_tbl.a = generate_series.generate_series)
          ->  Bitmap Heap Scan on base_tbl
                Recheck Cond: (a > 0)
-               Bloom Filter 1: keys=(a)
                ->  Bitmap Index Scan on base_tbl_pkey
                      Index Cond: (a > 0)
          ->  Hash
-               Bloom Filter 1
                ->  Function Scan on generate_series
-(11 rows)
+(9 rows)
 
 -- it's still updatable if we add a DO ALSO rule
 CREATE TABLE base_tbl_hist(ts timestamptz default now(), a int, b text);
@@ -3528,18 +3526,17 @@ EXPLAIN (COSTS OFF) UPDATE v2 SET a = 1;
  Update on t1
    InitPlan exists_1
      ->  Result
-   ->  Merge Join
-         Merge Cond: (t1.a = v1.a)
-         ->  Sort
-               Sort Key: t1.a
-               ->  Seq Scan on t1
-         ->  Sort
-               Sort Key: v1.a
+   ->  Hash Join
+         Hash Cond: (t1.a = v1.a)
+         ->  Seq Scan on t1
+               Bloom Filter 1: keys=(a)
+         ->  Hash
+               Bloom Filter 1
                ->  Subquery Scan on v1
                      ->  Result
                            One-Time Filter: (InitPlan exists_1).col1
                            ->  Seq Scan on t1 t1_1
-(14 rows)
+(13 rows)
 
 DROP VIEW v2;
 DROP VIEW v1;
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 5c86619f023..dfda848b13c 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4324,15 +4324,14 @@ WHERE s.c = 1;
          Run Condition: (ntile(e2.salary) OVER w1 <= 1)
          ->  Sort
                Sort Key: e1.depname
-               ->  Merge Join
-                     Merge Cond: (e1.empno = e2.empno)
-                     ->  Sort
-                           Sort Key: e1.empno
-                           ->  Seq Scan on empsalary e1
-                     ->  Sort
-                           Sort Key: e2.empno
+               ->  Hash Join
+                     Hash Cond: (e1.empno = e2.empno)
+                     ->  Seq Scan on empsalary e1
+                           Bloom Filter 1: keys=(empno)
+                     ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on empsalary e2
-(15 rows)
+(14 rows)
 
 -- Ensure the run condition optimization is used in cases where the WindowFunc
 -- has a Var from another query level
diff --git a/src/test/regress/expected/with.out b/src/test/regress/expected/with.out
index db8c77721e7..25262b08839 100644
--- a/src/test/regress/expected/with.out
+++ b/src/test/regress/expected/with.out
@@ -686,11 +686,9 @@ select count(*) from tenk1 a
    ->  Hash Semi Join
          Hash Cond: (a.unique1 = x.unique1)
          ->  Index Only Scan using tenk1_unique1 on tenk1 a
-               Bloom Filter 1: keys=(unique1)
          ->  Hash
-               Bloom Filter 1
                ->  CTE Scan on x
-(10 rows)
+(8 rows)
 
 explain (costs off)
 with x as materialized (insert into tenk1 default values returning unique1)
@@ -753,22 +751,20 @@ select * from search_graph order by seq;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph0 g
                  Output: g.f, g.t, g.label, ARRAY[ROW(g.f, g.t)]
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, array_cat(sg.seq, ARRAY[ROW(g_1.f, g_1.t)])
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph0 g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph0 g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f)
+                 ->  Hash
                        Output: sg.seq, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.seq, sg.t
    ->  CTE Scan on search_graph
          Output: search_graph.f, search_graph.t, search_graph.label, search_graph.seq
-(22 rows)
+(20 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph0 g
@@ -826,22 +822,20 @@ select * from search_graph order by seq;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph0 g
                  Output: g.f, g.t, g.label, ROW('0'::bigint, g.f, g.t)
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, ROW(int8inc((sg.seq)."*DEPTH*"), g_1.f, g_1.t)
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph0 g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph0 g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f)
+                 ->  Hash
                        Output: sg.seq, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.seq, sg.t
    ->  CTE Scan on search_graph
          Output: search_graph.f, search_graph.t, search_graph.label, search_graph.seq
-(22 rows)
+(20 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph0 g
@@ -1097,20 +1091,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1135,20 +1129,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1210,21 +1204,19 @@ select * from search_graph;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph g
                  Output: g.f, g.t, g.label, false, ARRAY[ROW(g.f, g.t)]
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, CASE WHEN (ROW(g_1.f, g_1.t) = ANY (sg.path)) THEN true ELSE false END, array_cat(sg.path, ARRAY[ROW(g_1.f, g_1.t)])
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f)
+                 ->  Hash
                        Output: sg.path, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.path, sg.t
                              Filter: (NOT sg.is_cycle)
-(20 rows)
+(18 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph g
@@ -1244,20 +1236,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1281,20 +1273,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | N        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | N        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | N        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | N        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | N        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | N        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | N        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | N        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | N        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | Y        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | Y        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | Y        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | Y        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | N        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1446,20 +1438,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | {"(5,1)"}                                 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(5,1)","(1,2)"}                         | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(5,1)","(1,3)"}                         | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(5,1)","(1,4)"}                         | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(1,2)","(2,3)"}                         | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(5,1)","(1,4)"}                         | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(1,4)","(4,5)"}                         | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(4,5)","(5,1)"}                         | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(4,5)","(5,1)","(1,2)"}                 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(4,5)","(5,1)","(1,3)"}                 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(4,5)","(5,1)","(1,4)"}                 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(5,1)","(1,2)","(2,3)"}                 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(4,5)","(5,1)","(1,4)"}                 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(5,1)","(1,4)","(4,5)"}                 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(1,4)","(4,5)","(5,1)"}                 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(1,4)","(4,5)","(5,1)","(1,2)"}         | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(1,4)","(4,5)","(5,1)","(1,3)"}         | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(1,4)","(4,5)","(5,1)","(1,4)"}         | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(4,5)","(5,1)","(1,2)","(2,3)"}         | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(1,4)","(4,5)","(5,1)","(1,4)"}         | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(4,5)","(5,1)","(1,4)","(4,5)"}         | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(5,1)","(1,4)","(4,5)","(5,1)"}         | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"} | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1484,20 +1476,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | (0,5,1) | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | (1,1,2) | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (1,1,3) | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (1,1,4) | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (1,2,3) | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (1,1,4) | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (1,4,5) | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (1,5,1) | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | (2,1,2) | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (2,1,3) | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (2,1,4) | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (2,2,3) | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (2,1,4) | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (2,4,5) | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (2,5,1) | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | (3,1,2) | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (3,1,3) | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (3,1,4) | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (3,2,3) | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (3,1,4) | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (3,4,5) | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (3,5,1) | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | (4,2,3) | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1677,20 +1669,20 @@ select * from v_cycle1;
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  2 | 3 | arc 2 -> 3
@@ -1707,20 +1699,20 @@ select * from v_cycle2;
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  2 | 3 | arc 2 -> 3
@@ -3248,10 +3240,8 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
          Hash Cond: (m.k = o.k)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
-               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: o.k, o.v, o.*
-               Bloom Filter 1
                ->  Subquery Scan on o
                      Output: o.k, o.v, o.*
                      ->  Result
@@ -3262,7 +3252,7 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
            ->  CTE Scan on cte_basic
                  Output: (cte_basic.b || ' merge update'::text)
                  Filter: (cte_basic.a = m.k)
-(23 rows)
+(21 rows)
 
 -- InitPlan
 WITH cte_init AS MATERIALIZED (SELECT 1 a, 'cte_init val' b)
@@ -3299,15 +3289,13 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.k, o.v);
          Hash Cond: (m.k = o.k)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
-               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: o.k, o.v, o.*
-               Bloom Filter 1
                ->  Subquery Scan on o
                      Output: o.k, o.v, o.*
                      ->  Result
                            Output: 1, 'merge source InitPlan'::text
-(23 rows)
+(21 rows)
 
 -- MERGE source comes from CTE:
 WITH merge_source_cte AS MATERIALIZED (SELECT 15 a, 'merge_source_cte val' b)
@@ -3345,13 +3333,11 @@ WHEN NOT MATCHED THEN INSERT VALUES(o.a, o.b || (SELECT merge_source_cte.*::text
          Hash Cond: (m.k = merge_source_cte.a)
          ->  Seq Scan on public.m
                Output: m.ctid, m.k
-               Bloom Filter 1: keys=(m.k)
          ->  Hash
                Output: merge_source_cte.a, merge_source_cte.b, merge_source_cte.*
-               Bloom Filter 1
                ->  CTE Scan on merge_source_cte
                      Output: merge_source_cte.a, merge_source_cte.b, merge_source_cte.*
-(22 rows)
+(20 rows)
 
 DROP TABLE m;

---

From: "Matheus Alcantara" <[email-masked]>
Date: Thu, 02 Jul 2026 17:31:18 -0300

> is more for book-keeping and allowing lookup of filters.

I'm wondering if the adaptive probing can mess the execution of the
choose plan. Let's say that a plan was chosen with bloom filters to
pushdown  because it would reduce e.g 50% of the rows, what if at
runtime the bloom filter is proved to not be effective and it get
disabled making the scan node to produce 100% of the rows to the node
above that is not expecting? Do we end up with the same issue "expected
x actual rows"?

I think that if we keep using the unnefective bloom filter the scan node
will still produce more rows than expected, but perhaps this is easier
to understand?

> able to adjust those for this v3 patch.

I'm attaching a new v4 patchset incorporating Andrew patches with test
cases. 0001 and 0002 are your v3 untouched, 0003 is some tests added to
exercice the CustomScan path and 0004 is the Andrew changes with a few
changes required from v3 version:

Unlike the v1 PoC that pushed filters down in create_hashjoin_plan
(where it could simply walk the finished plan tree and accept any scan
node), the filters are now decided during bottom-up path construction,
so a scan only receives a filter if a filter-bearing path was generated
for its base relation.  So the main change is teaching path generation
about the custom scan.

> about not putting "Bloom" in naming etc.

I was also thinking about this when reading v1. I was checking other
databases and I realize that Trino [1] and Duckdb (I didn't find any
documentation from Duckdb but you can see this information on explain
analyse output) have "Dynamic Filtering" and IIUC is a similar
optimization that this patch is about.

I'm wondering if we could name as "Dynamic Filtering" or "Runtime filter
pushdown" or something else instead of Bloom Filters to make it more
generic.

On explain(analyze) output we can show as as "Runtime filter" and put
the columns (perhaps the values?) used on the filter.

I think that this can abstract and let the implementation decide if it
will use a bloom filter or not. It's a rough idea, but what do you
think?

I'm still thinking about your other points and about the XXX comments.
I'll share more soon. 

[1] https://trino.io/docs/current/admin/dynamic-filtering.html

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From 1646d007f42fd4998e32a0a6b16cc6f7b4918a4d Mon Sep 17 00:00:00 2001
From: Tomas Vondra <[email-masked]>
Date: Sat, 20 Jun 2026 20:44:00 +0200
Subject: [PATCH v4 1/4] PoC: hashjoin bloom filter pushdown

 

 

 COMMIT;

 

 
 COMMIT;
 
 COMMIT;

 COMMIT;
 
 COMMIT;

 
 COMMIT;
 
 COMMIT;

 

 

 

  QUERY PLAN 
 ------------
 
  QUERY PLAN 
 ------------

 

+/*
 
 /*

 

 	/*
 
+			/*
 
+	/*

+	/*
 
+	/*
 
 	/*
 	 * Free hash table
+	 *
 
 	/*
 
+			/*

 

 
 
 	/*

 	/*
 
+/*
 
+/*

+	/*

+/*

 
 
 	/*
 
+		/*
 
+/*
 
+/*

 
+	/*
 
+/*
+ * BloomFilterState
+ *
 
+	/*

 
+/*
+ *	 BloomFilter -
 
 /* ----------------

 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1

 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t2.b, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 EXPLAIN (COSTS OFF)

 

 

 

 

 explain (costs off)
 
 explain (costs off)
 select * from onek t1

 SELECT a.f1
 FROM tt4 a
 
 drop table skewedtable;

 explain (costs off)
 select a.* from tenk1 a
 
 reset enable_memoize;

 
 rollback;

 explain (costs off)
 select * from

 select ss1.d1 from
   tenk1 as t1
 
 select * from
 int4_tbl i0 left join
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select ss2.* from
   int4_tbl i41

 
 
 select t1.a from t t1

 

 rollback;

 

 

 select i8.q2, ss.* from
 int8_tbl i8,
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t

 

 

 

 

 

 

 EXPLAIN (COSTS OFF)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 RESET enable_mergejoin;

 

 
 explain (costs off)

 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 explain (costs off)

 
 EXPLAIN (COSTS OFF)

 
 
 EXPLAIN (costs off)
 MERGE INTO rw_view1 t

 
 
 explain (costs off)
 
 -- InitPlan

 DROP TABLE m;

 
 
2.50.1 (Apple Git-155)

From 6d4a45b3ff5bb25f8a14a1c2c3e34a92994a25a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <[email-masked]>
Date: Sat, 20 Jun 2026 20:20:08 +0000
Subject: [PATCH v4 2/4] PoC: Bloom filter pushdown during path construction

This implies that:

Notes:

 
 /*
  * show_bloom_filter_info
 
 	/*
 
+	/*

+/*
 
+/*
 
+/*
 
+/*
 
+/*

 

 
 
+		/*
 
-/*
 
 /*
  * try_push_bloom_filter

 
 	/*
 
-		var = (Var *) k;
 
-		/*
 
 	/*
 
 	/*

 
-	/*

 	/*
 
 	return join_plan;
 }
 
+		/*
 
+/*
 
+		/*
 
+		/*

 
 
+/*
+ * ExpectedFilter
+ *

 
 /*

 
 /*
  * joinrels.c

 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
 
 SELECT t2.b, avg(t2.c)
   FROM eager_agg_t1 t1
 
 EXPLAIN (COSTS OFF)

 SELECT t2.b, count(*)
   FROM eager_agg_t2 t2

 

 

 

 

 
 
 SELECT *
 
 SELECT *
 
 SELECT *

 
 SELECT *

 
 explain (costs off)
 
 explain (costs off)
 select * from onek t1

 SELECT a.f1
 FROM tt4 a
 
 drop table skewedtable;

 explain (costs off)
 select a.* from tenk1 a
 
 reset enable_memoize;

 
 rollback;

 
 explain (costs off)
 select * from

 select ss1.d1 from
   tenk1 as t1
 
 select * from
 int4_tbl i0 left join
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select t1.* from
   text_tbl t1
 
 select ss2.* from
   int4_tbl i41

 

 

 rollback;

 

 drop table j1;
 drop table j2;
 
 select * from j1
 
 select * from j1
 
 select * from j1

 

 -- updates + deletes
 SELECT explain_merge('
 
 -- only inserts
 SELECT explain_merge('

 EXPLAIN (COSTS OFF)

 

 

 

 

 

 
 EXPLAIN (COSTS OFF)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 RESET enable_mergejoin;

 

 explain (costs off)

 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select t1.q1, x from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 select ss2.* from
   int8_tbl t1 left join
 
 explain (costs off)

 
 EXPLAIN (COSTS OFF)

 

 DROP VIEW v2;
 DROP VIEW v1;

 explain (costs off)

 
 
 -- InitPlan

 DROP TABLE m;
2.50.1 (Apple Git-155)

From 81b42bfbd75d2a466c6119a92a1bb206081ae165 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <[email-masked]>
Date: Thu, 2 Jul 2026 15:46:05 -0300
Subject: [PATCH v4 3/4] Add tests for CustomScan filter pushdown

---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_bloom_customscan/Makefile    |  23 ++
 .../expected/test_bloom_customscan.out        | 113 ++++++
 .../modules/test_bloom_customscan/meson.build |  33 ++
 .../sql/test_bloom_customscan.sql             |  66 ++++
 .../test_bloom_customscan--1.0.sql            |  20 +
 .../test_bloom_customscan.c                   | 362 ++++++++++++++++++
 .../test_bloom_customscan.control             |   5 +
 9 files changed, 624 insertions(+)
 create mode 100644 src/test/modules/test_bloom_customscan/Makefile
 create mode 100644 src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
 create mode 100644 src/test/modules/test_bloom_customscan/meson.build
 create mode 100644 src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan.c
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..3f83feeb074 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -20,6 +20,7 @@ SUBDIRS = \
 		  test_binaryheap \
 		  test_bitmapset \
 		  test_bloomfilter \
+		  test_bloom_customscan \
 		  test_cloexec \
 		  test_checksums \
 		  test_copy_callbacks \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..e9efdb1e8a4 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -20,6 +20,7 @@ subdir('test_autovacuum')
 subdir('test_binaryheap')
 subdir('test_bitmapset')
 subdir('test_bloomfilter')
+subdir('test_bloom_customscan')
 subdir('test_cloexec')
 subdir('test_checksums')
 subdir('test_copy_callbacks')
diff --git a/src/test/modules/test_bloom_customscan/Makefile b/src/test/modules/test_bloom_customscan/Makefile
new file mode 100644
index 00000000000..5de5b5e6f4b
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_bloom_customscan/Makefile
+
+MODULE_big = test_bloom_customscan
+OBJS = \
+	$(WIN32RES) \
+	test_bloom_customscan.o
+PGFILEDESC = "test_bloom_customscan - CustomScan consuming a hashjoin bloom filter"
+
+EXTENSION = test_bloom_customscan
+DATA = test_bloom_customscan--1.0.sql
+
+REGRESS = test_bloom_customscan
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_bloom_customscan
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
new file mode 100644
index 00000000000..e1ce5903a02
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
@@ -0,0 +1,113 @@
+CREATE EXTENSION test_bloom_customscan;
+-- ensure the library (and its planner hook + GUC) is loaded
+LOAD 'test_bloom_customscan';
+-- Force a serial hash join, which is where the bloom-filter pushdown applies.
+SET enable_hashjoin_bloom = on;
+SET enable_mergejoin = off;
+SET enable_nestloop = off;
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE cs_fact (a int, b int, payload int);
+CREATE TABLE cs_dim (id int, id2 int, label text);
+-- Fact keys range over 0..999; the dimension only covers 1..50, so the join
+-- is highly selective and the pushed-down filter should reject most fact rows.
+INSERT INTO cs_fact
+  SELECT g % 1000, g % 1000, g FROM generate_series(1, 20000) g;
+INSERT INTO cs_dim
+  SELECT g, g, 'x' || g FROM generate_series(1, 50) g;
+ANALYZE cs_fact;
+ANALYZE cs_dim;
+-- Offer the bloom-capable custom scan and force it to be chosen (the CustomPath
+-- keeps disabled_nodes = 0, so disabling seqscan makes it win).
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+-- The fact relation is scanned by our Custom Scan and receives the filter.
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Aggregate
+   Output: count(*)
+   ->  Hash Join
+         Hash Cond: (f.a = d.id)
+         ->  Custom Scan (TestBloomCustomScan) on public.cs_fact f
+               Output: f.a
+         ->  Hash
+               Output: d.id
+               ->  Custom Scan (TestBloomCustomScan) on public.cs_dim d
+                     Output: d.id
+(10 rows)
+
+-- Single-key join: the filter must actually reject fact rows.
+SELECT test_bloom_cs_reset();
+ test_bloom_cs_reset 
+---------------------
+ 
+(1 row)
+
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+ count 
+-------
+  1000
+(1 row)
+
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+ filter_rejected_rows 
+----------------------
+ f
+(1 row)
+
+-- Correctness: the result must be identical with and without the filter.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs EXCEPT SELECT * FROM r_plain) x;
+ cs_minus_plain 
+----------------
+              0
+(1 row)
+
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain EXCEPT SELECT * FROM r_cs) x;
+ plain_minus_cs 
+----------------
+              0
+(1 row)
+
+-- Two-key join: the opted-in recipient also gets per-key filters built.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SELECT test_bloom_cs_reset();
+ test_bloom_cs_reset 
+---------------------
+ 
+(1 row)
+
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
+ count 
+-------
+  1000
+(1 row)
+
+SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
+ perkey_filters_built 
+----------------------
+ f
+(1 row)
+
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+ filter_rejected_rows 
+----------------------
+ f
+(1 row)
+
+-- cleanup
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+DROP TABLE cs_fact, cs_dim;
+DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/meson.build b/src/test/modules/test_bloom_customscan/meson.build
new file mode 100644
index 00000000000..e12dc80942c
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+test_bloom_customscan_sources = files(
+  'test_bloom_customscan.c',
+)
+
+if host_system == 'windows'
+  test_bloom_customscan_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_bloom_customscan',
+    '--FILEDESC', 'test_bloom_customscan - CustomScan consuming a hashjoin bloom filter',])
+endif
+
+test_bloom_customscan = shared_module('test_bloom_customscan',
+  test_bloom_customscan_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_bloom_customscan
+
+test_install_data += files(
+  'test_bloom_customscan.control',
+  'test_bloom_customscan--1.0.sql',
+)
+
+tests += {
+  'name': 'test_bloom_customscan',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'test_bloom_customscan',
+    ],
+  },
+}
diff --git a/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql b/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
new file mode 100644
index 00000000000..daae84a531a
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
@@ -0,0 +1,66 @@
+CREATE EXTENSION test_bloom_customscan;
+-- ensure the library (and its planner hook + GUC) is loaded
+LOAD 'test_bloom_customscan';
+
+-- Force a serial hash join, which is where the bloom-filter pushdown applies.
+SET enable_hashjoin_bloom = on;
+SET enable_mergejoin = off;
+SET enable_nestloop = off;
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE cs_fact (a int, b int, payload int);
+CREATE TABLE cs_dim (id int, id2 int, label text);
+
+-- Fact keys range over 0..999; the dimension only covers 1..50, so the join
+-- is highly selective and the pushed-down filter should reject most fact rows.
+INSERT INTO cs_fact
+  SELECT g % 1000, g % 1000, g FROM generate_series(1, 20000) g;
+INSERT INTO cs_dim
+  SELECT g, g, 'x' || g FROM generate_series(1, 50) g;
+
+ANALYZE cs_fact;
+ANALYZE cs_dim;
+
+-- Offer the bloom-capable custom scan and force it to be chosen (the CustomPath
+-- keeps disabled_nodes = 0, so disabling seqscan makes it win).
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+
+-- The fact relation is scanned by our Custom Scan and receives the filter.
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+
+-- Single-key join: the filter must actually reject fact rows.
+SELECT test_bloom_cs_reset();
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+
+-- Correctness: the result must be identical with and without the filter.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs EXCEPT SELECT * FROM r_plain) x;
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain EXCEPT SELECT * FROM r_cs) x;
+
+-- Two-key join: the opted-in recipient also gets per-key filters built.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SELECT test_bloom_cs_reset();
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
+SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+
+-- cleanup
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+DROP TABLE cs_fact, cs_dim;
+DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql b/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
new file mode 100644
index 00000000000..329d795d6f0
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
@@ -0,0 +1,20 @@
+/* src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_bloom_customscan" to load this file. \quit
+
+CREATE FUNCTION test_bloom_cs_reset()
+	RETURNS void
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_scanned_rows()
+	RETURNS bigint
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_rejected_rows()
+	RETURNS bigint
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_perkey_built()
+	RETURNS boolean
+	AS 'MODULE_PATHNAME' LANGUAGE C;
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
new file mode 100644
index 00000000000..124cbef9ded
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
@@ -0,0 +1,362 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_bloom_customscan.c
+ *		Minimal CustomScan provider that consumes a hash-join Bloom filter.
+ *
+ * This is a test-only stand-in for a storage-level scan provider.  When enabled, it
+ * installs a set_rel_pathlist_hook that offers a CustomPath for plain heap
+ * base relations, advertising CUSTOMPATH_SUPPORT_BLOOM_FILTERS.  That opt-in
+ * is what lets the planner (a) generate filter-bearing paths for the scan and
+ * (b) push a hash-join Bloom filter down to it.
+ *
+ * The scan itself is an ordinary heap sequential scan; the only interesting
+ * part is that its per-tuple loop probes the pushed-down combined filter with
+ * ExecBloomFilters() -- exactly what a stock scan does inside ExecScanExtended,
+ * but here done by the provider itself -- and, for a multi-key join, checks
+ * that the per-key filters were populated on the producer side.
+ *
+ * A few SQL-callable functions expose counters so the regression test can
+ * assert that the filter was actually probed and rejected tuples, and that
+ * per-key filters were built, without depending on timing or EXPLAIN output.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *	  src/test/modules/test_bloom_customscan/test_bloom_customscan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/relscan.h"
+#include "access/tableam.h"
+#include "executor/executor.h"
+#include "fmgr.h"
+#include "nodes/execnodes.h"
+#include "nodes/extensible.h"
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+#include "optimizer/cost.h"
+#include "optimizer/optimizer.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/restrictinfo.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* GUC: when off, the hook does nothing and plans look exactly like upstream. */
+static bool test_bloom_cs_enabled = false;
+
+/* Observation counters, reset/read from SQL. */
+static uint64 test_bloom_cs_scanned = 0;
+static uint64 test_bloom_cs_rejected = 0;
+static bool test_bloom_cs_perkey_seen = false;
+
+static set_rel_pathlist_hook_type prev_set_rel_pathlist_hook = NULL;
+
+/* Execution state, embedding CustomScanState as its first field. */
+typedef struct BloomCSScanState
+{
+	CustomScanState css;
+	TableScanDesc scandesc;
+	bool		inspected;		/* have we checked per-key filters yet? */
+}			BloomCSScanState;
+
+/* forward declarations */
+static Plan *bloom_cs_plan_custom_path(PlannerInfo *root, RelOptInfo *rel,
+									   CustomPath *best_path, List *tlist,
+									   List *clauses, List *custom_plans);
+static Node *bloom_cs_create_scan_state(CustomScan *cscan);
+static void bloom_cs_begin_scan(CustomScanState *node, EState *estate,
+								int eflags);
+static TupleTableSlot *bloom_cs_exec_scan(CustomScanState *node);
+static void bloom_cs_end_scan(CustomScanState *node);
+static void bloom_cs_rescan(CustomScanState *node);
+
+static const CustomPathMethods bloom_cs_path_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.PlanCustomPath = bloom_cs_plan_custom_path,
+};
+
+static const CustomScanMethods bloom_cs_scan_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.CreateCustomScanState = bloom_cs_create_scan_state,
+};
+
+static const CustomExecMethods bloom_cs_exec_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.BeginCustomScan = bloom_cs_begin_scan,
+	.ExecCustomScan = bloom_cs_exec_scan,
+	.EndCustomScan = bloom_cs_end_scan,
+	.ReScanCustomScan = bloom_cs_rescan,
+};
+
+/*
+ * set_rel_pathlist_hook: offer a bloom-filter-capable CustomPath for plain
+ * heap base relations.  We copy the cost of the existing sequential scan path
+ * so join costing stays sane; the test forces the custom scan to be chosen
+ * with "SET enable_seqscan = off" (the CustomPath keeps disabled_nodes = 0).
+ */
+static void
+bloom_cs_set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
+						  RangeTblEntry *rte)
+{
+	CustomPath *cpath;
+	Cost		startup_cost = 0;
+	Cost		total_cost = 0;
+	ListCell   *lc;
+
+	if (prev_set_rel_pathlist_hook)
+		prev_set_rel_pathlist_hook(root, rel, rti, rte);
+
+	if (!test_bloom_cs_enabled)
+		return;
+
+	/* Only plain, ordinary base tables. */
+	if (rel->reloptkind != RELOPT_BASEREL)
+		return;
+	if (rte->rtekind != RTE_RELATION || rte->relkind != RELKIND_RELATION)
+		return;
+
+	/* Borrow the sequential scan's cost estimate. */
+	foreach(lc, rel->pathlist)
+	{
+		Path	   *path = (Path *) lfirst(lc);
+
+		if (path->pathtype == T_SeqScan && path->param_info == NULL)
+		{
+			startup_cost = path->startup_cost;
+			total_cost = path->total_cost;
+			break;
+		}
+	}
+
+	cpath = makeNode(CustomPath);
+	cpath->path.pathtype = T_CustomScan;
+	cpath->path.parent = rel;
+	cpath->path.pathtarget = rel->reltarget;
+	cpath->path.param_info = NULL;
+	cpath->path.parallel_aware = false;
+	cpath->path.parallel_safe = false;
+	cpath->path.parallel_workers = 0;
+	cpath->path.rows = rel->rows;
+	cpath->path.startup_cost = startup_cost;
+	cpath->path.total_cost = total_cost;
+	cpath->path.pathkeys = NIL;
+
+	cpath->custom_paths = NIL;
+	cpath->custom_private = NIL;
+	cpath->methods = &bloom_cs_path_methods;
+
+	add_path(rel, (Path *) cpath);
+}
+
+static Plan *
+bloom_cs_plan_custom_path(PlannerInfo *root, RelOptInfo *rel,
+						  CustomPath *best_path, List *tlist,
+						  List *clauses, List *custom_plans)
+{
+	CustomScan *cscan = makeNode(CustomScan);
+
+	cscan->flags = best_path->flags;
+	cscan->methods = &bloom_cs_scan_methods;
+	cscan->custom_plans = custom_plans;
+
+	/* A base-relation scan: leave custom_scan_tlist NIL, use rel's rowtype. */
+	cscan->scan.scanrelid = rel->relid;
+	cscan->scan.plan.targetlist = tlist;
+	cscan->scan.plan.qual = extract_actual_clauses(clauses, false);
+
+	return &cscan->scan.plan;
+}
+
+static Node *
+bloom_cs_create_scan_state(CustomScan *cscan)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) newNode(sizeof(BloomCSScanState),
+														  T_CustomScanState);
+
+	bcss->css.methods = &bloom_cs_exec_methods;
+	bcss->scandesc = NULL;
+	bcss->inspected = false;
+
+	/*
+	 * We scan a heap table with table_scan_getnextslot(), which stores
+	 * on-disk heap tuples, so the scan tuple slot must be a buffer-heap slot
+	 * rather than the default virtual slot.  ExecInitCustomScan() reads
+	 * css.slotOps when building ss_ScanTupleSlot (before BeginCustomScan
+	 * runs), so it has to be set here.
+	 */
+	bcss->css.slotOps = &TTSOpsBufferHeapTuple;
+
+	return (Node *) bcss;
+}
+
+static void
+bloom_cs_begin_scan(CustomScanState *node, EState *estate, int eflags)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	/*
+	 * ExecInitCustomScan already opened the scan relation (scanrelid > 0) and
+	 * called ExecInitBloomFilters(), so node->ss.ps.bloom_filters is set up.
+	 * We just need a table scan descriptor.
+	 */
+	if (!(eflags & (EXEC_FLAG_EXPLAIN_ONLY | EXEC_FLAG_EXPLAIN_GENERIC)))
+		bcss->scandesc = table_beginscan(node->ss.ss_currentRelation,
+										 estate->es_snapshot, 0, NULL, 0);
+}
+
+/*
+ * On the first tuple (by which point an eager producer has built its hash
+ * table and filters), record whether the per-key filters were populated.
+ */
+static void
+bloom_cs_maybe_inspect_perkey(BloomCSScanState * bcss)
+{
+	ListCell   *lc;
+
+	if (bcss->inspected)
+		return;
+
+	foreach(lc, bcss->css.ss.ps.bloom_filters)
+	{
+		BloomFilterState *bfs = (BloomFilterState *) lfirst(lc);
+		HashJoinState *producer = bfs->producer;
+		HashState  *hashNode;
+
+		if (producer == NULL)
+			continue;
+		hashNode = castNode(HashState, innerPlanState(&producer->js.ps));
+
+		/* Hash table (and filters) not built yet; try again next tuple. */
+		if (hashNode->bloom_filter == NULL)
+			return;
+
+	}
+
+	bcss->inspected = true;
+}
+
+static TupleTableSlot *
+bloom_cs_exec_scan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+	ExprContext *econtext = node->ss.ps.ps_ExprContext;
+	TupleTableSlot *scanslot = node->ss.ss_ScanTupleSlot;
+	ExprState  *qual = node->ss.ps.qual;
+	ProjectionInfo *proj = node->ss.ps.ps_ProjInfo;
+
+	bloom_cs_maybe_inspect_perkey(bcss);
+
+	for (;;)
+	{
+		ResetExprContext(econtext);
+
+		if (!table_scan_getnextslot(bcss->scandesc, ForwardScanDirection,
+									scanslot))
+		{
+			if (proj)
+				return ExecClearTuple(proj->pi_state.resultslot);
+			return ExecClearTuple(scanslot);
+		}
+
+		test_bloom_cs_scanned++;
+		econtext->ecxt_scantuple = scanslot;
+
+		/*
+		 * Probe the pushed-down combined Bloom filter, just as a stock scan
+		 * would inside ExecScanExtended.  A conclusive miss lets us drop the
+		 * tuple without evaluating the qual or projection.
+		 */
+		if (!ExecBloomFilters(node->ss.ps.bloom_filters, econtext))
+		{
+			test_bloom_cs_rejected++;
+			continue;
+		}
+
+		if (qual != NULL && !ExecQual(qual, econtext))
+			continue;
+
+		if (proj != NULL)
+			return ExecProject(proj);
+
+		return scanslot;
+	}
+}
+
+static void
+bloom_cs_end_scan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	if (bcss->scandesc != NULL)
+		table_endscan(bcss->scandesc);
+}
+
+static void
+bloom_cs_rescan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	bcss->inspected = false;
+	if (bcss->scandesc != NULL)
+		table_rescan(bcss->scandesc, NULL);
+}
+
+/* ------------------------------------------------------------------------
+ * SQL-callable observation helpers
+ * ------------------------------------------------------------------------
+ */
+PG_FUNCTION_INFO_V1(test_bloom_cs_reset);
+PG_FUNCTION_INFO_V1(test_bloom_cs_scanned_rows);
+PG_FUNCTION_INFO_V1(test_bloom_cs_rejected_rows);
+PG_FUNCTION_INFO_V1(test_bloom_cs_perkey_built);
+
+Datum
+test_bloom_cs_reset(PG_FUNCTION_ARGS)
+{
+	test_bloom_cs_scanned = 0;
+	test_bloom_cs_rejected = 0;
+	test_bloom_cs_perkey_seen = false;
+	PG_RETURN_VOID();
+}
+
+Datum
+test_bloom_cs_scanned_rows(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64((int64) test_bloom_cs_scanned);
+}
+
+Datum
+test_bloom_cs_rejected_rows(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64((int64) test_bloom_cs_rejected);
+}
+
+Datum
+test_bloom_cs_perkey_built(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_BOOL(test_bloom_cs_perkey_seen);
+}
+
+void
+_PG_init(void)
+{
+	DefineCustomBoolVariable("test_bloom_customscan.enable",
+							 "Offer a bloom-filter-capable CustomScan for heap tables.",
+							 NULL,
+							 &test_bloom_cs_enabled,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("test_bloom_customscan");
+
+	RegisterCustomScanMethods(&bloom_cs_scan_methods);
+
+	prev_set_rel_pathlist_hook = set_rel_pathlist_hook;
+	set_rel_pathlist_hook = bloom_cs_set_rel_pathlist;
+}
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.control b/src/test/modules/test_bloom_customscan/test_bloom_customscan.control
new file mode 100644
index 00000000000..10f90510302
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.control
@@ -0,0 +1,5 @@
+# test_bloom_customscan extension
+comment = 'Test CustomScan provider that consumes a hashjoin bloom filter'
+default_version = '1.0'
+module_pathname = '$libdir/test_bloom_customscan'
+relocatable = true
-- 
2.50.1 (Apple Git-155)

From 903311f0c1a494f530dfa7dba92edad518eb9329 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <[email-masked]>
Date: Thu, 2 Jul 2026 16:29:26 -0300
Subject: [PATCH v4 4/4] Allow a CustomScan to consume a pushed-down hashjoin
 Bloom filter

Extend the hashjoin Bloom-filter pushdown so that a base-relation
CustomScan can build filter-aware paths, be chosen as a filter
recipient, and consume the filter inside its own scan loop The whole
mechanism is gated on a new opt-in path flag,
CUSTOMPATH_SUPPORT_BLOOM_FILTERS; providers that do not set it, and
heap, are unaffected.

The core of this change is teaching path generation about the custom
scan:

* generate_expected_filter_paths()/create_filtered_scan_path() now clone
  a base-relation CustomPath into filter-bearing variants when the path
  advertised CUSTOMPATH_SUPPORT_BLOOM_FILTERS, exactly as they already do
  for the stock scan path types.

* find_bloom_filter_recipient() treats a base-rel CustomScan
  (scanrelid > 0) that carries the flag the same as a SeqScan.  The probe
  is not wired into ExecScanExtended() -- a CustomScan dispatches to the
  provider's ExecCustomScan -- so the provider calls ExecBloomFilters()
  itself at whatever granularity it supports.  ExecInitCustomScan()
  compiles the probe state up front via ExecInitBloomFilters(), and
  set_customscan_references() fixes the pushed key expressions for a
  base-relation scan just like the scan qual.

In addition, an opted-in recipient gets two things a stock scan does
not:

* Per-key filters.  Alongside the combined-hash filter, the hash join
  optionally builds one Bloom filter per join key.
  The combined filter, keyed on the hash of all keys together, stays the
  default and remains the more selective one for a per-row probe: per-key
  filters only test whether each column's value appears somewhere in the
  build side, so on a multi-column join they are strictly weaker.  What
  they enable is testing a single key column on its own (a column store
  can check one column against its per-column dictionary or zone map and
  skip whole row groups before decompression, which the combined filter
  cannot support). The build reuses per-key inner hash ExprStates; the
  extra CPU and memory are paid only by a consumer that opted in, and a
  recipient correlates HashState.perkey_filters[i] with
  BloomFilter.filter_exprs[i] by position.  Heap and single-key joins are
  unaffected.

* Eager filter build.  When the outer relation's startup cost is below
  the hash-table build cost, ExecHashJoinImpl fetches the first outer
  tuple before building the hash table to take the empty-outer shortcut.
  For a CustomScan that applies the filter in its own scan loop that is
  too late -- its first tuple request may decompress a whole row group
  before the filter exists.  A HashJoin.bloom_eager flag, set when the
  filter is pushed to such a recipient, tells the executor to skip the
  prefetch and build the hash table (and filter) first.  Only such a
  recipient pays the cost of a possibly-needless hash build on an empty
  outer.

Based on previous patches from Andrew Dunstan, adapted to the path-based
pushdown design.
---
 src/backend/executor/nodeCustom.c             | 11 +++++
 src/backend/executor/nodeHash.c               | 35 +++++++++++++++
 src/backend/executor/nodeHashjoin.c           | 36 +++++++++++++++
 src/backend/optimizer/path/allpaths.c         | 14 ++++++
 src/backend/optimizer/plan/createplan.c       | 45 +++++++++++++++++++
 src/backend/optimizer/plan/setrefs.c          | 10 +++++
 src/backend/optimizer/util/pathnode.c         | 23 +++++++---
 src/include/nodes/execnodes.h                 | 14 ++++++
 src/include/nodes/extensible.h                |  2 +
 src/include/nodes/plannodes.h                 | 21 +++++++++
 .../expected/test_bloom_customscan.out        |  9 ++--
 .../test_bloom_customscan.c                   |  5 +++
 12 files changed, 215 insertions(+), 10 deletions(-)

diff --git a/src/backend/executor/nodeCustom.c b/src/backend/executor/nodeCustom.c
index b7cc890cd20..c18dcb1035d 100644
--- a/src/backend/executor/nodeCustom.c
+++ b/src/backend/executor/nodeCustom.c
@@ -101,6 +101,17 @@ ExecInitCustomScan(CustomScan *cscan, EState *estate, int eflags)
 	css->ss.ps.qual =
 		ExecInitQual(cscan->scan.plan.qual, (PlanState *) css);
 
+	/*
+	 * CUSTOMPATH_SUPPORT_BLOOM_FILTERS and the planner found a filter to
+	 * push.
 
+			/*
 
index e3467f14739..c501b98067d 100644
@@ -908,6 +919,7 @@ ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 
 	/*
 	 * Initialize result slot, type and projection.
@@ -1031,6 +1043,28 @@ ExecInitHashJoin(HashJoin *node, EState *estate, int eflags)
 								&hashstate->ps,
 								0);
 
+		/*
@@ -1118,6 +1152,7 @@ ExecEndHashJoin(HashJoinState *node)
 
 	/*
@@ -1775,6 +1810,7 @@ ExecReScanHashJoin(HashJoinState *node)
 			 * freed by the ExecHashTableDestroy call.
 			 */
 			hashNode->bloom_filter = NULL;
+			hashNode->perkey_filters = NULL;
 
 			/*
 			 * if chgParam of subnode is not null then plan will be re-scanned
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 8829c7c3108..72c51422394 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -25,6 +25,7 @@
 #include "catalog/pg_proc.h"
 #include "foreign/fdwapi.h"
 #include "miscadmin.h"
+#include "nodes/extensible.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/supportnodes.h"
@@ -1294,6 +1295,19 @@ generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 			case T_TidRangePath:
 				basepaths = lappend(basepaths, path);
 				break;
+			case T_CustomPath:
+
+				/*
+				 * A base-relation CustomScan can receive a pushed-down filter,
+				 * but only if the provider advertised that it knows how to
+				 * consume one (it probes the filter itself inside its scan
+				 * loop; see find_bloom_filter_recipient in createplan.c and
+				 * ExecInitCustomScan).  Providers that don't opt in, like heap,
+				 * are unaffected.
+				 */
+				if (((CustomPath *) path)->flags & CUSTOMPATH_SUPPORT_BLOOM_FILTERS)
+					basepaths = lappend(basepaths, path);
+				break;
 			default:
 				break;
 		}
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 51990b98419..b9e481ba298 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4781,6 +4781,25 @@ find_bloom_filter_recipient(Plan *plan, Index target_relid)
 					return plan;
 				return NULL;
 			}
+		case T_CustomScan:
+			{
+				/*
+				 * A CustomScan on a base relation can act as a recipient, but
+				 * only if the provider advertised that it knows how to consume
+				 * a pushed-down bloom filter.  Unlike the stock scans, the
+				 * probe is not performed by ExecScanExtended() (a CustomScan
+				 * dispatches to the provider's own ExecCustomScan); the
+				 * provider is responsible for calling ExecBloomFilters() at
+				 * whatever granularity it likes.  Non-leaf custom nodes have
+				 * scanrelid == 0 and so are rejected by the relid test.
+				 */
+				CustomScan *cscan = (CustomScan *) plan;
+
+				if ((cscan->flags & CUSTOMPATH_SUPPORT_BLOOM_FILTERS) &&
+					cscan->scan.scanrelid == target_relid)
+					return plan;
+				return NULL;
+			}
 		case T_Sort:
 		case T_IncrementalSort:
 		case T_Material:
@@ -4910,6 +4929,32 @@ try_push_bloom_filter(PlannerInfo *root, HashJoin *hj, Plan *outer_plan,

+	/*
+	 * A CustomScan recipient that opted in consumes the filter in its own scan
+	 * loop, possibly at the storage level, so it wants two things a stock scan
+	 * does not.
+	 */
+		if (list_length(hj->hashkeys) > 1)

 	/* Adjust child plan-nodes recursively, if needed */
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 9cd9188a1cf..65690553cf5 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -501,6 +501,17 @@ create_filtered_scan_path(PlannerInfo *root, Path *subpath, List *filters)
 		case T_TidRangePath:
 			sz = sizeof(TidRangePath);
 			break;
+		case T_CustomPath:
+
+			/*
+			 * A base-relation CustomScan provider that advertised
+			 * CUSTOMPATH_SUPPORT_BLOOM_FILTERS can receive a pushed-down
+			 * filter and apply it in its own scan loop
+			 * .generate_expected_filter_paths() only offers such paths here,
+			 * so we need not re-check the flag.
+			 */
+			sz = sizeof(CustomPath);
+			break;
 		default:
 			/* unsupported scan path type */
 			return NULL;
@@ -620,10 +631,10 @@ add_path(RelOptInfo *parent_rel, Path *new_path)
 
 		/*
 		 * Paths carrying different sets of expected Bloom filters serve
-		 * different purposes (each may be consumed by a different parent join,
-		 * or none at all), and their cost/row estimates aren't directly
-		 * comparable.  So if the two paths don't expect the same filters, keep
-		 * both and don't let either dominate the other.
+		 * different purposes (each may be consumed by a different parent
+		 * join, or none at all), and their cost/row estimates aren't directly
+		 * comparable.  So if the two paths don't expect the same filters,
+		 * keep both and don't let either dominate the other.
 		 */
 		if (!expected_filters_equal(new_path->expected_filters,
 									old_path->expected_filters))
@@ -854,8 +865,8 @@ add_path_precheck(RelOptInfo *parent_rel, int disabled_nodes,
 		 * filters of their own, so any filter-bearing old path is a
 		 * non-comparable speculative path and must not be allowed to dominate
 		 * (and thereby suppress) the new path.  Skipping them here also
-		 * guarantees that a join relation always retains at least one ordinary,
-		 * filter-free path to serve as cheapest_total_path.
+		 * guarantees that a join relation always retains at least one
+		 * ordinary, filter-free path to serve as cheapest_total_path.
 		 */
 		if (old_path->expected_filters != NIL)
 			continue;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index aad721f3421..873c02fb080 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2786,6 +2786,20 @@ typedef struct HashState
 	 */
 	struct bloom_filter *bloom_filter;
 
+	/*
 
 /*
index 4e35d77cc49..0e011f3d4e2 100644
@@ -1124,6 +1124,27 @@ typedef struct HashJoin
 
 /* ----------------
diff --git a/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
index e1ce5903a02..8e784209901 100644
--- a/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
+++ b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
@@ -33,9 +33,10 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
                Output: f.a
          ->  Hash
                Output: d.id
+               Bloom Filter 1
                ->  Custom Scan (TestBloomCustomScan) on public.cs_dim d
                      Output: d.id
-(10 rows)
+(11 rows)
 
 -- Single-key join: the filter must actually reject fact rows.
 SELECT test_bloom_cs_reset();
@@ -53,7 +54,7 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
 SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
  filter_rejected_rows 
 ----------------------
- f
+ t
 (1 row)
 
 -- Correctness: the result must be identical with and without the filter.
@@ -97,13 +98,13 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
 SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
  perkey_filters_built 
 ----------------------
- f
+ t
 (1 row)
 
 SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
  filter_rejected_rows 
 ----------------------
- f
+ t
 (1 row)
 
 -- cleanup
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
index 124cbef9ded..99338c7d361 100644
--- a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
@@ -145,6 +145,7 @@ bloom_cs_set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
 	cpath->path.total_cost = total_cost;
 	cpath->path.pathkeys = NIL;
 
+	cpath->flags = CUSTOMPATH_SUPPORT_BLOOM_FILTERS;
 	cpath->custom_paths = NIL;
 	cpath->custom_private = NIL;
 	cpath->methods = &bloom_cs_path_methods;
@@ -234,6 +235,10 @@ bloom_cs_maybe_inspect_perkey(BloomCSScanState * bcss)
 		if (hashNode->bloom_filter == NULL)
 			return;
 
+		if (hashNode->want_perkey_bloom &&
+			hashNode->perkey_nfilters > 0 &&
+			hashNode->perkey_filters != NULL)
+			test_bloom_cs_perkey_seen = true;
 	}
 
 	bcss->inspected = true;
-- 
2.50.1 (Apple Git-155)

---

From: Tomas Vondra <[email-masked]>
Date: Fri, 3 Jul 2026 12:10:33 +0200

> will still produce more rows than expected, but perhaps this is easier

Yes. If we pick a filter expecting it to eliminate 99% of tuples, but
then find it does not eliminate any tuples, that'll affect the counts in
explain. But that's simply how misestimates work, it's not specific to
filter pushdown. It may happen for WHERE clauses etc.

The estimate would hit us at some point anyway - it's the selectivity of
the join, so even without the pushdown the cardinality would be off
above the join.

Also, what else could we do? I don't think we can do planning assuming
the estimates are off - we have to assume the estimates are OK. We might
consider how "safe" a given estimate is, and then maybe not push down
filters for "risky" ones, but we don't have any such capability.

> for its base relation.  So the main change is teaching path generation

Thanks, I'll take a look early next week. My plan is to create a branch
carrying all the pieces, and then gradually refine that.

One thing I'm a bit concerned about is the CustomScan support. We don't
have a single module in the tree, so how would we test the changes? I
think it might be necessary to introduce a minimal CustomScan module, so
that we can test the new pieces.

> will use a bloom filter or not. It's a rough idea, but what do you

Yeah. I don't think the name is that important, it's more about what
kind of flexibility is needed.

> I'm still thinking about your other points and about the XXX comments.

Thanks!

-- 
Tomas Vondra

---

From: "Matheus Alcantara" <[email-masked]>
Date: Fri, 03 Jul 2026 09:50:57 -0300

> filters for "risky" ones, but we don't have any such capability.

Yeah, I was just worried about this may add more confusing, but after
more thinking it does not seems to be the case. I was imagining a
scenario where the expected x actual rows is not very accurate and it
could be hard to know if it was because outdated stats or because the
adaptive probing decided to turn off the filter pushdown but I think
that in the end it's all the same problem: The adaptive probing may turn
off the filter because it's not very effective but this can happen
because of outdated stats that make the planner think that the push down
filters would help but it's not.

> carrying all the pieces, and then gradually refine that.

Good, thanks.

> that we can test the new pieces.

On 0003 I've created a new test extension that register a CustomScan
method. It also expose some sql functions to assert some behaviours of
filter pushdown. I think that it's still on early stage but it may help.
Let me know what do you think.

---

From: Tomas Vondra <[email-masked]>
Date: Mon, 6 Jul 2026 15:34:57 +0200

> for its base relation.  So the main change is teaching path generation

I took a quick look at the v4 patches adding the earlier patches from
Andrew,, and I think it looks sensible enough for a PoC. Thanks!

I think it'd make sense to split some of these into separate commits, as
it's not necessarily tied to just CustomScan nodes. And even if it is,
it can be a separate easier-to-review change.

I propose we move these two "features" into separate commits:

- "eager" creation of filters

- requesting per-key filters

I think it might be useful for other scans (or even non-scan nodes, if
we choose to allow filter pushdown for these). And it should be a
"generic" thing so that find_bloom_filter_recipient does not need to
check if the recipient is CustomScan. In fact, I think this should be
part of the information about expected filters "propagated" up during
planning.

FWIW It might make sense to show these things in the explain, at least
for development. AFAICS the CustomScan (used by the tests in 0003) does
not actually print the filters, right? And I'm not sure if the per-key
filters will be printed at all. But that's minor / easy to fix.

To move this whole thing forward, we'll need to figure out what to do
about the *big* questions. The four main ones I can think of (based on
memory and skimming the XXX comments in the patches) are:

1) selection of 'interesting' filters

Currently, we pick 'candidate' filters based solely on the selectivity,
but that only works if the scans don't use the filter in some smart way
(i.e. when it's evaluated at the very end). But if the custom scan can
do something very smart with some filters, the selectivity alone may not
be enough. The per-key filters make it even harder, I think.

2) construction of paths with filters (adjusting cost)

Right now the paths are constructed by "cloning" existing scan paths,
which is what create_filtered_scan_path does. But as already mentioned
in some XXX comments, I don't think it can work like this.

First, there's the question of costing. Do we need to adjust the cost in
some path-specific way? Maybe we don't, at least for built-in core
paths? In that case the filters are evaluated on the tuples the path
would produce, so maybe we can just add a bit of CPU cost per tuple, to
account for the evaluation of the filter (and reduce the row estimate).
Ff the filter is selective it'd still win, as expected.

But it's a bit dubious even for built-in core paths, because we'd
operate only on paths that already went through add_path and survived
the selection. Maybe we already eliminated some interesting path?

With "smart" CustomScan it's a bigger problem, though. If the scan is
doing something smart with the filters, it likely needs to adjust the
cost in a much more complex way. But the cloning does not allow that.

But more importantly, we can't copy CustomPath like this, because the
comment in pathnodes.h says:

    * Core code must avoid assuming that the CustomPath is only as
    * large as the structure declared here; providers are allowed to
    * make it the first element in a larger structure. (Since the
    * planner never copies Paths, ...

And we'd violate that. (Maybe this is a problem for the in-core scan
paths too?)

Some particular CustomPaths might be OK, but we can't know which ones
are. Maybe we could require scans wih CUSTOMPATH_SUPPORT_BLOOM_FILTERS
to allow such cloning, but it seems pretty fragile.

3) pushdown to multiple consumers (scans of partitioned tables)

Right now, we only allow pushdown to a single consumer. But with
partitioned tables that won't work - each partition scan is a consumer
of the same join (unless it's a partition-wise join, probably). We need
to make that work. It probably can't happen right now, because we don't
push through Append nodes (per find_bloom_filter_recipient), but that
seems like something we should allow.

Does not seem extremely particularly hard to address, though. The
find_bloom_filter_recipient simply needs to collect a list of recipient
nodes, not just the one.

4) parallel hash joins

There's some complexity due to having to put the filter into shmem, but
the v2 patch solved that I think, and we can mostly just copy that. I
don't think we need to solve that now.

I think (3) and (4) are somewhat mechanical. I know, it can easily be
much harder I envision. We won't know until we try.

For (1) and (2), I think we need to make this work more like how we
build paths for pathkeys. Do some sort of initial selection of
interesting filters, without trying to be very smart. So we'd maybe
check the filter discards > 30% of tuples, but without trying to keep
only the "top three" filters.

Then we'd generate the paths for all the possible combinations of
filters, not by cloning but by passing the expected filters to the
create_ function, and constructing a new path. If the scan can do
something smart - cool, it'd calculate the cost. In the worst case it'd
apply the filter at the end, with the generic cost / rows adjustment.

The paths with matching expected filters would still compete (just like
for matching pathkeys), of course.

There's still need to be some limits, though. If the table has 10 joins,
we can't generate paths for each possible combination of filters, that'd
be 2^10 combinations. But there are some inherent limits too, I think.
For example, once we discard ~90% tuples it probably does not make much
sense to keep applying additional filters. If each filter discards ~50%
tuples, that's 3 filters. So even with 10 joins we'd be limited to ~120
paths (which is still a lot, but way lower than 2^10).

We could still have some "heuristics" limits too, e.g. don't keep all
these paths, but prune them very aggressively and pick only the best
ones - just like we do now by only picking 3 most selective filters,
except that we'd do that based on proper costing (and not selectivity).

I plan to experiment with (1) and (2) soon, but it'd be great if you
could experiment with that too, and/or think about alternative ideas.

regards

-- 
Tomas Vondra

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Jul 2026 15:20:27 -0400

On Wed, Jun 3, 2026 at 5:21 AM Oleg Bartunov <[email-masked]> wrote:
> For a plain heap scan this may mostly save hash probes. But with zone/chunk-oriented storage, where chunks have dictionaries, min/max metadata, Bloom summaries, or tenant ranges, the same runtime filter can skip whole chunks. That is the part I find most interesting: turning join-derived knowledge into scan-level pruning, against the normal direction of data flow.
>
> Bloom is just one carrier for that knowledge. The real feature is a pluggable runtime-filter mechanism that heap, CustomScan, FDW, columnar/table AMs, partitioned storage, or chunk/cold storage can consume at the level they understand.

+1. I think it's fine if the optimizer and executor decide to do
things strictly with Bloom filters, if that turns out to be a good
technique. But if we're talking about pushing things down into table
AM we should try to be more general.

Or at least, that's my current thinking.

-- 
Robert Haas
EDB: http://www.enterprisedb.com

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Jul 2026 15:32:00 -0400

On Wed, Jun 3, 2026 at 6:07 AM Tomas Vondra <[email-masked]> wrote
> hash join, because what other nodes / joins can build the filter?

Even if only hash joins build Bloom filters (or some other kind of
filters), those filters can be pushed through other joins. For
example, consider:

Hash Join
Nested Loop
  -> Seq Scan on a
  -> Index Scan on b
-> Hash
  -> Seq Scan on c

If the hash join to table c builds a Bloom filter, it's perfectly
valid to test against that filter before doing the index probe into
table b.

The hard part is the planning. I feel like doing this at path-to-plan
time, after costing decisions have been made, is probably going to be
a tough sell. In a plan tree with many joins, pushing down the Bloom
filtering (or other filtering) decision to the lowest possible level
could drastically change the row count estimates, and thus the
costing, for a whole bunch of intermediate nodes, potentially meaning
that the best plan is something quite different from what we
estimated. On the other hand, if we try to do the "right" thing at
planning time -- meaning constructing separate paths for each possible
place where we could put the filter -- so that we can do costly
properly, that sounds super-expensive, which will be really bad for
queries that are supposed to have short execution times, and also not
great for cases where the runtime is longer but our estimates are
inaccurate, so that the extra planning effort is an expensive way of
deciding what to do at random.

i haven't hard time to read the paper to which you linked, but I do
sort of feel like knowing what the practical experience has been in
other systems might be important. Theoretically there are good
arguments for and against additional planning effort, but what happens
in practice is not clear to me.

---

From: Tomas Vondra <[email-masked]>
Date: Tue, 7 Jul 2026 20:39:52 +0200

> valid to test against that filter before doing the index probe into

I don't disagree, but it's that what all the push-down patches in this
thread are already doing? When I said "tied to hash join" I meant the
filter would be built by a hash join, not that all the joins have to be
hash joins. It can certainly by pushed through NL/merge joins.

> deciding what to do at random.

I agree, but I think you're still looking at the v1 patch. The v3 posted
a week ago (and v4 posted by Matheus) are doing this doing the bottom-up
planning, when constructing scan paths.

You're definitely right the extra paths may be rather expensive, and
we'll need to keep that under control. If a query has 10 joins, we can't
realistically consider all 2^10 of filter combinations. The patch does
that by selecting a couple of the most promising filters, but I'm sure
we may need to do more.

With pushdown done at path-to-plan time, this was not an issue, but as
you point out it's after we already selected the plan. So we might have
missed a "better" plan, it was merely an attempt to improve the plan we
already selected. OTOH, that "limited the damage" when our estimates
turned out to be wrong.

But I think there are more options how to limit the cost. For example,
it's unlikely to help when the scan already returns very few tuples. So
maybe we could consider filters only when the row count is above 1000,
or something like that. And maybe we could do something similar based on
cost? But that would ignore savings above the scan (e.g. maybe one of
the later joins is very expensive).

I think a lot of the regressions can be handled by adaptive behavior at
execution time. Like, we can postpone building the filter until after
we've already probed the hash table to know it's worth it. Or disable
the filter when it becomes ineffective. The v3/v4 patches already do
some of this, IIRC.

The CustomScan could ignore a lot of that, of course. For example, v4
has an option to force building the filter eagerly (so that hashjoin
does not wait for the first outer tuple). But that's really a problem of
the CustomScan authors.

Of course, even with the adaptive behavior we're "stuck" with the new
plan (which may differ from the plan we'd have picked otherwise). But
I'm not sure it's really that different. Because really, the impact is
somewhat limited.

What happens is that we pick the K most selective joins, and build
filters for those. And then we plan with that. Those selective joins
would be at the very bottom of the original plan, because we prefer to
do selective joins first.

Let's say a plan has 4 joins.

           X
         /   \
       B2     X
            /   \
           B1    X
               /   \
              A2     X
                   /   \
                  A1    T

Most likely, A1 and A2 joins are the two most selective, so we get to
build filters for those, and push them to scan on T. Which means the
filtering is applied in the scan, and the joins themselves won't reduce
the cardinality at all!

The B1 and B2 joins become "more selective", and in the new plan
(selected with filter pushdown) will be performed first:

           X
         /   \
       A2     X
            /   \
           A1    X
               /   \
              B2     X
                   /   \
                  B1    T (filters from A1/A2)

But the A1/A2 filtering still happens *before* B1/B2, thanks to the
filter. So B1/B2 are still planned with the same selectivity and row
counts as before, and so will most likely use the same join algorithm.

So the impact on the query plan is actually much smaller than it might
seem. Which likely limits the risks quite a bit.

Of course, this is just a very simple example. Maybe it's riskier for
more complex joins, not sure.

> in practice is not clear to me.

That's a good idea, but I'm not aware of an open source database
implementing this :-(

regards

-- 
Tomas Vondra

---

From: Robert Haas <[email-masked]>
Date: Tue, 7 Jul 2026 17:19:38 -0400

> hash joins. It can certainly by pushed through NL/merge joins.

I haven't actually looked at the patches -- I was just responding to
the email. Sounds like I misunderstood.

> realistically consider all 2^10 of filter combinations.

Yeah.

> the later joins is very expensive).

I'm not sure which scan you mean when you say "when the scan already
returns very few tuples." Suppose we propose to join A to B. We plan
to use a hash join and use B as the build table and A as the probe
table. So, if I understand correctly, we'd build the bloom filter on B
and use it when scanning A. Do you mean that the filter is unlikely to
help when B is small, or when A is small? Neither is obvious to me: if
B is small, the hash table will be small, which might make it cheap to
probe, but it also makes the Bloom filter small, so maybe that's
*really* cheap to probe, plus a small Bloom filter is enough to have a
really low false-positive rate. If A is small, you're not going to
save as much in absolute terms, but you save the same thing
percentage-wise as on a larger table.

It seems like the most important thing -- which I'm aware you already
mentioned -- is how likely it is that a row in A joins to a row in B.
If the answer is "not very likely," the filter has a good chance of
working out.

> the filter when it becomes ineffective. The v3/v4 patches already do

Yeah, adaptive behavior is great. We need to be careful not to add too
much complexity, on the one hand, or to expect perfection, on the
other. But it's a good way of mitigating downsides.

> I'm not sure it's really that different. Because really, the impact is

This doesn't especially worry me: any plan can be wrong. If the
planner goes wrong substantially more frequently with this patch than
without, of course that would be bad. But if it just goes wrong for a
different set of queries,  I think that's just life.

> do selective joins first.

This is a clever idea.

> counts as before, and so will most likely use the same join algorithm.

They could switch to a nested loop, though, which is a little tricky
to reason about: any individual join could be better as a nested loop,
while the set of joins taken together might be better if you use all
hash joins so that the filters can be combined.

> more complex joins, not sure.

I don't see that a more complex tree magnifies the risk; I think it's
just about how sure you can be that you're actually going to filter
something out. Your chances of winning probably actually grow with the
number of joins for which you build the filter, because I guess you
can bitwise-AND all of those filters and just test the result. If even
one of the filters is highly selective, that may gain you enough to
pay for the cost of building all of them. This is not guaranteed, of
course, but the risks and rewards seem asymmetric: a useful filter
seems as though it can win much harder than a useless filter can ever
lose. I could be wrong, but my guess is that we only need to do a
moderately good job avoiding the worst case.

---

From: Tomas Vondra <[email-masked]>
Date: Wed, 8 Jul 2026 00:26:41 +0200

On 7/7/26 23:19, Robert Haas wrote:
> help when B is small, or when A is small?

When A is small, i.e. the outer side of the join (not the side on which
we built the hash / filter).

The idea is that if the outer side has 1000 rows, even if we eliminate
90% of them it may not pay for itself (because of the cost of extra
paths during planning, building the filters etc.).

One of my ideas for keeping the number of paths under control was to
stop "adding" filters to a path once the cardinality drops below some
threshold. Let's say we start with a scan returning 1M rows, and we add
2 filters eliminating 90% tuples each. Cool, now it's expected to return
10k rows. Should we consider a version with a 3rd filter? Maybe it'll
help a little bit, but the benefit is certainly much smaller than for
the first two filters.

> Neither is obvious to me: if
> really low false-positive rate.

The size of B (on which the hash table is built) is a very poor signal.
What matters is the selectivity of the join, and a tiny table can
eliminate 1% or 99% - it does not depend on the size.

Yes, a tiny table may be cheap to probe (the Bloom filter will still be
cheaper, but it'll be closer). For large hash tables the trade offs is a
bit different, especially when the join start spilling. But even then
it's more about what fraction of tuples it eliminates.

But focusing on the relative cost of probing the hash table vs. probing
the Bloom filter is missing the point - it's about *when* you probe it.
With the pushdown the probe happens much earlier, before doing a lot of
other relatively expensive stuff.

As an extreme case, imagine we push the filter into a ForeignScan. The
foreign scan could copy the filter to the remote side, and discard all
the tuples there, saving all the network costs. (Yes, the patches don't
do this - yet, but it's not a huge change.)

Yes, maybe we could skip building the filter and just probe the hash
table directly. That only works in some cases (e.g. the FDW case would
not work), and only when the hash table is "complete" (no spilling).

> If A is small, you're not going to 

Exactly. And then it's also about the cost of what happens in between
the probe and the join.

> other. But it's a good way of mitigating downsides.

100% agreed

> different set of queries,  I think that's just life.

OK

> hash joins so that the filters can be combined.

No, I don't think they could switch to nested loops. That's the whole
point - the B1/B2 joins are planned with *exactly* the same row
estimates as before, because A1/A2 still eliminate the tuples before
B1/B2. So if they are nested loops in the new plan, it'd be the same in
the old plan too.

Of course, it can happen that B1/B2 really are nested loops, and that
it's a poor choice because A1/A2 eliminated much smaller fraction of
tuples than we thought. But that affects both plans the same way.

> moderately good job avoiding the worst case.

I admit I'm hedging a little bit - it's probably fine even for complex
plans, but I haven't done enough tests to make such a clear claim.

Maybe you're right more filters make it safer. But more filters mean we
need to consider more paths, so it's a trade off. Unless we figure out a
good way to pick "interesting" combinations.

I'm not sure about combining the filters by bitwise-AND. Maybe it could
work and help a little bit, but it'd also require all the filters to
have the same size. It's worth testing, but I think the cost of the
filter probe does not matter all that much (compared to the savings by
eliminating the tuples much earlier).

regards

-- 
Tomas Vondra