← Blog

October 21, 2026 · Steve Johnson

Row-Level Security Performance Overhead in Multi-Tenant PostgreSQL

Row-level security performance overhead in multi-tenant PostgreSQL is not a fixed tax. With the right composite indexes in place, its cost depends almost entirely on three choices, namely function volatility, membership subquery placement, and leakproof function use. Getting those choices right makes RLS essentially free on ordinary transactional paths, while getting them wrong can turn selective lookups into seconds-long sequential scans.

What RLS actually does inside PostgreSQL and why its cost is not fixed

PostgreSQL applies the security predicate defined in an RLS policy when it processes reads and writes. That predicate becomes part of the work required to execute the query. Its expense therefore depends on how the policy is written and whether the planner can use the available indexes. In a well-indexed baseline, manual WHERE filtering and RLS on CRUD paths differ by less than two percent. The important distinction is between a predicate the planner can resolve cheaply and one that forces repeated evaluation or scanning.

This mechanism makes the expensive cases discoverable. A function declaration, a membership lookup, or a limit on when a function can run can change the access path while keeping the same security rule. The policy can remain logically correct while becoming operationally expensive. Before examining those three cases, however, the database needs indexes that support the tenant predicate itself.

Why composite indexes with tenant_id as the leading column are the prerequisite for everything else

Missing composite indexes are the largest performance killer in RLS deployments. Every query against a tenant-scoped table must check the tenant condition that the policy adds. An index that supports the visible application query but not that condition leaves the planner without the access path the complete query needs. As a result, a query that looks selective in application code can still require a sequential scan. Correcting function declarations or membership logic cannot compensate for that missing foundation.

The operational rule is that every table with an RLS policy needs tenant_id as the leading column in every primary access index. Tenant filtering is part of the primary access pattern. Composite indexes must reflect that order. Only after this requirement is satisfied can the three policy cost drivers be isolated usefully.

Adding those indexes to a large table that already exists needs CREATE INDEX CONCURRENTLY. The deployment is not complete merely because the build was attempted, since a failed concurrent build does not always raise an error. An automated indisvalid check belongs in the CI pipeline. Without it, an invalid index can appear days later as unexplained slow queries.

Cost driver one: volatile function declarations that force per-row evaluation and disable index use

On a two-million-row table, a VOLATILE helper in a policy predicate turned a sub-millisecond lookup into a multi-second query. That is a change in execution strategy. A PL/pgSQL helper is VOLATILE by default, and a volatile function cannot be inlined. When the policy requires that helper to run for every candidate row, the planner cannot use it as a fixed lookup value. The result can be a sequential scan despite an otherwise suitable index.

Where the function qualifies, declaring it STABLE removes that obstacle. For LANGUAGE sql functions, this can permit inlining and allow the tenant value to be evaluated once per query. Existing indexes can then serve the query normally. The alternative is to wrap the call in a (SELECT ...) subexpression so that it is evaluated once per query without changing the declaration. Both approaches change how often the policy runs the check and keep its intended access rule the same.

Volatility is not the only declaration detail that matters. SECURITY DEFINER and a function-level SET search_path also prevent SQL function inlining. A helper should therefore be examined as a complete declaration. The performance boundary lies in what the planner can do with the call.

Cost driver two: membership subqueries that re-execute on every row instead of once per request

A policy using tenant_id IN (SELECT tenant_id FROM memberships WHERE user_id =...) can require the membership list to be evaluated against every candidate row. That adds work to each query regardless of tenant size. Rewriting the expression as = ANY(ARRAY(SELECT ...)) can improve counts while worsening queries that return a limited number of rows.

The recommended pattern is to resolve tenant membership in the application at request start. The application stores the resolved tenant ID with SET LOCAL, and the policy compares tenant_id with the value from current_setting('app.current_tenant_id'), cast to uuid. This removes membership lookup from the row-filtering path.

SET LOCAL is a transaction boundary. It scopes the value to the current transaction, whereas SET persists across the session. With connection pooling, a persistent value can expose the next request to the previous tenant’s context.

The wrong context can also make data seem to be missing during debugging. The policy can return zero rows without an error, log entry, or alert, while a superuser who bypasses RLS can still find the records. The canary test is to count known owned tenant rows as the application role. An unexpected zero shows that context injection has failed before a customer reports missing data.

Cost driver three: non-leakproof functions that silently suppress index use for large tenants

In the benchmark for a large tenant, the leakproof interaction added tens of milliseconds to a sub-millisecond baseline without RLS, while a median small tenant barely noticed. The cost depends on how many tenant rows remain to be processed after the security condition has narrowed the query.

