Demystifying PostgreSQL’s "Infinite Recursion" in Row-Level Security: Causes, Benchmarks, and Six Production-Ready Fixes

Database security at the multi-tenant layer often introduces subtle, high-impact architectural traps. Among the most frustrating obstacles for modern developers leveraging PostgreSQL is the dreaded infinite recursion detected in policy for relation error (SQLSTATE 42P17).
This exception emerges the exact moment a Row-Level Security (RLS) policy attempts to read the very table it is designed to protect. In multi-tenant environments, this pattern typically manifests within the primary membership table: users belong to multiple tenants, and the logical rule—“you can see the members of the tenants you belong to”—requires querying the membership table to determine who you are before granting permission to read that table.
To understand the mechanics of this error, engineers tested PostgreSQL 17.11 under default settings using a controlled benchmark environment containing a million invoices and a corresponding membership matrix. Their findings reveal stark performance divergences among potential workarounds, demonstrating that fixing the recursion does not automatically guarantee acceptable query performance.
Main Facts: Anatomy of SQLSTATE 42P17
PostgreSQL encounters infinite recursion during its query-rewriting phase when evaluating a policy requires reading the target relation under those identical rules.
Key Characteristics of the Error:
- Pre-Execution Failure: The error is raised before the query executes. Operations like
SELECT ... WHERE false, anEXPLAINstatement withoutANALYZE, and anINSERTall fail instantly under a globally applied policy. - Select-Bound Scope: Only policies that apply to read operations trigger the error—specifically policies defined
FOR SELECTor for all commands. Policies restricted strictly toFOR INSERT,FOR UPDATE, orFOR DELETEdo not initiate recursion on read. - Silent in Development: The error is notoriously easy to miss during early development cycles. Database owners and superusers bypass row-level security by default; consequently, queries execute successfully for administrators while crashing for standard application roles.
- Cross-Table Contagion: The exception is not restricted solely to the faulty table. Any auxiliary table whose RLS policy references the membership table will also fail, with the error message consistently naming the primary table being queried rather than the immediate table in the query text.
Chronology and Diagnostic Setup
The operational dynamics of this issue were mapped out on PostgreSQL 17.11 running inside an isolated container environment.
The Diagnostic Matrix
When deploying a self-referencing RLS policy such as:
create policy members_of_my_tenants on memberships
using (tenant_id in (select tenant_id from memberships where user_id = me));
The database reacts differently depending on the execution context and command type:
| Action / Context | Result |
|---|---|
CREATE POLICY with self-reference |
Created successfully without error |
select count(*) from memberships as app role |
Raises infinite recursion error |
select count(*) ... WHERE false or EXPLAIN |
Raises infinite recursion error |
insert into memberships ... as app role |
Raises infinite recursion error |
Querying invoices (whose policy reads memberships) |
Fails, naming memberships in the error |
select count(*) from memberships as table owner (no FORCE) |
Returns 12,000 rows successfully |
As table owner with FORCE ROW LEVEL SECURITY |
Raises infinite recursion error |
| Executed as a database superuser | Returns 12,000 rows successfully |
The tests confirmed that two separate tables can easily trigger mutual recursion. If a policy on memberships references tenants, and a policy on tenants references memberships, both relations fail validation, with each error message pointing directly to the table actively queried.
Supporting Data: Benchmarking Six Solutions
Breaking the recursion loop is only half the battle; ensuring the query planner can leverage underlying indexes is critical for application scalability. Researchers measured six distinct architectural approaches using a table of 201,602 invoices (for a large user with access to multiple tenants) and 801 invoices (for a small user), alongside membership resolution speeds.
Performance Comparison of the Six Fixes
| Mitigation Strategy | Invoice Count (Large User: 201k rows) | Invoice Count (Small User: 801 rows) | Visible Memberships Count |
|---|---|---|---|
Reference: Own rows only (in (select ... from memberships)) |
38.6 ms | 33.8 ms | 0.04 ms (3 rows) |
1. SECURITY DEFINER function returning a set (tenant_id in (select my_tenants())) |
85.2 ms | 74.5 ms | 0.91 ms |
2. Set function via ANY array (tenant_id = any (array(select my_tenants()))) |
13.0 ms | 0.15 ms | 0.09 ms |
3. Array-returning function marked STABLE (tenant_id = any (my_tenant_arr())) |
12.7 ms | 0.18 ms | 0.14 ms |
4. Array function left as default VOLATILE |
7,171 ms | 6,583 ms | 85.3 ms |
5. View owned by table owner (in (select tenant_id from my_tenant_ids)) |
40.2 ms | 34.8 ms | 1.01 ms |
| 6. Tenant isolated in a session variable (single tenant per request) | 8.3 ms | 0.09 ms | 0.04 ms (10 rows) |
Analyzing the Performance Bottlenecks
The performance delta between Fix 1 and Fix 2 underscores how the PostgreSQL query planner handles sub-selects versus explicit array evaluations:
- Fix 1 (The Trap): Moving the lookup into a standard
SECURITY DEFINERfunction and writingtenant_id in (select my_tenants())eliminates the recursion error, but processing the small user’s invoice count takes a sluggish 74.5 ms. The execution plan reveals a sequential scan on theinvoicestable (Rows Removed by Filter), testing every single row against a hashed sub-plan. Declaring the functionSTABLEinstead ofVOLATILEyields no performance improvement here. - Fix 2 & 3 (The Optimal Index Path): By transforming the call into
tenant_id = any (array(select my_tenants()))or returning an array directly, the sub-select collapses into anInitPlanthat executes precisely once. The query planner transitions to an efficientIndex Only Scanusing the corresponding index conditions, plummeting execution time down to 0.15 ms. - Fix 4 (The Volatility Trap): Omitting the
STABLEdesignation leaves the function markedVOLATILEby default. When called directly within a policy expression without an encapsulating array sub-select, PostgreSQL evaluates the function repeatedly for every single row scanned. This catastrophic execution pattern pushes counts to over 6.5 seconds.
Official Responses and Edge Cases
The FORCE ROW LEVEL SECURITY Complication
When database administrators enable FORCE ROW LEVEL SECURITY on a table, even the table owner becomes bound by policies. If a SECURITY DEFINER function is owned by the table owner, a dangerous runtime loop emerges: the function executes its query, the query triggers the RLS policy, the policy invokes the function, and the process repeats until memory exhaustion occurs:
ERROR: stack depth limit exceeded
HINT: Increase the configuration parameter "max_stack_depth" (currently 2048kB)...
Artificially inflating max_stack_depth fails to resolve the issue because the recursion lacks a terminal condition. The production-ready solution requires creating an isolated auxiliary role to own the helper functions. This role must possess BYPASSRLS privileges and SELECT access on the memberships table, operating independently from the standard application login role.
The Lookup Table Security Trade-off
An alternative architectural pattern involves copying mapping data into a decoupled lookup table devoid of Row-Level Security. However, this trades the recursion error for a severe security risk. Without explicit permission grants, application queries fail with permission denied. Once read access is granted to the application role, executing select count(*) from user_tenants exposes the complete mapping matrix of all system users in a single request, completely breaking multi-tenant data isolation.
Implications and Production Architecture Recommendations
Securing multi-tenant applications requires deliberate design choices across function execution rights, write policies, and schema definitions. Engineering teams should adopt the following best practices:
- Granular Command Policies: Never write blanket policies without command specifiers unless intended. For membership lists, scope the recursive prevention policy strictly
FOR SELECT, ensuring that data modifications (INSERT,UPDATE,DELETE) utilize dedicated, non-recursive validation rules. - Secure Function Execution: Revoke
EXECUTEpermissions from thePUBLICrole on all helper security functions (revoke execute on function my_tenant_arr() from public;), explicitly granting access solely to the designated application role. - Explicit Search Paths: Always harden
SECURITY DEFINERfunctions by setting an immutable search path (set search_path = '') and fully qualifying internal table references with their schema name (e.g.,public.memberships). - Comprehensive Test Suites: Because superusers and table owners inherently bypass RLS, automated integration test suites must execute routine assertions under the exact low-privilege context of the application runtime role to catch recursion regressions prior to production deployments.
