Does Postgres RLS actually ruin performance? Let’s look at the data.
Source: Dev.to
The RLS Performance Conundrum
There’s a particular kind of conundrum that has derailed more architectural decisions than I care to count: performance optimization. What do you trade off for what, in which scenario, and what might it achieve? I ran into one of those conundrums recently.
For a multi‑tenant platform I chose PostgreSQL Row‑Level Security (RLS) for tenant isolation—each tenant’s rows are locked behind a policy, enforced at the database level, with no application‑code involvement. Clean, elegant, one less thing to accidentally screw up in your ORM layer. Then I spent a weekend second‑guessing myself.
So I did what you should always do before making an architectural decision: I measured it.
What RLS actually does (the one‑sentence version)
You attach a policy to a table. Every query against that table gets an invisible WHERE clause appended by PostgreSQL, based on who’s asking. A regular app user sees only their rows; a superuser bypasses it entirely. That’s it. The question is whether that invisible WHERE clause costs you anything meaningful at scale.
The Setup
- Data size: 1 million rows in a single table.
- Tenant size: One tenant owns roughly 10 % of them (~100 k rows).
- Benchmark: 50 timed executions per condition, discarding the first three as warm‑up, then measuring p50, p95, and p99 latency. (p95 = 95 % of queries finished faster than this number — your realistic “bad day”, not the theoretical worst case.)
Four test conditions
| Condition | Role | Index? | RLS? |
|---|---|---|---|
| A | superuser | no | bypassed |
| B | app_role | no | active |
| C | superuser | yes | bypassed |
| D | app_role | yes | active (production) |
Query types
- Simple
LIMIT 100fetch. - Filtered scan with a second condition.
- Full
COUNT(*).
These cover the range from “barely touches the table” to “has to read everything”.
Finding 1: RLS overhead is basically noise
Compare A and B. Same hardware, same data, same queries—the only difference is whether RLS policies are being evaluated.

- At p95, A vs. B differ by **)`. That lets the planner push both conditions into the index scan and skip the heap entirely. I didn’t test that here—future post material—but the query plans point directly at it.
What this actually means for you
Three take‑aways:
- Index your
tenant_idcolumn. Not optional. Without it, every aggregation query ends up scanning the whole table. - RLS itself is cheap. The overhead of policy evaluation is negligible compared to the cost of scanning rows you’ll later discard.
- If you have additional filters, consider composite indexes. A single‑column index on
tenant_idhelps most queries, but queries that also filter on another column will benefit from a multi‑column index to avoid heap fetches.
By focusing on the right thing—proper indexing—you get massive performance gains while still enjoying the safety and simplicity of PostgreSQL Row‑Level Security.
Why Row‑Level Security (RLS) Isn’t As Expensive As You Think
-
Use a proper index.
If you create an index that includes thetenant_idcolumn used by the RLS policy, PostgreSQL can apply the policy during index scans instead of scanning the whole table. This reduces the work from “300 k wasted row evaluations” to essentially zero. -
Stop worrying about RLS overhead.
The cost of evaluating a policy is real, but it’s measured in microseconds. The architectural benefits—tenant isolation enforced at the database level and protection against accidental row leaks from missingWHEREclauses—far outweigh a typical ~1.5 ms overhead. -
Watch your secondary predicates.
If your common queries filter on more than justtenant_id, consider a composite index that covers those additional columns. The planner is smart, but it can only use the indexes you provide.
The part where I admit something
I’ll be honest — I already knew the likely outcome before running this. The PostgreSQL community broadly understands that RLS overhead is index‑shaped, not policy‑shaped. But “broadly understood” and “here are actual numbers from a real table at 1 M rows” are different things.
One caveat worth naming: this benchmark tests a simple single‑condition policy—the kind that covers most multi‑tenant SaaS use cases. Complex policies with subqueries or permission‑table joins are a different story, and this experiment does not speak to those scenarios. That’s a follow‑up for another day.
Takeaway: Measure things. Then decide.