Build1 distinct publisher3 min readUpdated
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
Compiled by The EngineerSomething wrong?How this is made
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 [1]. 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` [2]. 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 [3]. Index scans on `tenant_id` degrade to sequential scans [4].
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 [5]. No RLS produced an index scan at 3.2ms [6]. The naive `current_setting()` policy produced a sequential scan at 1,840ms [7]. Adding a partial index recovered an index scan at 4.1ms [8], and wrapping the call in a `STABLE` function recovered an index scan at 3.9ms [9]. 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 [10].
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 [11]. 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 [16]. The stated prerequisites include a pooler such as PgBouncer in transaction mode [17].
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` [18]. 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.
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
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.
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.
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.
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.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Single self-published benchmark on a documented mechanism
Everything in the cluster comes from one dev.to tutorial by one author. The underlying mechanism — function volatility governing whether the planner can use an inlined predicate as a scan key — is documented PostgreSQL behavior that the source links to, which lifts plausibility above pure assertion. The quantities do not clear the same bar: the setup is disclosed (10M rows, PostgreSQL 15, r6g.xlarge, VACUUM ANALYZE, 5-run median) but no dataset, query harness, raw EXPLAIN output or run-to-run variance is published, only one query shape is exercised, and no second publisher corroborates any number.
No adoption signal in supplied material
The cluster contains no deployment, release, usage disclosure, incident report or third-party uptake evidence for the recommended patterns. The one observation available is the author's own micro-benchmark, which measures a configuration rather than showing anyone running these policies in production, so adoption cannot be scored without guessing.
Headline multiple outruns one unreproducible run
The framing is a dramatic single number — '~575x', echoed in the cluster dek — derived from one query shape in one unreproducible run, and both remedies converge to within a millisecond of baseline, which is the far less headline-friendly result. The gap is modest rather than large because the author self-limits in the same breath: results are called directional, profiling on your own data is urged, and the Gotchas section explicitly warns that STABLE is not a compile-time constant and that behavior varies by version and statistics.
Agency content marketing with an embedded app plug
The post is syndicated on dev.to but canonicalizes to a commercial agency blog (mvpfactory.co), and it closes with a promotional link to a consumer Android app. That is a clear content-marketing incentive to publish an attention-grabbing figure, and no interest disclosure accompanies it. The incentive is tempered by the fact that nothing in the technical guidance sells a product: the recommended fixes are stock PostgreSQL features, so there is no captive-technology conflict.
Mechanism credible, magnitudes weakly held
Confidence is moderate and asymmetric. The directional finding — a naive VOLATILE-function RLS policy can defeat plan-time index selection and a STABLE wrapper plus partial indexes can restore it — is consistent with documented PostgreSQL volatility semantics and is internally coherent across the source's plan-type column. The specific magnitudes, the 22 percent residual overhead, and any generalization to other schemas, versions, or workload shapes are held weakly: one publisher, one author, no replication, no artifacts, no variance, and a visible promotional incentive.
build
"Too Many Clients Already" Is Arithmetic, Not Capacity1 distinct publisher
build
Your "Index Only Scan" Did 2,847 Heap Fetches: Covering Indexes Are a Vacuum Problem1 distinct publisher
build
Postgres row-level security does nothing for the role your Symfony app connects with1 distinct publisher
build
Your ORM never puts a WHERE clause in an index, and that is where the seq scans live1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 21, 2026