<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ashwin Sridhar</title>
    <description>The latest articles on DEV Community by Ashwin Sridhar (@ashwin_sridhar_koto7).</description>
    <link>https://dev.to/ashwin_sridhar_koto7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3957718%2Fe99f0cc3-66c1-4310-ba50-808f356ce7de.png</url>
      <title>DEV Community: Ashwin Sridhar</title>
      <link>https://dev.to/ashwin_sridhar_koto7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashwin_sridhar_koto7"/>
    <language>en</language>
    <item>
      <title>Does Postgres RLS actually ruin performance? Let’s look at the data.</title>
      <dc:creator>Ashwin Sridhar</dc:creator>
      <pubDate>Fri, 29 May 2026 07:45:42 +0000</pubDate>
      <link>https://dev.to/ashwin_sridhar_koto7/does-postgres-rls-actually-ruin-performance-lets-look-at-the-data-24jf</link>
      <guid>https://dev.to/ashwin_sridhar_koto7/does-postgres-rls-actually-ruin-performance-lets-look-at-the-data-24jf</guid>
      <description>&lt;p&gt;There's a particular kind of conundrum that has derailed more architectural decisions than I care to count. You know the one - performance optimization. What do you trade off for what and in what scenario and what might it achieve. I had to sit with one of those conundrums recently. &lt;/p&gt;

&lt;p&gt;For a multi-tenant platform, I'd chosen Postgres Row-Level Security for tenant isolation — each tenant's rows locked behind a policy, enforced at the database level, no application code involved. Clean, elegant, one less thing to accidentally screw up in your ORM layer. Then I spent a weekend second-guessing myself. &lt;/p&gt;

&lt;p&gt;So I did what you should always do before making an architectural decision: I measured it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RLS actually does (the one-sentence version)
&lt;/h2&gt;

&lt;p&gt;You attach a policy to a table. Every query against that table gets an invisible WHERE clause appended by Postgres, based on who's asking. A regular app user asking for rows gets 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;1 million rows in a single table. One tenant owns roughly 10% of them (~100k rows). I ran 50 timed executions per condition, threw away the first 3 as warmup, and measured p50, p95, and p99 latency. (p95 means 95% of your queries finished faster than this number — it's your realistic bad day, not your theoretical worst case.)&lt;/p&gt;

&lt;p&gt;Four conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A — superuser,  no index    → RLS completely bypassed
B — app_role,   no index    → RLS active, planner on its own
C — superuser,  with index  → RLS bypassed, index benefit only
D — app_role,   with index  → RLS active + index (this is production)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three query types: a simple &lt;code&gt;LIMIT 100&lt;/code&gt; fetch, a filtered scan with a second condition, and a full &lt;code&gt;COUNT(*)&lt;/code&gt;. The idea was to cover the range from "barely touches the table" to "has to read everything."&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 1: RLS overhead is basically noise
&lt;/h2&gt;

&lt;p&gt;Compare A and B. Same hardware, same data, same queries — the only difference is whether RLS policies are being evaluated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ywydb7gn9wgltu7gza4.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ywydb7gn9wgltu7gza4.jpeg" alt=" " width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At p95, A vs B differs by less than 2% across every query type. On the &lt;code&gt;count&lt;/code&gt; query — the heaviest one, which scans the entire table — A clocks in at 73.3ms and B at 74.9ms. That 1.6ms is Postgres evaluating your RLS policy. It is, to use a technical term, nothing.&lt;/p&gt;

&lt;p&gt;The performance concern, as it turns out is focussed on the wrong thing. Policy evaluation is not your bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 2: The index is doing all the work
&lt;/h2&gt;

&lt;p&gt;Now look at C and D. Same queries, but I've added an index on &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxc4wextqdrfvxmzzf5hs.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxc4wextqdrfvxmzzf5hs.jpeg" alt=" " width="799" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;count&lt;/code&gt; query drops from ~73ms (A and B, no index) to 2.2ms (C) and 2.7ms (D). That's a &lt;strong&gt;26× speedup&lt;/strong&gt;. RLS overhead within the indexed conditions? Still less than 25%.&lt;/p&gt;

&lt;p&gt;Here's what's happening under the hood. Without the index, Postgres launches a parallel sequential scan — it reads every single row in the table and throws away the ones that don't belong to your tenant. With the index, it goes straight to your tenant's rows and in the &lt;code&gt;COUNT&lt;/code&gt; case, never even visits the heap at all.&lt;/p&gt;

&lt;p&gt;The EXPLAIN plans make this embarrassingly obvious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without index (condition A):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Parallel Seq Scan on jobs
  Filter: (tenant_id = ...)
  Rows Removed by Filter: 299,876
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With index (condition D):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Index Only Scan using idx_jobs_tenant_id
  Index Cond: (tenant_id = ...)
  Heap Fetches: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;299,876 rows examined vs 0 heap fetches. The index doesn't just help — it changes the &lt;em&gt;shape&lt;/em&gt; of the query entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fucfcwl2af7crwuv1i99m.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fucfcwl2af7crwuv1i99m.jpeg" alt=" " width="799" height="396"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The nuance nobody talks about: secondary predicates
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. The filtered query — which adds a second condition on top of the tenant filter — barely improves with the index. About 42ms without, about 40ms with. A 5% improvement where the count query got 26×.&lt;/p&gt;

&lt;p&gt;Why? Because the second predicate forces Postgres to visit the actual heap rows to check the condition. The bitmap index scan narrows the candidate set using the tenant index, but it still has to physically read all ~11,000 heap blocks to evaluate the second filter. You can't index-only scan your way out of a heap fetch.&lt;/p&gt;

&lt;p&gt;The fix, if this matters for you, is a composite index: &lt;code&gt;(tenant_id, your_second_column)&lt;/code&gt;. That lets the planner push both conditions into the index scan and skip the heap entirely. I didn't test that here — that's a follow-up post — but the query plans point directly at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this actually means for you
&lt;/h2&gt;

&lt;p&gt;Three things you can take away from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Index your tenant_id column.&lt;/strong&gt; Not optional. Without it, every aggregation query scans your entire table regardless of how tight your RLS policy is. With it, your database goes from doing 300k wasted row evaluations to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Stop worrying about RLS overhead.&lt;/strong&gt; The policy evaluation cost is real but it's measured in microseconds. The architectural benefits — tenant isolation enforced at the database level, impossible to accidentally leak rows from a missing WHERE clause in your application code — are worth far more than 1.5ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Watch your secondary predicates.&lt;/strong&gt; If your common queries filter on more than just tenant_id, think about whether a composite index makes sense. The planner is smart but it can only use what you give it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part where I admit something
&lt;/h2&gt;

&lt;p&gt;I'll be honest — I already knew the likely outcome before running this. The Postgres 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 1M rows" are different things.&lt;/p&gt;

&lt;p&gt;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 not one this experiment speaks to. That's a follow-up for another day.&lt;/p&gt;

&lt;p&gt;Measure things. Then decide.&lt;/p&gt;




</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>rls</category>
    </item>
  </channel>
</rss>
