Language: English
When RLS Authorization Checks Scaled Proportionally With Row Count
PostgreSQL was evaluating the RLS policy's authorization function once per row, so list queries slowed in proportion to row count. Changing the unit of evaluation from row to SQL statement removed the repetition and delivered a measured ~11x speedup.
On our annotation platform, Row Level Security restricts the rows the database returns, with the Project as the boundary. List-style queries were slow across the board, degrading linearly as the row count grew.
The cause was how the policy was written. It took a shape where PostgreSQL couldn’t evaluate the authorization decision in one shot.
Per-row policy evaluation
The original policy called a function that receives the row’s project_id.
-- Before のイメージ
USING (can_access_project(project_id))
can_access_project runs EXISTS checks against projects and project_members inside.
It’s a function that decides whether this actor can access the given project.
Read as code, it looks perfectly natural.
The problem is the argument.
As long as the argument is the project_id of the row being scanned, the function can receive different input on every row.
PostgreSQL can’t collapse that into a single SubPlan.
As a result, scanning N rows runs the authorization function N times, touching the related tables N times inside it.
Capturing EXPLAIN ANALYZE BUFFERS confirmed the RLS function was executing for every visited row.
List screens read the most rows. Meaning: the screens reading the most rows were repeating the very same authorization decision the most.
Rewriting toward statement-level evaluation
Without changing the authorization condition, we changed the unit of evaluation.
-- After のイメージ
USING (project_id = ANY (accessible_project_ids()))
accessible_project_ids() returns the set of project IDs the actor can access.
It takes no row value as its argument, so it needs evaluating only once per SQL statement.
PostgreSQL can hold the result as a hash set.
What each row does shrinks to checking whether its own project_id is in that set.
Related tables are no longer touched per row; all that remains is membership testing against the hash set.
| Before | After | |
|---|---|---|
| Work per row | can_access_project(project_id) | Hash-set membership check |
| Related-table lookups | projects / project_members per row | Once per SQL statement |
| Unit of evaluation | Row | SQL statement |
Measurement conditions and results
We loaded about 1 million tasks into an isolated PostgreSQL and ran EXPLAIN ANALYZE under both the old style and the new policy, holding the authorization condition identical.
- Old style: 3,463.889 ms
- New policy: 311.042 ms
Same-condition comparison, and roughly an 11.1x difference. An upfront caveat: this is Execution Time taken in an isolated environment, not production endpoint latency.
RLS was not removed. The returned row set is identical—we only rewrote the same authorization condition into a shape PostgreSQL evaluates efficiently.
Transaction boundaries and the verification setup
The improvements around RLS didn’t stop at this single SQL change.
Reads that rely on RLS are confined to a single transaction to keep the authorization scope uniform. RLS depends on the context configured on the connection, so once reads scatter across transactions it becomes hard to trace which context they were evaluated under.
For the SQL candidates, we compared variants—with and without MATERIALIZED, different JOIN arrangements—via EXPLAIN ANALYZE.
Judging speed from what a query looks like is unreliable in this territory.
Verification, too, was prepared with roles split across branch coverage, RLS behavior on the real DB, detection of dangerous code shapes, and actual wall-clock waits. Unit tests passing, the policy genuinely restricting rows, and the whole thing being fast each fail in their own distinct way.
What generalizes
If a function written in an RLS policy takes row values as arguments, it can be evaluated per row. If everything feeding the decision derives from the actor alone, you can remove the row values from the argument list and evaluate once per SQL statement.
And this isn’t unique to RLS. Put a function in a WHERE clause or CHECK constraint, and the moment its arguments include row columns the unit of evaluation becomes the row. Decisions like authorization—where the outcome is fixed once the actor is known—can usually be lifted outside the row.
Based on material presented at the results presentation on July 31, 2026.