Trending Posts

Diagram comparing a slow sequential scan to a fast indexed query plan for Supabase RLS performance

A Supabase query that returns in single digit milliseconds during development can suddenly take over a second once real production data lands in the table. Nothing in the schema changed, and the row level security (RLS) policy protecting that table is still logically correct. What changed is scale, and RLS has a well documented tendency to punish scale if the policy wasn't written with the Postgres query planner in mind.

This guide breaks down exactly why Supabase RLS performance degrades as tables grow, what the query planner is actually doing differently between a fast policy and a slow one, and the specific fixes that close the gap. It's worth reading our complete guide to multi-tenant schema design in Supabase first, since most of the slow policies covered here show up specifically in multi-tenant, team-based architectures.

This is written for engineers who already have RLS enabled and working, not people looking for a first RLS tutorial. If your policies pass tests locally but time out in production, or query time keeps creeping up as your tenant count grows, keep reading.

Quick Answer: Supabase RLS performance collapses when policy functions like auth.uid() get re-evaluated on every row instead of once per query, turning an indexed lookup into a full sequential scan. Wrapping the call as (select auth.uid()), indexing the columns your policies filter on, and marking helper functions STABLE can take a query from hundreds of milliseconds down to under 1 millisecond on the same table.

Table of Contents

  1. Why Does Supabase RLS Get Slower as Data Grows?
  2. InitPlan vs SubPlan: The Postgres Internals Nobody Explains
  3. How Do You Diagnose Which Part of Your Policy Is Slow?
  4. Fix #1: Wrap auth.uid() in a Scalar Subquery
  5. Fix #2: Index Every Column Your Policies Actually Filter On
  6. Fix #3: Mark Custom RLS Helper Functions STABLE
  7. Fix #4: Repeat Your Filters at the Application Level
  8. Fix #5: Move Expensive Joins Into SECURITY DEFINER Functions
  9. Fix #6: Replace Role Joins With JWT Custom Claims
  10. Why Do Views Get Slower Under RLS?
  11. Quick Answers About Supabase RLS Performance
  12. Frequently Asked Questions

Why Does Supabase RLS Get Slower as Data Grows?

Simply put, RLS gets slower because Postgres has to evaluate your policy's condition, not just your query's WHERE clause, on every row a scan touches. If that policy calls a function like auth.uid() without help, Postgres can end up re-running that function once per row instead of once per query.

At 100 rows this is invisible. At 100,000 rows it starts showing up in your logs. At a million rows, it can turn a query that should take under a millisecond into one that times out entirely. Supabase's own RLS performance and best practices guide documents this exact pattern: a sequential scan behind an unindexed, unwrapped policy check running at roughly 171 milliseconds, next to the same query completing in about 0.046 milliseconds once it's rewritten to use an index and a cached function call.

The mechanism behind that gap is what most Supabase content skips entirely: the difference between an InitPlan and a SubPlan.

InitPlan vs SubPlan: The Postgres Internals Nobody Explains

Every piece of Supabase content that tells you to wrap auth.uid() in a subquery is giving correct advice without explaining why it works, and that gap is exactly where engineers get stuck the first time a "fix" doesn't behave the way they expected.

When Postgres evaluates a function directly inside a WHERE clause, which is effectively what an RLS policy is, it has two options depending on how confident the planner is that the function's result won't change mid-query:

  • SubPlan: the function is treated as something that might return a different result for every row, so Postgres re-executes it per row scanned. On a 100,000 row table, that's 100,000 separate evaluations.
  • InitPlan: the function is evaluated exactly once, before the main scan starts, and the single result is cached in memory for the rest of the query.

Wrapping the call in a scalar subquery, (select auth.uid()), is what nudges Postgres toward treating it as an InitPlan instead of a SubPlan. One publicly documented EXPLAIN (ANALYZE, BUFFERS) breakdown showed a SubPlan node looping 99,950 times to return just 50 matching rows, adding roughly 171 milliseconds of pure per-row function overhead. That's the InitPlan/SubPlan distinction made visible in a real execution plan, not a theoretical concern.


EXPLAIN ANALYZE output showing a SubPlan node looping per row in a slow Supabase RLS query

This is also why function volatility matters so much, which is covered in the fixes below.

How Do You Diagnose Which Part of Your Policy Is Slow?

Before applying any fix, confirm what's actually slow. Run EXPLAIN (ANALYZE, BUFFERS) on the query while authenticated as the role your app actually uses. RLS behaves differently for the anon role, the authenticated role, and the service role, so testing as postgres or service_role will hide the problem entirely.

