Does Postgres RLS actually ruin performance? Let’s look at the data.

Published: (May 29, 2026 at 03:45 AM EDT)
4 min read
Source: Dev.to

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

ConditionRoleIndex?RLS?
Asuperusernobypassed
Bapp_rolenoactive
Csuperuseryesbypassed
Dapp_roleyesactive (production)

Query types

  1. Simple LIMIT 100 fetch.
  2. Filtered scan with a second condition.
  3. 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.

RLS overhead vs. no RLS (p95 latency)

  • 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:

  1. Index your tenant_id column. Not optional. Without it, every aggregation query ends up scanning the whole table.
  2. RLS itself is cheap. The overhead of policy evaluation is negligible compared to the cost of scanning rows you’ll later discard.
  3. If you have additional filters, consider composite indexes. A single‑column index on tenant_id helps 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

  1. Use a proper index.
    If you create an index that includes the tenant_id column 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.

  2. 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 missing WHERE clauses—far outweigh a typical ~1.5 ms overhead.

  3. Watch your secondary predicates.
    If your common queries filter on more than just tenant_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.

0 views
Back to Blog

Related posts

Read more »

Just Use Postgres for Durable Workflows

markdown Durable Workflows: A Simpler Approach Using the Database as Orchestrator Durable workflows are a simple but powerful tool for building reliable program...

PostgreSQL 01008 오류 원인과 해결 방법 완벽 가이드

PostgreSQL 에러 코드 01008은 WARNING: implicit zero bit padding 경고로, 비트 문자열BIT/BIT VARYING 데이터를 다룰 때 지정된 길이보다 짧은 값이 입력되면 PostgreSQL이 자동으로 오른쪽에 0 비트를 채워 넣는 상황에서 발생합니다...

Warm up your MacBook (2019)

How to Warm Up Your MacBook Quickly You’ve been there – after a cold commute, you sit down, place your palms on the keyboard, and feel the metal drain the heat...