PostgreSQL restricts non-leakproof functions, including lower() and LIKE, from running before the RLS filter where doing so could expose protected information. In the affected plan, the index serves only the tenant_id condition. The rest of the expression runs row by row as a Filter step.

The preferred fix is a stored generated column, such as customer_email_lower GENERATED ALWAYS AS (lower(customer_email)) STORED, with an index and queries that use that column. This restored performance to near-baseline in the benchmark. The next option is a text_pattern_ops operator class on the index for applicable pattern queries. Where a function qualifies, a STABLE declaration can also permit inlining and restore index use, although stability and leakproofness address different planner constraints.

The diagnostic close is to run EXPLAIN as the application role. The table owner bypasses RLS and may see an index scan unavailable to the application. That discrepancy is the evidence that matters.

How to detect all three cost drivers in a live database before they reach production load

Detection begins during schema review, while a policy is still an engineering decision. Queries that join pg_proc and pg_policy can find policies that call VOLATILE functions. Reviewing those results establishes whether a helper may force per-row execution before query latency becomes the signal. Membership expressions then need to be checked for work that should move from inside the row predicate to the start of the request. Together, these checks expose the first two cost drivers at their source.

The next step is to inspect the plan under the role that actually serves requests. Use SET ROLE to choose the application role, then run EXPLAIN ANALYZE to see Filter steps and Seq Scans that an owner-role plan can hide. The workload must include large tenants because small-tenant plans and timings can understate the leakproof cost. This connects declaration review to the access path PostgreSQL actually chooses.

The test suite then checks the context-injection path by counting owned tenant rows as the application role. An unexpected zero means the context path is broken. That canary belongs with tests of transaction-scoped context under connection reuse. Finally, every concurrent index build is followed by an automated indisvalid gate.

When RLS is the right architecture and when schema-per-tenant is the better call

Eliminating the three cost drivers does not make RLS the right isolation model for every product. The architectural choice still depends on tenant count, compliance posture, and migration requirements. RLS fits high-volume self-serve SaaS scaling past thousands of tenants because shared tables support O(1) DDL migrations, uniform connection pooling through PgBouncer, and minimal catalog overhead. Those properties matter when the same schema change must reach the whole tenant population. They are separate from the cost of executing any individual policy.

Schema-per-tenant is a better fit for enterprise accounts that need isolated restores, explicit tenant deletion compliance, or regulatory data segregation. DDL migrations scale linearly with tenant count, and catalog bloat in pg_class and pg_attribute degrades planner performance past several thousand schemas. The decision is therefore not between a costly architecture and a free one. It is between costs that fit different tenant profiles.

The practitioner objection that RLS becomes troublesome only after scale is reached deserves serious weight. These failures emerge gradually, and the architecture may be deeply embedded before they become visible. That experience reflects a fixable planner constraint. These problems can be fixed before teams turn to migration. Early detection addresses that objection.

Analytical aggregation is a further boundary. Heavy GROUP BY and COUNT workloads on the OLTP primary incur visibility-check work that ordinary selective lookups do not. The recommended response is to offload heavy aggregates to asynchronous background workers or a read replica. It is not to abandon RLS on the transactional tables.

How these decisions look in a production fractional CTO engagement

For early-stage SaaS founders, these performance choices start with which stack to use and how to build with it. Getting them wrong at pre-seed can mean refactoring under load at Series A. Embedded technical leadership makes policy review a deployment gate by reviewing every feature touching a tenant-scoped table before merge for function volatility, index coverage, and leakproof interactions. The canary test and index-validity check enter CI/CD from day one.

Transaction-scoped SET LOCAL belongs in ORM request interceptors, background job runners, admin tooling, and MCP tool handlers in agent workflows. Agents can bypass the application middleware that normally injects tenant context. The context pattern therefore cannot be assumed to propagate simply because the main application implements it correctly. Each database entry point is part of the isolation design.

Properly configured PostgreSQL RLS meets the multi-tenancy needs of most early-stage SaaS products without adding a second database engine. The default is to stay on PostgreSQL until concrete, measured bottlenecks justify a change, rather than designing around theoretical scaling concerns. A production fractional CTO engagement carries that decision into composite indexes, helper declarations, generated columns, transaction boundaries, and pipeline tests. Advice without implementation ownership is a different operating model. The engagement goes beyond a recommendation to use RLS by establishing a durable development discipline that keeps its three avoidable costs from compounding.