Two signals tell you almost everything:

  • A Seq Scan node where you expected an Index Scan usually means the column your policy filters on isn't indexed, or the planner couldn't use the index because of how the policy is written.
  • A high Rows Removed by Filter count next to a SubPlan node means a policy function is being re-evaluated per row instead of cached.

A full walkthrough of reading an EXPLAIN ANALYZE plan line by line is planned as a dedicated diagnostic guide (internal link pending, not yet published), but these two checks alone will point you at the right fix in most cases.

Fix #1: Wrap auth.uid() in a Scalar Subquery

The fix: replace any RLS policy calling auth.uid(), auth.jwt(), or a custom helper function directly with the same call wrapped in a scalar subquery.

-- Before: evaluated as a SubPlan, re-run for every row scanned
create policy "select_own_rows"
on public.orders
for select
using (auth.uid() = user_id);

-- After: evaluated once as an InitPlan, cached for the whole query
create policy "select_own_rows"
on public.orders
for select
using ((select auth.uid()) = user_id);

This single change is responsible for the majority of the improvement in every published Supabase RLS benchmark, and it costs nothing. The policy's logic is identical; only the execution plan changes.

Fix #2: Index Every Column Your Policies Actually Filter On

The fix: add a standard B-Tree index on every column your RLS policies reference directly, most commonly a tenant_id, user_id, or organization_id foreign key.

create index if not exists idx_orders_user_id
on public.orders (user_id);

Supabase's troubleshooting documentation includes a benchmark scaling a policy from 10 to 500 teams: the unindexed, unwrapped version times out after roughly two minutes at scale, while the indexed and wrapped version returns in about 3 milliseconds on the same table. The company's core RLS documentation reports a similar case, a roughly 99.94% drop in execution time, from about 171 milliseconds down to under 0.1 milliseconds, from indexing alone.


Before and after EXPLAIN ANALYZE output showing a sequential scan replaced by an index scan after adding a B-Tree index

An index without the InitPlan fix above still helps, but the two together produce the dramatic drop. Skipping either one leaves real performance on the table. Composite indexing strategies for policies that filter on more than one column are covered in more depth in our follow-up guide to the tenant_id IN() fix.

Fix #3: Mark Custom RLS Helper Functions STABLE

The fix: any custom Postgres function you write and reference inside an RLS policy, for example a helper that checks team membership, should be explicitly declared STABLE unless it genuinely needs to be VOLATILE.

Postgres creates every new function as VOLATILE by default. A VOLATILE function tells the planner its result might change from one row to the next within the same statement, which blocks caching, blocks pushing the function into an index scan, and forces the per-row SubPlan behavior described above, regardless of any indexes on the underlying table.

create or replace function private.is_team_member(team_id uuid)
returns boolean
language sql
security definer
stable  -- tells the planner this result is safe to cache per statement
as $$
  select exists (
    select 1 from public.team_members
    where team_members.team_id = is_team_member.team_id
    and team_members.user_id = (select auth.uid())
  );
$$;

Marking the function STABLE promises Postgres it won't change output during a single table scan, which is what lets the planner cache the result instead of running the membership check for every row. A dedicated comparison of STABLE, VOLATILE, and IMMUTABLE for RLS specifically is coming as its own article (internal link pending, not yet published).

Fix #4: Repeat Your Filters at the Application Level

The fix: don't rely on RLS alone to filter your results. Add the same filter explicitly in your application query, even though the policy already restricts the same rows.

// RLS already restricts this to the current user's rows,
// but adding .eq() explicitly still helps the planner
const { data } = await supabase
  .from('orders')
  .select('*')
  .eq('user_id', userId)
  .limit(20);

This feels redundant, since the policy is already enforcing the same restriction, but RLS is applied as an implicit condition late in query planning. An explicit filter in the query itself lets the planner discard entire blocks of data earlier, before RLS evaluation even runs. One widely referenced case in the Supabase community reported a roughly 94.74% improvement from this change alone, layered on top of the InitPlan and indexing fixes.

This matters even more for pagination. Queries using limit and offset generally need the full sort order determined before the limit is applied, and RLS gets evaluated against that entire sorted set first. Without an explicit filter narrowing the scan early, pagination on a large multi-tenant table can get progressively slower with every row added, even though each page only returns 20 or 50 records. It's also worth remembering that RLS tuning alone won't fix an N+1 query pattern hiding in your application code. A community-reported case found a single 2.7 millisecond query executing 412,000 times in a loop; no policy fix touches that, only application-level batching does.

