[PROPOSAL] Expand OR clauses in joins to UNION ALL paths
Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.
You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:
docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t254090psql -h localhost -U postgresBuilt from patchset v8 (message #8), October 22, 2026 at 03:55 PM.
Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:
git clone --branch t254090_8 https://github.com/hackorum-dev/postgres.gitIn a checkout you already have, add the fork once:
git remote add hackorum https://github.com/hackorum-dev/postgres.gitthen, for this patchset and every later one:
git fetch hackorum t254090_8 && git checkout t254090_8Patchset v8 (message #8) is on t254090_8
Hello hackers,
Attached is a patch that lets the planner rewrite an OR clause that spans
several relations into a UNION ALL with one branch per arm, and use that
plan when it is estimated to be cheaper.
Background
OR clauses that mention more than one relation are awkward for the planner
today. If the OR is the join condition itself (a.x = b.y OR a.x = b.z), it
cannot be a hash or merge join clause, so the only choice left is a nested
loop, usually with a BitmapOr of index scans on the inner side that is run
again for every outer row. If it is a filter on two different tables
(a.f = 1 OR b.g = 2), BitmapOr cannot help either, since it only combines
indexes of one relation, and the OR ends up as a join filter. The
textbook case is a star schema with an OR over conditions on two
dimension tables.
I ran into this while working on undirected edge patterns in SQL/PGQ
[1]: /messages/by-id/4ab664c3585d3c2da2d4c1d1bbed430e@data-bene.io
edge in the join condition, and on LSQB the nested loop
with the per-row BitmapOr ran the inner scan about 61 million times at SF
0.1. Writing the same query by hand as a UNION ALL took it from 91.7 s to
1.6 s, and at greater scale factors it was faster by 4610x . I first
did the rewrite in the GRAPH_TABLE rewriter, but Ashutosh pointed out that
it belongs in the planner: if at any time its added, the rewrite for
SQL/PGQ would be redundant.
Previous work
There was a previous discussion on this, Tom Lane posted a patch for
this in 2017 [2]/messages/by-id/7f70bd5a-5d16-e05c-f0b4-2fdfc8873489@BlueTreble.com, in the thread . The idea is: plan the arms before
query_planner() runs and add
the result as an Append path afterwards. The differences are the way
duplicates are avoided and what is accepted. At that time it wasn't
clear how to prove correctness on deduplication (which was done with a
Unique on CTIDs of base relations), last message of the thread actually
puts the proof of doing the expansion with negating previous arms'
clauses. The latter way of doing it is far better than deduplication
with CTIDs because: we are not limited to plain tables, we don't need a
unique step.
What the patch does
For an OR with arms C1..Cn, branch k is planned as the original query with
the OR replaced by
Ck AND (C1) IS NOT TRUE AND ... AND (C(k-1)) IS NOT TRUE
so a row that satisfies several arms is returned only by the first of
them. IS NOT TRUE (rather than NOT) keeps rows where an earlier arm
evaluates to NULL, so the result has the same rows and multiplicity as the
original query and no duplicate elimination step is needed. Each branch is
planned with query_planner() on a copy of the query, the cheapest path
of each branch is taken, and
they are combined into an Append that is added to the top scan/join
relation with add_path(). Here i followed exactly what Tom's patch did.
Outer joins are reduced again inside each branch, since the branch's
quals can make it null-rejecting.
Where it applies:
An OR in the WHERE clause with between 2 and or_expansion_limit arms that
refers to at least two relations: either the OR is the join condition, or
it is a filter spanning two tables.
SELECT ... FROM a, b WHERE a.x = b.y OR a.x = b.z;
SELECT ... FROM a JOIN b ON a.id = b.a_id WHERE a.f = 1 OR b.g = 2;
Where it is not considered:
- ORs that only reference one relation, the argument is that in this
case, surely a BitmapOr or anything else do the job.
- volatile functions in the jointree, security quals or function/subquery
RTEs, since the arms are evaluated more than once
- FULL joins, TABLESAMPLE, FOR UPDATE/SHARE, CTEs, set operations,
recursive queries, well anything other than SELECT ?
- partitioned tables, also inside subqueries. Child RTEs are created inside
query_planner(), so after arms prune differently their range table
indexes no longer match the original one, i didn't try to make it
work yet the solution here is maybe to refix pointers back to match root
PlannerInfo for each arm, but this felt too much, i wonder if its worth
it for the benefit it can get for partitioned tables.
- ORs in JOIN ... ON clauses. Only the top level WHERE quals are looked at
for now.
or_expansion_limit (default 8, 0 disables) bounds the number of arms.
Every arm costs one extra planner run, and arm n carries n-1 negated copies
of the earlier arms, so planning cost can grow quickly with the number of
arms. It is also an off switch to makes before/after comparisons easy
for the moment. The
default of 8 is a nice guess because i found that starting from 7 arms,
planning time sometimes doubles (in complicated queries).
Some simlpe benchmark results
12 vCPUs, 15GB RAM, NVMe, shared_buffers = 2GB, work_mem = 128MB,
max_parallel_workers_per_gather = 2.
Star schema, 20M row fact table, OR between a condition on a customer
dimension and one on a product dimension:
1893 ms -> 148 ms (planning 0.26 ms -> 0.52 ms)
Two 2M row tables, selective OR with one arm on each table:
415 ms -> 26 ms (planning 0.94 ms -> 1.07 ms)
Three tables, three selective arms:
891 ms -> 125 ms (planning 1.6 ms -> 3.0 ms)
Join of two 2M row tables on (a = a OR b = b), join columns indexed,
outer side limited to id < 100k:
1520 ms -> 435 ms (buffer hits 57,204,110 -> 179,797)
Same with no indexes, id < 50k:
1508 ms -> 484 ms
With id < 10k, indexed / not indexed:
841 ms -> 417 ms / 1538 ms -> 451 ms
Where the arms are not selective (about 20% of the rows each) the original
plans are kept.
Patch and simple benchmark files attached.
[1]: /messages/by-id/4ab664c3585d3c2da2d4c1d1bbed430e@data-bene.io
/messages/by-id/4ab664c3585d3c2da2d4c1d1bbed430e@data-bene.io
[2]: /messages/by-id/7f70bd5a-5d16-e05c-f0b4-2fdfc8873489@BlueTreble.com
/messages/by-id/7f70bd5a-5d16-e05c-f0b4-2fdfc8873489@BlueTreble.com
Regards,
Ayoub Kazar
On 07.10.26 13:02, Ayoub Kazar wrote:
Attached is a patch that lets the planner rewrite an OR clause that spans
several relations into a UNION ALL with one branch per arm, and use that
plan when it is estimated to be cheaper.Background
OR clauses that mention more than one relation are awkward for the planner
today. If the OR is the join condition itself (a.x = b.y OR a.x = b.z), it
cannot be a hash or merge join clause, so the only choice left is a nested
loop, usually with a BitmapOr of index scans on the inner side that is run
again for every outer row. If it is a filter on two different tables
(a.f = 1 OR b.g = 2), BitmapOr cannot help either, since it only combines
indexes of one relation, and the OR ends up as a join filter. The
textbook case is a star schema with an OR over conditions on two
dimension tables.
This transformation is extremely useful and in my experience, queries
that can profit from it occur frequently. So +1 for the effort.
Where it is not considered:
- ORs that only reference one relation, the argument is that in this
case, surely a BitmapOr or anything else do the job.
- volatile functions in the jointree, security quals or function/subquery
RTEs, since the arms are evaluated more than once
- FULL joins, TABLESAMPLE, FOR UPDATE/SHARE, CTEs, set operations,
recursive queries, well anything other than SELECT ?
- partitioned tables, also inside subqueries. Child RTEs are created inside
query_planner(), so after arms prune differently their range table
indexes no longer match the original one, i didn't try to make it work
yet the solution here is maybe to refix pointers back to match root
PlannerInfo for each arm, but this felt too much, i wonder if its worth
it for the benefit it can get for partitioned tables.
- ORs in JOIN ... ON clauses. Only the top level WHERE quals are looked at
for now.
The transformation is not always beneficial. Here's an example:
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'returned' -- very selective: 1k rows
OR c.tier = 'standard'; -- very unselective: 900k of 1M customers
When one condition is very selective a NLJ on the table with the more
selective condition is actually pretty efficient.
Do you unconditionally apply the transformation, or do you have
heuristics to only do it if it likely pays off?
--
David Geier
On 07.10.26 13:02, Ayoub Kazar wrote:
Attached is a patch that lets the planner rewrite an OR clause that spans
several relations into a UNION ALL with one branch per arm, and use that
plan when it is estimated to be cheaper.Background
OR clauses that mention more than one relation are awkward for the planner
today. If the OR is the join condition itself (a.x = b.y OR a.x = b.z), it
cannot be a hash or merge join clause, so the only choice left is a nested
loop, usually with a BitmapOr of index scans on the inner side that is run
again for every outer row. If it is a filter on two different tables
(a.f = 1 OR b.g = 2), BitmapOr cannot help either, since it only combines
indexes of one relation, and the OR ends up as a join filter. The
textbook case is a star schema with an OR over conditions on two
dimension tables.
This transformation is extremely useful and in my experience, queries
that can profit from it occur frequently. So +1 for the effort.
Where it is not considered:
- ORs that only reference one relation, the argument is that in this
case, surely a BitmapOr or anything else do the job.
- volatile functions in the jointree, security quals or function/subquery
RTEs, since the arms are evaluated more than once
- FULL joins, TABLESAMPLE, FOR UPDATE/SHARE, CTEs, set operations,
recursive queries, well anything other than SELECT ?
- partitioned tables, also inside subqueries. Child RTEs are created inside
query_planner(), so after arms prune differently their range table
indexes no longer match the original one, i didn't try to make it work
yet the solution here is maybe to refix pointers back to match root
PlannerInfo for each arm, but this felt too much, i wonder if its worth
it for the benefit it can get for partitioned tables.
- ORs in JOIN ... ON clauses. Only the top level WHERE quals are looked at
for now.
The transformation is not always beneficial. Here's an example:
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'returned' -- very selective: 1k rows
OR c.tier = 'standard'; -- very unselective: 900k of 1M customers
When one condition is very selective a NLJ on the table with the more
selective condition is actually pretty efficient.
Do you unconditionally apply the transformation, or do you have
heuristics to only do it if it likely pays off?
--
David Geier
On 07/10/2026 17:42, David Geier wrote:
On 07.10.26 13:02, Ayoub Kazar wrote:
Attached is a patch that lets the planner rewrite an OR clause that spans
several relations into a UNION ALL with one branch per arm, and use that
plan when it is estimated to be cheaper.Background
OR clauses that mention more than one relation are awkward for the planner
today. If the OR is the join condition itself (a.x = b.y OR a.x = b.z), it
cannot be a hash or merge join clause, so the only choice left is a nested
loop, usually with a BitmapOr of index scans on the inner side that is run
again for every outer row. If it is a filter on two different tables
(a.f = 1 OR b.g = 2), BitmapOr cannot help either, since it only combines
indexes of one relation, and the OR ends up as a join filter. The
textbook case is a star schema with an OR over conditions on two
dimension tables.This transformation is extremely useful and in my experience, queries
that can profit from it occur frequently. So +1 for the effort.
Glad to hear.
Where it is not considered:
- ORs that only reference one relation, the argument is that in this
case, surely a BitmapOr or anything else do the job.
- volatile functions in the jointree, security quals or function/subquery
RTEs, since the arms are evaluated more than once
- FULL joins, TABLESAMPLE, FOR UPDATE/SHARE, CTEs, set operations,
recursive queries, well anything other than SELECT ?
- partitioned tables, also inside subqueries. Child RTEs are created inside
query_planner(), so after arms prune differently their range table
indexes no longer match the original one, i didn't try to make it work
yet the solution here is maybe to refix pointers back to match root
PlannerInfo for each arm, but this felt too much, i wonder if its worth
it for the benefit it can get for partitioned tables.
- ORs in JOIN ... ON clauses. Only the top level WHERE quals are looked at
for now.The transformation is not always beneficial. Here's an example:
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'returned' -- very selective: 1k rows
OR c.tier = 'standard'; -- very unselective: 900k of 1M customersWhen one condition is very selective a NLJ on the table with the more
selective condition is actually pretty efficient.Do you unconditionally apply the transformation, or do you have
heuristics to only do it if it likely pays off?
No everything is planned like any other path, each arm in the OR gets
fully planned alone, then UNIONed ALL together, cost is recalculated on
this new Append path, so its cost based, not a rule.
Regards,
Ayoub Kazar
Hello!
I see the motivation / idea behind it, but I think this needs some more thought.
The patch seems to cause some test failures in the test suite (test_misc/003_check_guc, test_plan_advice/001_replan_regress.
And some quick testing revealed two more specific issues:
1. Shouldn't the patch either handle subplans, or skip queries containing them?
CREATE TABLE t1 (id int PRIMARY KEY, a int, c int);
CREATE TABLE t2 (id int PRIMARY KEY, b int);
INSERT INTO t1 SELECT g, g % 10, g % 7 FROM generate_series(1, 2000) g;
INSERT INTO t2 SELECT g, g % 10 FROM generate_series(1, 2000) g;
CREATE INDEX ON t2 (b);
ANALYZE t1, t2;
CREATE SEQUENCE flip_seq;
CREATE FUNCTION flip() RETURNS bool VOLATILE LANGUAGE sql
AS $$ SELECT nextval('flip_seq') % 3 = 0 $$;
SELECT count(*) FROM t1, t2
WHERE (SELECT flip() WHERE t1.id > 0) OR t1.a = t2.b;
2.
The default of 8 is a nice guess because i found that starting from 7 arms,
planning time sometimes doubles (in complicated queries).
The planning time and memory use seems to be exponential with nesting, this explain takes 153 seconds for me in a debug build:
create table t1 (id int primary key, a int, c int);
create table t2 (id int primary key, b int);
insert into t1 select g, g % 10, g % 7 from generate_series(1, 2000) g;
insert into t2 select g, g % 10 from generate_series(1, 2000) g;
analyze t1, t2;
explain (summary, memory, costs off, timing off) select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select id, b from t2) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id;
Hello,
On 07/10/2026 23:44, Zsolt Parragi wrote:
Hello!
I see the motivation / idea behind it, but I think this needs some more thought.
Thanks for the review!
The patch seems to cause some test failures in the test suite
(test_misc/003_check_guc, test_plan_advice/001_replan_regress.
I was aware of GUC test, i forgot about that, so its fixed now.
For the plan_advice test, there's a join test where previously planner
was doing ScalarArrayOpExpr transformation, when i added or expansion it
became cheaper for that test case. IIUC pg_plan_advice doesn't support
having different join strategies across Append arms for the same
relation pair, i'm not sure how should i avoid this test fail without
doing something wrong like disabling or expansion before the join test ?
I suppose this might be a pg_plan_advice issue ?
And some quick testing revealed two more specific issues:
1. Shouldn't the patch either handle subplans, or skip queries containing them?
CREATE TABLE t1 (id int PRIMARY KEY, a int, c int);
CREATE TABLE t2 (id int PRIMARY KEY, b int);
INSERT INTO t1 SELECT g, g % 10, g % 7 FROM generate_series(1, 2000) g;
INSERT INTO t2 SELECT g, g % 10 FROM generate_series(1, 2000) g;
CREATE INDEX ON t2 (b);
ANALYZE t1, t2;
CREATE SEQUENCE flip_seq;
CREATE FUNCTION flip() RETURNS bool VOLATILE LANGUAGE sql
AS $$ SELECT nextval('flip_seq') % 3 = 0 $$;
SELECT count(*) FROM t1, t2
WHERE (SELECT flip() WHERE t1.id > 0) OR t1.a = t2.b;
Correct:
+ if (contain_subplans((Node *) parse->jointree->quals))
this should be enough i think.
2.
The default of 8 is a nice guess because i found that starting from 7 arms,
planning time sometimes doubles (in complicated queries).The planning time and memory use seems to be exponential with nesting,
this explain takes 153 seconds for me in a debug build:create table t1 (id int primary key, a int, c int);
create table t2 (id int primary key, b int);
insert into t1 select g, g % 10, g % 7 from generate_series(1, 2000) g;
insert into t2 select g, g % 10 from generate_series(1, 2000) g;
analyze t1, t2;
explain (summary, memory, costs off, timing off) select x.id, max(s.b)
as b from t1 x, (select x.id, max(s.b) as b from t1 x, (select x.id,
max(s.b) as b from t1 x, (select x.id, max(s.b) as b from t1 x,
(select x.id, max(s.b) as b from t1 x, (select x.id, max(s.b) as b
from t1 x, (select x.id, max(s.b) as b from t1 x, (select id, b from
t2) s where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s
where x.a = s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a
= s.b or x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or
x.c = s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c =
s.id or x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or
x.id = s.b group by x.id) s where x.a = s.b or x.c = s.id or x.id =
s.b group by x.id;
True, this shouldn't be allowed for subqueries IMO.
+ /* + * Only top-level queries may be expanded. Expanding within
subqueries + * leads to extreme planning overhead with nested
subqueries. + */ + if (root->parent_root != NULL) + return false;
I also found two other issues:
- Previously i had a comment on whether LateralRTEs should be allowed, i
found that they are not safe for this transformation, therefore there's
a new guard for it too.
- Derived tables with un-grouped aggregates can be optimized into
MinMaxAggPath nodes whose InitPlan replacement parameters are populated
in that arm's private subroot->minmax_aggs during plan creation. Because
set_subqueryscan_references() resolves the subquery's planner state
through the top-level root->simple_rel_array, this mapping is missing
during reference fixing, causing an Aggref found in non-Agg plan node
executor error.
Example:
SELECT * FROM agg_t1 t1, agg_t2 t2, (SELECT min(x) as mx FROM agg_t2) s
WHERE (t1.a = t2.x OR t1.b = t2.y) AND s.mx = 1;
so i added a guard for this too. (attached as v2 of the patch)
I'm not sure whether it is worth making some of the guarded cases work
with or-expansion, if the list doesn't grow bigger than this, i think
that not considering them would be cheaper.
Or we can do the inverse: the query is safe for expansion only if all
RTEs are base relations, not even inherited and partitioned relations.
The latter might be too restrictive but far simpler, maybe (attached as
v3 of the patch).
Thoughts ?
Regards,
Ayoub Kazar
Attachments:
t254090_6v2-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchtext/x-patch; charset=UTF-8; name=v2-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchDownload+1012-6
v3-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchtext/x-patch; charset=UTF-8; name=v3-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchDownload+956-6
The transformation is not always beneficial. Here's an example:
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'returned' -- very selective: 1k rows
OR c.tier = 'standard'; -- very unselective: 900k of 1M customersWhen one condition is very selective a NLJ on the table with the more
selective condition is actually pretty efficient.Do you unconditionally apply the transformation, or do you have
heuristics to only do it if it likely pays off?No everything is planned like any other path, each arm in the OR gets
fully planned alone, then UNIONed ALL together, cost is recalculated on
this new Append path, so its cost based, not a rule.
Ah, that's cool!
I was thinking about it like a rewrite happening post-parsing /
pre-planning.
--
David Geier
Rebased v2 and v3.
Regards,
Ayoub Kazar
Attachments:
t254090_8v3-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchtext/x-patch; charset=UTF-8; name=v3-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchDownload+956-6
v2-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchtext/x-patch; charset=UTF-8; name=v2-0001-Expand-OR-clauses-in-joins-to-UNION-ALL-Append-paths.patchDownload+1012-6
+ /* Only plain base relations are permitted */
+ if (rte->rtekind != RTE_RELATION)
+ return false;
This will even reject simple GROUP BY (RTE_GROUP)
SELECT a.x FROM a JOIN b ... WHERE a.f=1 OR b.g=2 GROUP BY a.x
And there are probably many other interesting query features the patch could support without problems.
I also want to point out that this can result in queries that otherwise work on master erroring out with the patch:
CREATE TABLE item (id int PRIMARY KEY, kind text);
CREATE TABLE attr (item_id int, val text, tag int);
INSERT INTO item
SELECT g, CASE WHEN g % 100 = 0 THEN 'txt' ELSE 'num' END
FROM generate_series(1, 100000) g;
INSERT INTO attr
SELECT g, CASE WHEN g % 100 = 0 THEN 'abc' ELSE (g % 500)::text END,
CASE WHEN g % 100 = 0 THEN 7 ELSE g % 1000 END
FROM generate_series(1, 100000) g;
CREATE INDEX ON item (kind);
CREATE INDEX ON attr (tag);
ANALYZE item, attr;
SELECT count(*) FROM item i JOIN attr a ON a.item_id = i.id
WHERE i.kind = 'txt' OR (a.val::int = 7 AND a.tag = 7);
Every 'txt' item in this database fails the right side of the OR, but because the left side is true, the right side is never evaluated.
The evaluation order isn't guaranteed in this case, the documentation explicitly tells people not to do this. So in that sense, breaking it isn't wrong, but it is still a noticeable behavior change.