Debugging Postgres Performance Under Row-Level Security

zhixuan1 pts0 comments

Debugging Postgres performance under Row-Level Security | HOAi ,<br>so there is no FOUC and no inline theme script is needed. -->

Skip to content

Go back<br>Debugging Postgres performance under Row-Level Security<br>Zhixuan Lai | August 12, 2026<br>In Postgres, Row-Level Security (RLS) controls which rows a user can see and modify. You attach RLS policies to tables, and Postgres evaluates those policies whenever the table is accessed.

For example:

create policy "Users can view their own documents."<br>on documents for select<br>using ( (select auth.uid()) = user_id );<br>Conceptually, you can think of an RLS policy as an extra WHERE clause that Postgres applies to your query.

-- You write<br>select *<br>from documents;

-- Conceptually, Postgres evaluates<br>select *<br>from documents<br>where auth.uid() = documents.user_id;<br>Because these predicates are evaluated as part of every table access, a poorly written policy can quietly become very expensive.

We recently learned this the hard way.

Every morning, one of our production databases climbed above 80% CPU. A single RLS policy turned out to be responsible for most of it. But it took us two days to find because the tools we normally use to debug Postgres performance were showing us a different query from the one production was actually running.

After rewriting the policy, peak CPU dropped from around 80% to 16%.

The misleading EXPLAIN

We started where you would expect: pg_stat_statements. Our top query accounted for 74.1% of all execution time in the database. So we took that exact query and ran EXPLAIN. The plan was efficient and the query was fast. We also checked connection pools and indexes. There was a clear gap between what we measured locally and what we saw in production, but we couldn’t explain it.

Eventually, we realized the problem: Our EXPLAIN wasn’t running under the same security context as the production query.

We were debugging with a privileged readonly role. Postgres skips RLS for superusers, table owners, and roles with BYPASSRLS. Our readonly role happened to have BYPASSRLS = true. So the query plan we were inspecting didn’t contain the same RLS predicates that production was evaluating.

Inlining the policy

Once we realized RLS was missing from our measurements, we wanted to isolate its cost.

We took the SQL expressions from the relevant policies, manually added them as WHERE clauses, and compared the query with and without them.

Result sizeOriginal queryWith RLS inlinedDelta225.1 ms101.4 ms+76.4 ms1548.7 ms121.8 ms+73.1 ms4563.9 ms138.2 ms+74.3 ms88101.5 ms173.0 ms+71.5 ms<br>The result was hard to miss: adding the policy predicates cost roughly 70–76 ms per query in these tests.

Interestingly, the overhead barely changed with the number of rows returned. That was another hint that output size wasn’t the important variable. The expensive work was happening while Postgres evaluated candidate rows inside the query plan, before the final result set was produced.

Now we knew where to look.

Root cause

Several of our policies called auth.jwt(), which reads and parses JWT claims from a session variable.

A simplified version looked like this:

create policy "Admins can view all documents."<br>on documents for select<br>using ( auth.jwt() -> 'app_metadata' ->> 'role' = 'admin' );<br>In a RLS predicate, the planner re-evaluates the function for every candidate row in the scan. A query that returns 1,000 documents parses the same JWT blob ~1,000 times. This is CPU intensive.

Joins make it worse. If every table in a query has its own RLS policy calling auth.jwt(), the function runs for every candidate row in every table’s scan.

select<br>d.title,<br>c.name as category,<br>u.email as author,<br>count(v.id) as view_count<br>from documents d<br>join categories c on c.id = d.category_id<br>join users u on u.id = d.author_id<br>join views v on v.document_id = d.id<br>where d.org_id = 'acme'<br>group by d.id, c.name, u.email;<br>If above query touches 1,000 documents, 50 categories, 200 users, and 50,000 view records, the database would parse the same JWT token 51,250 times.

Rewriting the policy

Because the JWT token doesn’t change mid-statement, we only need to check the user’s role once per statement. To do that, we wrap the function call in a scalar subquery. This tells Postgres to create an InitPlan that evaluates the function once per statement instead of once per row. In other words, the original cost O(rows) becomes O(1).

create policy "Admins can view all documents."<br>on documents for select<br>using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );<br>After we applied this optimization to 21 policies across 11 tables, our peak CPU went from ~80% to ~16%.

Profiling RLS in practice

Before you trust an EXPLAIN query plan, check what database role you’re using. Some roles skip RLS silently. If your EXPLAIN shows an efficient plan but production query is slow, you might be looking at a different query than what production is running.

To measure the cost of an RLS policy, you can inline its SQL...

query policy documents postgres from select

Related Articles