🚀 New: chi (χ) — an open-source autoresearch harness for fleets of LLM coding agents. Read the announcement.

Postgres Self-Join Elimination Could Merge Two Table References That Had Different RLS Policies

Tom Lane's September 29 commit, backpatched to Postgres 18, stops self-join elimination from merging two references to the same table when their checkAsUser differs. Why a join-removal rewrite made that unsafe with row-level security, what the fix compares instead of the policy quals, and who could have been exposed.

Contents

On September 29, Tom Lane committed dca6a9e to Postgres master with the subject “Prevent self-join elimination when RTEs’ checkAsUser fields differ”, and it was backpatched through version 18.[1][2] The commit message is blunt about the defect: self-join elimination (SJE) “didn’t consider the possibility that two RTEs referencing the same table have different securityQuals, and might choose to merge them anyway, thereby possibly losing quals that need to be enforced."[1]

That is a row-level security problem hiding in the optimizer. The interesting part is not that it was fixed, but what the fix compares.

Two references to one table are not always the same reference

SJE rewrites a query like SELECT ... FROM t a JOIN t b ON a.pk = b.pk into a scan of t once. For that to be legal, the two range table entries (RTEs) must be interchangeable, and the planner has to be sure that nothing attached to one of them gets lost when the other survives.

Row-level security attaches filters to an RTE as securityQuals. Which policies apply depends on the role the table is accessed as, and that role is recorded per RTE as checkAsUser in its permission info. A table read directly by the calling user has one effective role. The same table read through a view that is not security_invoker is checked as the view owner, so its RTE carries a different checkAsUser and, potentially, different policy expressions.

The commit message states the invariant the fix leans on: “the set of applicable RLS policies depends only on the role that is considered to be accessing the table, so that the securityQuals must be equal if the checkAsUser values are."[1]

Why this was a regression and not an old bug

The message says it is “a regression introduced by 2ebf25e”.[1] That earlier commit, “Perform join removal by editing the query’s jointree”, was dated August 28 in the history I read and carries a Backpatch-through: 16 trailer. It was written to fix bug #19560, where removed joins left orphaned equivalence classes and produced incorrect query results.[3]

According to Lane’s message, the relevant change is that, after 2ebf25e, the planner regenerates baserestrictinfo lists from the RTEs’ securityQuals after SJE has run. Before that, it did not, so the surviving relation kept whatever restrictions it had already been given. After it, the planner rebuilds restrictions from the surviving RTE, and any securityQuals that lived only on the removed RTE are simply not there.

So a correctness fix to join removal opened a hole in a different feature. The two features do not look related until you notice that both rewrite what an RTE means.

What the fix compares

The first proposed fix was to skip SJE for any RTE with non-empty securityQuals. Lane rejected it as “rather sad, especially since such cases worked before 2ebf25e”.[1] Comparing the qual trees directly was also out, because it is more expensive and “we can’t sort them so the preliminary sort step wouldn’t help."[1]

Instead, the candidate struct gets one more field, and both sorting and grouping use it:[2]

typedef struct
{
    int relid;
    Oid reloid;
    Oid useroid;    /* checkAsUser */
} SelfJoinCandidate;

Candidates are sorted by reloid, then useroid, and a group ends when either differs. The diff touches analyzejoins.c (33 additions, 14 deletions) plus the rowsecurity regression test.[2] The cost is one more integer comparison in a step that already sorts by relation OID.

The commit message flags a side benefit: “It might also keep us from creating similar bugs if we ever invent other features that depend on the accessing role."[1]

What the regression test shows

The new test in rowsecurity.sql has two cases. When both references go through the same view, SJE still applies:

EXPLAIN (COSTS OFF)
SELECT * FROM rls_view r1, rls_view r2 WHERE r1.a = r2.a;

 Seq Scan on rls_tbl
   Filter: (InitPlan exists_2).col1
   InitPlan exists_1
     ->  Seq Scan on ref_tbl
   InitPlan exists_2
     ->  Seq Scan on ref_tbl ref_tbl_1

When one reference is rls_tbl directly and the other is the view, as regress_rls_bob, the test comment reads “No SJE here, because the two rls_tbl RTEs have different checkAsUser values”, and the plan stays a Hash Join with a separate filter on each scan.[2]

The first plan shows a quirk the author documents: the merged scan keeps only exists_2 as a filter, but exists_1 is still attached as an unused InitPlan, because SS_process_sublinks runs before SJE.[1] The second quirk is deliberate. RTEs with a zero checkAsUser and a nonzero one are never merged, even when the calling user equals the nonzero value, “so that SJE doesn’t require having to mark the plan as caller-dependent."[1]

Who was exposed

I could not establish exposure beyond what the dates show, so treat this as an inference. The release tags I read list 18.6 on August 11, 2026, which is before 2ebf25e’s August 28 date, while 19 Beta 4 is dated September 21, after it.[4] That suggests the regression sits in the development branches and the 19 betas, not in a tagged 18.x release. I did not confirm which branches received 2ebf25e on which day, so check your own build’s commit history before relying on this.

If you build from REL_18_STABLE or run a 19 beta, and you combine RLS with views owned by different roles, the sensible reading of the commit is to pick up the fix. Setting enable_self_join_elimination to off would also avoid the code path, which follows from the GUC the regression test toggles, though I did not test it.

The part that generalizes

An optimizer transformation that merges two references needs an equivalence test that covers everything determining what the reference means, not only which relation it names. Here that includes the role, because the role selects the policies. Lane’s fix works because checkAsUser is a cheap, sortable proxy that provably determines the policy set. When you add a planner feature that dedupes, caches, or shares work between references to one table, ask which per-reference attributes it silently assumes are equal.

Sources

[1] Commit message, “Prevent self-join elimination when RTEs’ checkAsUser fields differ”: https://github.com/postgres/postgres/commit/dca6a9e320e0272f0ea8d7e076cda04b06050866 (reported by Yonghwa Lee, reviewed by Ayush Tiwari; discussion at https://postgr.es/m/20260926174457.13.noahmisch@microsoft.com)

[2] Same change on the 18 branch (37c2bc9, backpatched through 18), diff for analyzejoins.c and rowsecurity tests: https://github.com/postgres/postgres/commit/37c2bc9

[3] Commit 2ebf25e, “Perform join removal by editing the query’s jointree”: https://github.com/postgres/postgres/commit/2ebf25e

[4] Release tags: https://github.com/postgres/postgres/tags