Supabase Postgres RLS Performance Impact at 100K Rows

RLS sounds safe. It can also quietly destroy your query performance. At 100K rows, the difference between a well-tuned and a poorly-configured Row Level Security policy isn’t milliseconds — it’s seconds.
Supabase has exploded in adoption among production teams building on Postgres. As of Q1 2026, Supabase reports over 1 million databases on its platform. Most of those teams enable RLS on day one because the docs say to. Far fewer understand what enabling RLS actually costs at scale — and what specific patterns push that cost to unacceptable levels.
The thesis is simple: RLS itself doesn’t kill performance. Bad policy design does. And the Supabase Postgres row level security performance impact at 100K rows is where you start seeing the real cost clearly enough to act on.
Key Takeaways
- Enabling RLS on a Postgres table with 100K rows causes negligible overhead when policies reference indexed columns — benchmarks show query times under 5ms in this configuration.
- Unindexed
auth.uid()comparisons in RLS policies can increase query execution time by 10–40x at 100K row scale, according to community benchmarks shared on the Supabase GitHub Discussions board (2025).- Wrapping RLS policies in
security definerfunctions eliminates repeated permission checks, reducing planning overhead on complex policy trees.- Supabase’s built-in
auth.uid()function carries a per-row evaluation cost — a(select auth.uid())subquery pattern executes the function once per query instead of once per row, a critical optimization at scale.- Teams running 100K+ row tables without policy indexes are trading security convenience for production latency they haven’t measured yet.
Background: Why RLS Performance Became a Real Concern in 2026
Row Level Security is a native Postgres feature. It’s been in the core engine since Postgres 9.5, released in 2016. Supabase didn’t invent it — they made it the default security model for every project and wired it directly to their Auth layer.
That integration is genuinely elegant. When a user authenticates, Supabase sets a JWT-derived session variable. RLS policies read that variable via auth.uid() and filter rows accordingly. Your application code never manually filters by user ID again. The security boundary lives in the database.
The problem didn’t surface loudly until teams started hitting scale. At 1K rows, no policy is slow. At 10K rows, most policies still feel fine. At 100K rows, the evaluation model matters enormously.
Two factors converged in 2025 to make this urgent. First, Supabase’s free tier became generous enough that projects scaled further before teams optimized infrastructure. Second, the Postgres query planner evaluates RLS policies differently than regular WHERE clauses — the policy expression gets appended to every query, but it doesn’t always get the benefit of standard index planning.
A thread on Supabase’s GitHub Discussions in late 2025 surfaced this concretely: a developer reported query times climbing from ~3ms to ~180ms after a table crossed 80K rows. The table had RLS enabled. The policy was auth.uid() = user_id. No index on user_id. That single missing index explained the entire regression.
Main Analysis
The Core Mechanism: How Postgres Evaluates RLS Policies
Every SELECT, INSERT, UPDATE, and DELETE on an RLS-enabled table runs the policy expression as an implicit filter. Postgres appends it as a WHERE clause internally. The query planner sees it — but policy expressions aren’t always parsed identically to explicit WHERE conditions you’d write yourself.
The auth.uid() function in Supabase returns the UUID of the authenticated user from the current session. By default, Postgres evaluates this function once per row when used directly inside a policy. A table scan across 100K rows means auth.uid() gets called 100,000 times.
The fix is one line. Change:
CREATE POLICY "user_data" ON profiles
USING (auth.uid() = user_id);
to:
CREATE POLICY "user_data" ON profiles
USING ((SELECT auth.uid()) = user_id);
The subquery wrapping forces Postgres to evaluate auth.uid() exactly once, cache the result, and compare it against each row. According to Supabase’s official RLS documentation, this is the recommended pattern for any policy using session-based lookups. The performance difference at 100K rows is measurable in the tens of milliseconds — sometimes more, depending on server load.
The Index Question at 100K Rows
RLS policies don’t automatically create indexes. That’s your job, and most teams miss it early.
When a policy uses auth.uid() = user_id, Postgres needs to evaluate that condition against every row unless it can use an index on user_id to skip rows early. Without the index, it’s a sequential scan. At 100K rows, a sequential scan on a mid-tier Supabase instance — 4GB RAM, shared CPU — runs roughly 40–80ms depending on row width, according to community-published benchmarks on the Supabase Discord (December 2025).
Add a standard B-tree index:
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
Query time on the same table drops to 1–4ms. That’s a 20–40x improvement. The Supabase Postgres row level security performance impact at 100K rows consistently shows this pattern across different table types — the index is not optional at this scale.
Multi-Policy Complexity and Planning Overhead
Single-policy tables are easy. Production apps aren’t single-policy.
A typical SaaS schema might have three policies on one table: one for users to read their own data, one for admins to read all data, and one for a service role bypass. Each policy adds an OR branch to the implicit WHERE clause. Postgres evaluates all branches during planning.
At 100K rows with three policies, the planning phase alone can consume 5–15ms before execution starts. Wrapping policies in security definer functions lets Postgres cache the execution plan across calls:
CREATE FUNCTION user_can_access(row_user_id uuid)
RETURNS boolean
LANGUAGE sql SECURITY DEFINER AS $$
SELECT (SELECT auth.uid()) = row_user_id;
$$;
Then reference the function in the policy. This reduces repeated planning overhead on complex policy trees and matters most when connection pools are handling hundreds of concurrent queries.
Comparison: RLS Configuration Patterns at 100K Rows
| Configuration | Avg Query Time (100K rows) | Index Required | Complexity |
|---|---|---|---|
| RLS disabled | ~2ms | No | Low |
RLS + unindexed auth.uid() (per-row) | ~120–180ms | No | Low |
RLS + unindexed (select auth.uid()) | ~40–80ms | No | Low |
RLS + indexed user_id + subquery | ~1–4ms | Yes | Medium |
RLS + security definer function + index | ~1–3ms | Yes | Medium-High |
| RLS + multiple policies, no index | ~200–400ms | No | Medium |
Timing estimates based on community benchmarks from Supabase GitHub Discussions and Discord, Q4 2025, on shared-CPU Supabase Pro instances.
The data tells an uncomfortable story. RLS-disabled and well-configured RLS are nearly indistinguishable in production — the ~2ms gap at 100K rows is noise. The gap between unindexed and indexed configurations is anything but noise. You’re looking at 120–180ms versus 1–4ms. That’s not a performance concern. That’s a production incident waiting to happen.
Practical Implications: Three Real Scenarios
Scenario 1 — The startup that enabled RLS on day one and never tuned it.
Tables are growing past 50K rows. Queries feel slow but the team assumes it’s network latency. The actual issue is unindexed user_id columns with per-row auth.uid() evaluation. Audit every RLS-enabled table with EXPLAIN ANALYZE. If you see sequential scans, add indexes. Switch every auth.uid() reference to the (select auth.uid()) pattern. This is a one-hour fix that can cut p95 query times by 30–60%.
This approach can fail when tables have grown organically across multiple engineers with inconsistent policy naming conventions. In those cases, a full policy audit takes longer — but it’s still worth doing before you add more rows, not after.
Scenario 2 — The team adding multi-tenant features to an existing app.
Adding a new tenant_id column and bolting on RLS mid-project. Don’t copy policies from smaller tables. At 100K+ rows per tenant, policy complexity compounds fast. Start with a single security definer function that handles access logic centrally. Index tenant_id before enabling the policy, not after. The order matters — enabling RLS on an unindexed column while the table is live puts immediate query pressure on a sequential scan.
Scenario 3 — The team considering RLS vs. application-layer filtering. Some teams skip RLS entirely and filter in application code. The benchmark data shows that well-configured RLS matches application-layer filtering performance closely — within 1–3ms. But application-layer filtering breaks the moment someone issues a direct database query or uses a Supabase client without the JWT attached. This isn’t a theoretical risk. Reports from teams using Supabase in multi-developer environments show that direct psql connections and admin scripts frequently bypass application-layer filters entirely.
RLS at the database level is worth the setup cost for any data with access-control requirements. The performance argument against it disappears once you add the index.
Conclusion & Future Outlook
The benchmark data from 2025–2026 tells a clear story:
- Unindexed RLS at 100K rows is 20–40x slower than indexed RLS on the same data.
- The
(select auth.uid())subquery pattern is the single highest-ROI optimization available for Supabase RLS policies — one line of SQL, dramatic results. - Multi-policy tables need
security definerfunctions to avoid compounding planning overhead at scale. - Well-tuned RLS and no RLS are nearly identical in performance — the overhead comes from configuration choices, not the feature itself.
Looking at the next 6–12 months: Supabase is actively working on policy-aware query planning improvements in their Postgres infrastructure, referenced in their 2025 engineering blog. Postgres 17’s improved predicate pushdown may also reduce planning overhead on complex policy trees. These improvements will help — but they won’t save an unindexed table. The fundamentals don’t change.
Teams building on Supabase today should treat RLS tuning as a standard part of schema design, not an afterthought you revisit when users start complaining. The cost of fixing it early is one hour. The cost of fixing it after you’ve built three more features on top of a slow foundation is considerably higher.
If you haven’t run EXPLAIN ANALYZE on a 100K+ row table with RLS enabled, do it today. The results might change how you architect everything downstream.
What’s your current row count threshold before you start worrying about RLS policy performance? The answer probably needs to be lower than you think.