Fix #5: Move Expensive Joins Into SECURITY DEFINER Functions

The fix: when a policy needs to check a complex relationship, like whether a user belongs to a team that owns a resource through two or three joined tables, move that check into a SECURITY DEFINER function instead of writing the joins directly into the policy.

A SECURITY DEFINER function runs with the privileges of the function's owner rather than the calling user, which lets Postgres optimize the join logic once inside the function body instead of re-planning it as part of every row's policy check. Case studies in the Supabase ecosystem have documented complex multi-join policies dropping from roughly 178 seconds down to about 12 milliseconds after being restructured this way, though results at that scale are naturally workload-specific.

Two things matter for doing this safely, and both are routinely skipped:

  • Pin the search_path explicitly inside every SECURITY DEFINER function, for example set search_path = public, auth. Functions running with elevated privileges that don't pin their search path are a well-documented privilege escalation vector, since a malicious user could otherwise create objects in another schema that shadow the tables the function expects to find.
  • Wrap auth calls inside the function body too. Supabase's own linter, auth_rls_initplan, only checks the top-level expression of a policy. A bare auth.uid() call placed inside a SECURITY DEFINER function's body will pass the linter as clean while Postgres still evaluates it per row underneath. Wrap it the same way you would in the policy itself.

Store these functions in a dedicated schema like private, not public, so they can't be called directly by anything outside your database logic. This kind of explicit, auditable authorization function is also useful evidence for teams tracking SOC2 or similar access-control controls.

For a full worked example of this pattern, a normalized roles and permissions schema behind a single cached has_permission() function, see our guide to setting up hierarchical Postgres RBAC for B2B SaaS.

Fix #6: Replace Role Joins With JWT Custom Claims

The fix: for role or permission checks that don't need to hit the database at all, inject the role directly into the user's JWT using Supabase's Custom Access Token Hook, and check the claim instead of joining a roles table.

-- Instead of joining a user_roles table on every row:
create policy "admin_only"
on public.settings
for update
using (
  (select auth.jwt() -> 'app_metadata' ->> 'user_role') = 'admin'
);

Checking a value already embedded in the JWT payload requires zero database reads during policy evaluation, since the token itself carries the answer. That turns what would otherwise be a relational lookup on every row into a constant-time JSON boundary check. The tradeoff is that a role change won't take effect until the user's token refreshes, so this pattern fits roles and permissions that don't need to update instantly, not fast-changing state.

We cover the full hook setup, multi-tenant claims, and the revocation fix for that staleness problem in our dedicated guide to custom JWT claims for RLS.

Why Do Views Get Slower Under RLS?

Simply put, a Postgres view created with security_barrier stops the planner from pushing filters down into the underlying table the way it normally would, which can silently undo every other fix on this page if you're querying through a view instead of the table directly.

Security barrier views exist to stop a malicious function from leaking data through side effects, like a function that only throws an error for rows matching a specific value. To close that hole, Postgres refuses to push WHERE clauses or use certain indexes through the view unless the functions and operators involved are explicitly marked LEAKPROOF. Since most custom functions aren't marked leakproof, the practical effect is that the entire RLS policy gets evaluated against the full underlying table before any fast, targeted filter applies, exactly the sequential scan problem the fixes above are meant to avoid.


Diagram comparing how a security_barrier view blocks filter pushdown versus querying the underlying table directly

If you're layering views on top of RLS-protected tables for convenience, check whether they actually need security_barrier before assuming a slow query is a policy problem rather than a view problem, per PostgreSQL's own documentation on rules and privileges.

Quick Answers About Supabase RLS Performance

What Causes Slow Supabase RLS Queries?

Simply put, Supabase RLS gets slow when a policy function is re-evaluated once per row instead of once per query. This usually happens because auth.uid() or a custom helper function isn't wrapped in a scalar subquery, isn't marked STABLE, or the column the policy filters on has no index. All three problems compound on large tables and stay invisible on the small datasets used in local development.

Supabase RLS Performance at a Glance

