Skip to content

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

How we use AISend a correction

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
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [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. [2]

    Index scans on tenant_id degrade to sequential scans under the naive policy.

  3. [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.

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · August 21, 2026

    PostgreSQL Row-Level Security Without the Performance Tax

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Topics

Loading related stories