3 min read
Row-level security in PostgreSQL can cost nothing or cost seconds — the difference is in the details
RLS performance in PostgreSQL hinges on three specific policy mistakes. The worst part: small tenants in your test data will hide the problem entirely.
By intermac
Row-level security in PostgreSQL can cost nothing, or it can cost seconds. The difference is almost entirely in how you write the policy and the queries that run under it — not whether you enable RLS at all.
The measured breakdown from Now-Next makes this concrete on 2 million rows across 1,000 tenants. The headline result: a well-written policy with a simple tenant_id comparison is as fast as an explicit WHERE clause. A poorly written one — specifically, a PL/pgSQL helper function declared VOLATILE by default — turned an 11 ms count into 1,877 ms, not because of RLS overhead but because the optimizer could no longer use the index at all.
The traps are specific
There are three distinct failure modes, and they can compound.
VOLATILE helper functions. PostgreSQL can inline a plain SQL function, turning it into the index condition itself. A PL/pgSQL function can't be inlined. Declared VOLATILE — the default if you declare nothing — PostgreSQL evaluates it for every row, producing a full sequential scan for every tenant regardless of size. The fix is to declare it STABLE. The same applies to SQL functions that use SECURITY DEFINER or a SET search_path clause: both break inlining, so STABLE matters there too.
Per-row membership lookups. tenant_id IN (SELECT tenant_id FROM memberships WHERE user_id = ...) looks reasonable as a policy expression. It isn't. Without a single value to match against the index, PostgreSQL evaluates the subquery per row. The article measured this at 80–99 ms per query on a table that answers in 0.3 ms with a simple tenant setting. The correct design is to resolve the tenant once when the request starts, set it in app.tenant_id, and check group membership in the application code that sets that value — not in the policy itself.
Non-leakproof functions. PostgreSQL won't push user-supplied non-leakproof conditions past a security policy. A query with lower(customer_email) = lower($1) under RLS uses the index only for tenant_id, then scans every one of that tenant's rows to apply the email filter. On 534 rows that's 0.4 ms. On 267,000 rows it's 34–45 ms. The fix is a stored generated column holding the precomputed lowercase value, indexed alongside tenant_id, or using leakproof operators directly — ~>=~ and ~<~ for prefix searches brought 22 ms back to 0.3 ms.
The hidden cost of testing with small tenants
The leakproof trap is the one most likely to reach production undetected. The median tenant in the benchmark barely noticed — 0.2 ms versus 0.4 ms. The largest tenant paid 200 times more. If your staging environment doesn't include a realistic large tenant, none of this shows up. The problem scales with data volume per tenant, which means it lands hardest on your most important customers.
What to do
Enabling RLS is the right call for multi-tenant databases. But it isn't a passive control you can bolt on after the fact. It interacts with the query planner in ways that require deliberate decisions: function volatility, how tenant identity is resolved, and which conditions are leakproof. The article includes a query to audit your policies for VOLATILE functions, and recommends running EXPLAIN as the application role — not the table owner — to catch conditions that silently shift from index conditions to filters.
The performance is recoverable in every case described. The only question is whether you find the problem before your largest customer does.
Sources
- Row-level security performance in PostgreSQL, measured | Now-Next (original)
- Found via the TLDR newsletter