SymptomRoot CauseFix
High execution time, low planning timeA SubPlan node looping once per rowWrap auth.uid() in a scalar subquery: (select auth.uid())
Large "Rows Removed by Filter" countSequential scan instead of an index scanAdd a B-Tree index on the column the policy filters on
A helper function is the bottleneck in the planThe function defaults to VOLATILE and can't be cachedExplicitly declare the function STABLE
Direct queries are fast, pagination is slowRLS evaluated against the full result set before the limit is appliedAdd explicit application-level filters before .limit()
Multi-table authorization checks time outExpensive joins re-run inside the policy on every rowMove the check into a STABLE SECURITY DEFINER function
A view is slower than querying the table directlysecurity_barrier blocking filter pushdownConfirm the view needs security_barrier, or mark operators LEAKPROOF

When Does This Apply?

This applies to any Supabase table with RLS enabled that has grown past roughly 10,000 to 100,000 rows, especially multi-tenant tables filtered by tenant_id, user_id, or a team membership check. Small internal tools with a few hundred rows generally won't notice the difference either way.

Frequently Asked Questions

Why is auth.uid() slow in Supabase RLS policies?

By default, an unwrapped auth.uid() call inside a policy is treated as a SubPlan and re-evaluated once per row scanned. On a 100,000 row table, that's 100,000 separate evaluations. Wrapping it as (select auth.uid()) lets Postgres cache the result once per query instead.

Does RLS slow down inserts and updates too, not just reads?

Yes. An INSERT only triggers the WITH CHECK clause once per new row. An UPDATE triggers both the USING clause, to confirm access to the existing row, and WITH CHECK, to validate the new data, roughly doubling the function overhead on write-heavy tables.

How do you scope Supabase RLS to a team efficiently?

For teams that don't change often, push the team ID into the user's JWT via a Custom Access Token Hook and check it directly in the policy. For teams that change frequently, use a STABLE SECURITY DEFINER function backed by an indexed membership table instead of an inline join.

Can you turn off RLS to benchmark a slow query?

Not on the fly through a normal client call. You can bypass it using the service role key on a secured backend, or disable it temporarily as an admin in a non-production environment, but disabling RLS in production to "fix" performance also removes your row-level authorization entirely.

Why is my Supabase query slow even though the table is empty?

If a query is slow on an empty or near-empty table, the RLS policy usually isn't the cause. Check for cold starts, connection pooling overhead, or a mismatch between your app's region and your database's region before assuming it's a policy problem.

Why does pagination get slower as a table grows, even with RLS optimized?

Queries using limit and offset typically need the full result set sorted before the limit is applied, and RLS is checked against that entire set. Add explicit application-level filters, and consider keyset pagination instead of offset-based pagination on large tables.

Should I just disable RLS and filter permissions in my backend instead?

You can, but you're trading a database-enforced security boundary for an application-enforced one, meaning every query path in your codebase has to remember to filter correctly. For most teams, fixing the policy is safer than removing the safeguard entirely.

What's the difference between STABLE and VOLATILE for a Postgres function?

A VOLATILE function, the default, tells the planner its result might change between rows in the same statement, blocking caching. A STABLE function promises the result won't change during a single scan, letting Postgres cache and optimize around it.

Most of what makes Supabase RLS performance collapse under real data comes down to a handful of repeatable patterns: an auth call evaluated per row instead of once, a missing index on the column a policy actually filters on, a helper function left at its VOLATILE default, and joins that could have lived inside a cached SECURITY DEFINER function instead of running fresh on every row. None of these require weakening your security model. Every fix here keeps the same authorization logic, just executed the way the Postgres planner can actually optimize.

That said, a fast policy and a secure one are two different audits. Wrapping auth.uid() and adding the right index doesn't tell you whether an UPDATE policy is missing its WITH CHECK clause, or whether a view sitting on top of these same tables is quietly bypassing RLS through owner permissions. 5 Silent RLS Mistakes That Leave Your Database Exposed covers five separate, silent ways a technically fast, technically passing RLS setup still leaks tenant data.

If you're setting up RLS on a new multi-tenant schema rather than fixing an existing one, it's worth going back to our guide to multi-tenant database design in Supabase to make sure the schema itself supports these patterns from day one, rather than retrofitting indexes and STABLE functions after the table's already grown past the point where it's comfortable to test against. The next article in this series digs into the tenant_id IN() pattern specifically.

For the full picture of how these six fixes work together instead of in isolation, including the security-barrier mechanics and buffer cache cost this post doesn't cover, see our three-pillar breakdown of RLS query optimization.

Run EXPLAIN (ANALYZE, BUFFERS) against your slowest RLS-protected query today. Whatever the plan shows, one of the fixes above is almost certainly the answer.