Supabase Row Level Security Policy Performance: Slow Query pg_explain Analysis

RLS looked harmless on the dashboard. Then a production query went from 4ms to 1,800ms overnight — and pg_explain showed exactly why.
Supabase row level security policy performance is one of the most misunderstood bottlenecks in modern Postgres deployments. By April 2026, Supabase reports over 1 million projects on its platform (Supabase Blog, 2025). That’s a lot of teams enabling RLS, assuming it’s free, and then debugging slow queries six months later.
The problem isn’t RLS itself. It’s how Postgres evaluates RLS policies at query time — and why the planner sometimes can’t see through them efficiently. This analysis breaks down what pg_explain actually reveals about slow query patterns tied to Supabase row level security policies, and what you should do about it.
Key Takeaways
- Supabase RLS policies are evaluated as additional
WHEREclause predicates by the Postgres query planner — non-indexed policy columns cause sequential scans regardless of your other indexes.- Running
EXPLAIN (ANALYZE, BUFFERS)on RLS-affected queries reveals hidden filter steps that vanish when policies are bypassed withSET role = service_role.- An
auth.uid()function call inside a policy triggers a per-row context lookup unless Postgres can hoist it — which it often can’t without explicit index support.- Teams using
security definerwrapper functions aroundauth.uid()report 40–60% query time reductions on high-traffic tables, according to community benchmarks on the Supabase GitHub Discussions board (2025).- The most reliable diagnostic path is comparing
EXPLAINoutput with and without the policy active, then checking buffer hit ratios and filter row counts.
The Hidden Cost of “Just Enable RLS”
Supabase makes enabling row level security trivially easy. One toggle in the dashboard, one ALTER TABLE ... ENABLE ROW LEVEL SECURITY;, done. What the docs don’t foreground is that every query against that table now carries the policy’s logic as an appended predicate.
Postgres doesn’t magically optimize this. It evaluates the policy expression alongside your query’s WHERE clause. If that expression calls auth.uid() — which is how almost every Supabase multi-tenant policy works — the planner has to resolve the current user’s JWT-extracted ID on every execution context.
Supabase’s own documentation notes that RLS policies “are applied before any other conditions,” meaning they’re injected into the query before your application-level filters. This matters enormously for index selection. A policy like:
CREATE POLICY "Users see own rows"
ON messages
FOR SELECT
USING (user_id = auth.uid());
…looks innocent. But if user_id lacks an index, or if the planner estimates that filtering by auth.uid() will return a large fraction of rows, it may skip your index entirely and scan the table. Slow query. Confused developer.
What pg_explain Actually Shows You
EXPLAIN (ANALYZE, BUFFERS) is the right tool. Not EXPLAIN alone — you need actual execution stats.
Run this as the authenticated role hitting the policy:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM messages WHERE thread_id = 42;
Then run the same query as service_role, which bypasses RLS entirely:
SET role = service_role;
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM messages WHERE thread_id = 42;
Compare the two outputs. Specifically, look for:
- Filter rows — how many rows Postgres touched before the policy rejected them
- Seq Scan vs Index Scan — whether the planner chose a full table scan due to policy cardinality estimates
- Buffers hit/read ratio — high
readcounts mean you’re going to disk, not cache - Rows Removed by Filter — this number tells you policy overhead directly
A real-world pattern: a messages table with 500,000 rows, a policy filtering by user_id = auth.uid(), and no index on user_id. The non-RLS query returns in 3ms via an index on thread_id. The RLS query takes 340ms because the planner estimates auth.uid() returns roughly 30% of rows and abandons the index. The pg_explain output shows Rows Removed by Filter: 498,213. That’s a full scan with post-filter rejection — and it scales directly with table size.
Diagnosing and Fixing the Slow Query Patterns
The auth.uid() Stability Problem
auth.uid() is a STABLE function in Supabase — meaning Postgres knows it returns the same value within a single query, but it doesn’t treat it as a constant the planner can inline at parse time. The planner can’t always use it for index range estimation.
The fix that works: wrap auth.uid() in a security definer function that returns a typed constant:
CREATE OR REPLACE FUNCTION my_user_id()
RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER
AS $$ SELECT auth.uid() $$;
Rewrite the policy using my_user_id(). This gives the planner a stable, inlinable expression in many cases. Multiple teams in Supabase’s GitHub Discussions (thread #12847, March 2025) reported 40–60% latency improvements on tables above 100k rows using this exact approach. It’s not guaranteed — but on high-traffic tables, it’s worth testing before you reach for more invasive solutions.
Index Strategy for Policy Columns
Every column referenced in a USING or WITH CHECK policy expression needs an index — specifically a B-tree index for equality checks. For multi-tenant setups:
CREATE INDEX idx_messages_user_id ON messages(user_id);
For more selective queries, a composite index often outperforms a single-column one:
CREATE INDEX idx_messages_user_thread ON messages(user_id, thread_id);
The second form lets Postgres satisfy both the policy filter and the application query filter in a single index scan. That’s two problems solved with one index.
Comparison: RLS Policy Patterns and Their Performance Impact
| Pattern | Seq Scan Risk | Index-Friendly | Planner Estimability | Best For |
|---|---|---|---|---|
user_id = auth.uid() (no index) | High | No | Poor | Never — add an index |
user_id = auth.uid() (indexed) | Low | Yes | Moderate | Standard multi-tenant |
user_id = my_user_id() (SDEF wrapper, indexed) | Low | Yes | Better | High-traffic tables |
EXISTS (SELECT 1 FROM orgs WHERE ...) subquery | Very High | Rarely | Poor | Avoid on large tables |
true (public read policy) | None | N/A | Excellent | Public data tables |
The EXISTS subquery pattern is the worst offender. It fires a correlated subquery per row, and pg_explain shows it as a Nested Loop with a filter applied at every step. On a 200k-row table, that can mean 200,000 subquery executions per query. Don’t use it without a covering index on the subquery’s join column — and even then, test it under realistic load before trusting it in production.
What to Do Right Now
If you’re debugging a slow query today:
Run the side-by-side EXPLAIN (ANALYZE, BUFFERS) comparison — RLS active versus service_role. If the plans diverge, the policy is the bottleneck, not your application query.
Check Rows Removed by Filter in the RLS output. Anything above 10x your result set size means the policy is forcing a scan. Fix the index first, then evaluate the security definer wrapper if latency is still high after indexing.
If you’re designing a new schema:
Index every column that appears in a policy expression before enabling RLS. Test with realistic row counts — 10,000 rows won’t surface the problem that 500,000 rows will. Supabase’s own performance recommendations (Supabase Docs, 2026) suggest treating policy expressions as part of your query’s WHERE clause from day one of schema design. That framing alone changes how most teams approach their index strategy.
For ongoing monitoring:
Supabase’s dashboard exposes slow query logs via the pg_stat_statements extension. Filter for queries on RLS-enabled tables with mean_exec_time > 100ms. That list is your optimization backlog — work through it table by table, starting with the highest-traffic queries.
The Bottom Line
RLS performance problems aren’t mysterious. pg_explain shows you exactly what’s happening — extra filter steps, sequential scans, correlated subqueries executing per row. The diagnostic workflow is straightforward: compare plans with and without RLS active, read the filter row counts, fix the index, and consider the security definer wrapper for auth.uid() calls on large tables.
The teams that struggle aren’t using the wrong tools. They’re skipping the diagnostic step and guessing at fixes. One EXPLAIN (ANALYZE, BUFFERS) comparison takes thirty seconds and eliminates ninety percent of the guesswork.
What does your output actually show on your heaviest RLS table? That answer determines everything else.
References
- Supabase. Row Level Security. Supabase Docs, 2026. https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase GitHub Discussions, Thread #12847: RLS performance with auth.uid() on large tables, March 2025.
- PostgreSQL Global Development Group. EXPLAIN Documentation, PostgreSQL 16. https://www.postgresql.org/docs/current/sql-explain.html
- Supabase Blog. One Million Projects, 2025. https://supabase.com/blog
References
Photo by Drew Williams on Unsplash


