Skip to content
Updated Jun 9, 2026

POC #2 - FINDINGS

Verdict: GO - RLS is a viable tenant-isolation floor behind pgbouncer transaction pooling, conditional on carrying tenant context with SET LOCAL inside the transaction that runs the query. The naive session SET is catastrophic.

Setup: postgres 16, claim = 1,000,000 rows across 1,000 tenants, indexed on tenant_id. pgbouncer pool_mode=transaction. Harness: Go, through pgbouncer.


1. Isolation (the catastrophic-leak gate)

50,000 randomized checkouts @ 200 concurrency, default_pool_size=20 (heavy multiplexing):

Patterncorrectleakedstarvederrors
unsafe - session SET + separate autocommit SELECT6849,907250
safe - BEGIN; SET LOCAL; SELECT; COMMIT50,000000

Unsafe leaks on 99.8% of requests - two autocommit statements land on different server backends; the session GUC set by one client is read by another. Safe pattern: the whole tenant-scoped unit is one transaction → one backend → GUC scoped and auto-cleared. Zero cross-tenant rows. ✅ meets "0 leaks over ≥50k".

2. Sargability ✅

EXPLAIN (ANALYZE) - RLS predicate pushes into the index condition, same plan as a hand-written WHERE:

RLS    : Bitmap Index Scan on idx_claim_tenant
         Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id',true),''))::integer)
         Execution Time: 3.99 ms   (Planning 1.23 ms)
no-RLS : Bitmap Index Scan on idx_claim_tenant
         Index Cond: (tenant_id = 42)
         Execution Time: 3.15 ms   (Planning 0.55 ms)

The RLS predicate costs ~0.8 ms and never seq-scans.

3. Overhead - depends entirely on round-trips, not RLS

overhead = full tenant-scoped read vs a bare no-RLS WHERE, p50/p95/p99 (ms):

Pattern (RLS)round-tripsc=50 Δp50c=200 Δp50c=200 Δp99
4 statements (BEGIN/SET LOCAL/SELECT/COMMIT, lib/pq)4+78%+72%+38%
pipelined batch (pgx, 1 flush)1 flush / 4 stmts+67%+68%+36%
SECURITY INVOKER function, 1 statement1+6.7%+9.7%−3.0%

The RLS predicate is ~free (§2); the overhead is the transaction scaffolding needed to carry the GUC safely. Pushing set_config(local) server-side into a one-statement function (SELECT … FROM claim_list($tenant)) collapses it to <10% p50, ~0% at the tail ✅. Even the unoptimised 4-round-trip safe pattern adds only ~2 ms (c=50) to ~7 ms p99 (c=200) in absolute terms - inside the +50 ms whole-stack budget.


4. Build directives (mandatory for the design)

  1. Set tenant context with SET LOCAL (or set_config(...,true)) inside the same transaction as the query. Never a session SET. Session SET under txn pooling leaks 99.8% of the time.
  2. Prefer one statement. A SECURITY INVOKER function that does set_config(local) then the query keeps overhead <10%. Otherwise pipeline the batch. The plain 4-round-trip pattern is correct but pays ~2-7 ms.
  3. Fail-closed predicate must be NULLIF(current_setting('app.x',true),'')::int. A recycled pgbouncer backend presents an unset custom GUC as '', so a bare ::int cast errors instead of returning zero rows.
  4. ENABLE + FORCE ROW LEVEL SECURITY; policy TO appuser; the app role must not own the table and must not have BYPASSRLS.

5. Scorecard

ThresholdResult
0 cross-tenant rows over ≥50k checkouts @ ~200✅ 0 / 50,000 (safe)
RLS overhead < +10% vs no-RLS same query✅ +6.7-9.7% p50 (function); predicate itself ~0.8 ms
index scan preserved with RLS predicate✅ same Bitmap Index Scan

6. Not tested / watch-items for build

  • Writes (INSERT/UPDATE/DELETE RLS with WITH CHECK) - only SELECT covered here.
  • Prepared statements under txn pooling - pgx worked, but production must use a pgbouncer (≥1.21) that supports protocol-level prepared statements, or set pgx exec mode appropriately; server-side named prepared statements don't survive backend switches.
  • Multiple tables / joins - single table here; joins are POC #1's territory.
  • Connection setup of the GUC namespace - app.* custom GUCs need no config in PG≥9.2, confirmed.

Reproduce / source files

cd /root/rls-poc && docker compose up -d   # then run the harness (see README)

Source (raw):

Olly Health Insurance Platform