BuildNot yet confirmed elsewhere1 publisher3 min readPublished
RLS Is a Planner Problem: One VOLATILE Function Turns a 3ms Index Scan Into 1.8 Seconds
A dev.to tutorial benchmarks naive multi-tenant Row-Level Security at roughly 575 times slower than no policy at all. The variable is function volatility, not schema or traffic.
The Engineer · Build desk
What happened
- Scenario described: RLS shipped two weeks earlier, functional tests and security review passed, then p99 latency on a multi-tenant orders table climbed from single-digit milliseconds into the seconds with no schema changes and no traffic spike.
- PostgreSQL inlines an RLS policy as an additional predicate into every query on the table; SELECT * FROM orders WHERE status = 'pending' becomes that filter AND tenant_id = current_setting('app.current_tenant_id')::uuid.
- current_setting() is classified as VOLATILE in PostgreSQL's function catalog, so the planner cannot treat its return value as constant within a query and cannot use the predicate to drive index selection at plan time.
- Index scans on tenant_id degrade to sequential scans under the naive policy.
- Benchmark conditions: 10M-row orders table, B-tree index on (tenant_id, created_at), PostgreSQL 15, AWS r6g.xlarge, VACUUM ANALYZE before each series, 5-run median.
Why it matters
A tutorial published on dev.to describes a failure mode that does not present as a security problem: two weeks after Row-Level Security ships and passes both functional tests and security review, p99 latency on a multi-tenant `orders` table climbs from single-digit milliseconds into the seconds, with no schema changes and no traffic spike [6]. The cause is function volatility, which puts the fix on the desk of whoever owns query plans rather than whoever signed off on isolation.
The mechanism is straightforward once you look at it from the planner's side. PostgreSQL inlines an RLS policy as an additional predicate on every query against the table, so `SELECT * FROM orders WHERE status = 'pending'` becomes that filter plus `tenant_id = current_setting('app.current_tenant_id')::uuid` [7]. According to the tutorial, `current_setting()` is classified as `VOLATILE` in PostgreSQL's function catalog, so the planner cannot treat its return value as constant within a query and cannot use the predicate to drive index selection at plan time [1]. Index scans on `tenant_id` degrade to sequential scans [2].
The author's numbers come from a 10M-row `orders` table with a B-tree index on `(tenant_id, created_at)`, PostgreSQL 15, an AWS r6g.xlarge, `VACUUM ANALYZE` before each series, and a five-run median [3]. No RLS produced an index scan at 3.2ms [8]. The naive `current_setting()` policy produced a sequential scan at 1,840ms [9]. Adding a partial index recovered an index scan at 4.1ms [10], and wrapping the call in a `STABLE` function recovered an index scan at 3.9ms [11]. The author calls that roughly a 575-fold regression and flags the results as directional, with a standing instruction to profile on your own data [4].
Two things are worth reading off that table. The first is that the working fix is nearly free: the `STABLE` wrapper lands about 0.7ms above the no-RLS baseline, a penalty of roughly 22 percent on a 3ms query, which is noise next to three orders of magnitude [18]. The second is that both repairs work, so the choice between them is operational rather than technical. A `STABLE` declaration tells the planner the return value is constant within a single query execution, which lets it use the predicate as a scan key instead of re-evaluating per row [12].
Indexing then decides how far you take it. The tutorial offers a baseline partial index on `(tenant_id, created_at DESC) WHERE tenant_id IS NOT NULL`, useful when `tenant_id` is nullable or sparsely populated [13], and per-tenant partial indexes keyed to a single tenant's UUID, which are dramatically smaller and faster for high-volume tenants but carry maintenance overhead as tenant count grows [14]. Both are created with `CONCURRENTLY` [15].
None of this holds without correct tenant context. Session-level settings do not survive connection reuse under a pooler, so the tutorial specifies `SET LOCAL` inside an explicit transaction, which scopes the setting to that transaction and resets it at commit or rollback [5]. The stated prerequisites include a pooler such as PgBouncer in transaction mode [16].
What to watch: the patterns are written for PostgreSQL 15 and above and are described only as directional for earlier versions, to be verified with `EXPLAIN` [17]. If you have RLS in production and have never read a plan for a tenant-scoped query, that is the check to run before the next incident review does it for you.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence36
- Adoption
- Insufficient
- Hype gap+22
- Incentives62
- Confidence47
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
current_setting() is classified as VOLATILE in PostgreSQL's function catalog, so the planner cannot treat its return value as constant within a query and cannot use the predicate to drive index selection at plan time.
- [2]
Index scans on tenant_id degrade to sequential scans under the naive policy.
- [3]
Benchmark conditions: 10M-row orders table, B-tree index on (tenant_id, created_at), PostgreSQL 15, AWS r6g.xlarge, VACUUM ANALYZE before each series, 5-run median.
- [4]
The author describes the result as a ~575x regression, states results are directional and will vary by schema and table statistics, and advises always profiling on your own data.
- [5]
Session-level settings do not survive connection reuse under a pooler; the correct pattern is SET LOCAL inside an explicit transaction, which scopes the setting to the current transaction and resets automatically at commit or rollback, preventing cross-tenant leakage between pooled connections.
- [6]
Scenario described: RLS shipped two weeks earlier, functional tests and security review passed, then p99 latency on a multi-tenant orders table climbed from single-digit milliseconds into the seconds with no schema changes and no traffic spike.
ReportedSupportedSource: dev.to tutorial, PostgreSQL Row-Level Security Without the Performance TaxView cited source - [7]
PostgreSQL inlines an RLS policy as an additional predicate into every query on the table; SELECT * FROM orders WHERE status = 'pending' becomes that filter AND tenant_id = current_setting('app.current_tenant_id')::uuid.
- [9]
Naive current_setting() policy: Seq Scan, 1,840ms execution time.
- [10]
current_setting() plus partial index: Index Scan, 4.1ms execution time.
- [12]
A STABLE function tells the planner the return value is constant within a single query execution, which lets it use the predicate as a scan key rather than re-evaluating per row.
- [13]
Baseline partial index recommended: CREATE INDEX CONCURRENTLY on orders (tenant_id, created_at DESC) WHERE tenant_id IS NOT NULL, described as useful when tenant_id is nullable or sparsely populated.
- [14]
Per-tenant partial indexes, keyed to a single tenant UUID, are dramatically smaller and faster for the largest tenants but carry maintenance overhead as tenant count grows.
- [15]
Both index examples in the tutorial use CREATE INDEX CONCURRENTLY.
- [16]
Stated prerequisites include a connection pooler such as PgBouncer in transaction mode.
- [17]
Stated prerequisite is PostgreSQL 15 or later; the patterns are described as directional for earlier versions, to be verified with EXPLAIN.
- [18]
The STABLE wrapper result is 0.7ms slower than the no-RLS baseline, about 22 percent overhead.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toPostgreSQL Row-Level Security Without the Performance Tax
1 article · August 21, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
- Database indexing strategyFollow
- Performance Benchmarking PracticeFollow
- Connection PoolingFollow
- PostgreSQL Query PlanningFollow
- Row-Level SecurityFollow
- Multi-tenant data isolationFollow
Entities
- PostgreSQLFollow
- PgBouncerFollow
- dev.toFollow
- MVP FactoryFollow
- Amazon Web ServicesFollow
- HealthyDeskFollow