Now-Next

SaaS architecture

Rails, ActiveRecord and PostgreSQL RLS, measured

Setting the tenant for PostgreSQL row-level security in Rails 8.1, measured: before_action, a transaction per request, threads in a request, failed writes.

Chris van Eijk, Now-Next · · 7 min read

Row-level security in PostgreSQL needs to know the tenant, and the usual way to tell it is a setting on the connection: set_config('app.tenant', …). In Rails that line goes into a before_action or an around_action in ApplicationController. ActiveRecord keeps a pool of connections per process, and Puma serves several requests at once from threads that share that pool.

We measured four ways to set the tenant. Rails did better than the other stacks we tested on one point: a request that set its own tenant never got another tenant’s rows, also with 30 concurrent requests against 5 threads, because ActiveRecord::Base.connection leases the connection to the request — call with_connection instead and it leaked the way other stacks do. A request that set no tenant got the last customer’s invoices left on its connection, 200 times out of 200, twice. A thread started inside a request read whichever tenant was left on another connection. And the transaction per request that held in every concurrent run committed the insert of a request that answered with a 500.

The setup

Rails 8.1.4 (ActiveRecord 8.1.4) on Ruby 3.4.11, the pg gem 1.7.0, Puma 8.0.2 in single mode with 5 threads and a pool of 5 connections (max_connections: 5), RAILS_ENV=production; PostgreSQL 17.11 in a throwaway container. Measured on 15 and 16 October 2026.

  • One table invoices, 20 rows for tenant 1 and 10 for tenant 2, row-level security forced, policy using (tenant_id = nullif(current_setting('app.tenant', true), '')::int) with the same with check. Rails connects as an ordinary role, not the table owner.
  • One endpoint returns Invoice.count and the backend process ID. The tenant comes from ?tenant=.
  • Four requests in a row: tenant 1, no tenant, tenant 2, no tenant. “No tenant” stands for any path that does not set one: a public page, a health check, a controller with skip_before_action.

The four ways, each in ApplicationController:

  1. before_action that runs ActiveRecord::Base.connection.execute("select set_config('app.tenant', …, false)") when there is a tenant.
  2. The same with set_config(…, true).
  3. around_action with ActiveRecord::Base.transaction do, set_config(…, true) as its first statement, then yield.
  4. Way 1, but always: '' when there is no tenant.

What did the request without a tenant see?

WayTenant 1No tenantTenant 2No tenant
1 · before_action, session-level, only with a tenant20201010
2 · before_action, set_config(…, true)0000
3 · around_action with a transaction200100
4 · before_action, session-level, '' without a tenant200100

Rows counted per request, two runs each with identical results. Bold is another customer’s data; italics is a customer who sees none of their own.

ActiveRecord does not reset a session setting when a connection goes back to the pool. Checking a connection in clears its lease and marks it idle; no SQL is sent (ConnectionPool#checkin and AbstractAdapter#expire in ActiveRecord 8.1.4). The adapter’s reset!, which runs DISCARD ALL, is called when a connection is unpinned or reaped from a dead thread, not on an ordinary check-in. We confirmed it in a script: set app.tenant to 7, release the connection, check one out again — the same connection, still set to 7.

Way 2 returned nothing for the reason it does in Laravel and Django: outside a transaction, execute is its own transaction, and the setting ends with it.

What happened under concurrent requests?

600 requests, 30 at a time against 5 Puma threads; first only tenants 1 and 2, then a third of the requests without a tenant. Two runs of each:

WayTenant requests with another tenant’s countRequests without a tenant that saw rows
1 · session-level, only with a tenant0200 of 200, twice
2 · set_config(…, true)all of them, counted 00
3 · transaction per request00
4 · session-level, '' without a tenant00

The zero in the first row is where Rails differs from Spring Boot, TypeORM, Prisma and Drizzle, where the same pattern also gave requests that set their tenant another tenant’s rows. A model query on its own checks a connection out and gives it back straight after the query. But ActiveRecord::Base.connection, which the before_action calls, leases the connection to the request’s thread (lease_connection, a sticky lease), and every model query on that thread then runs on that same connection until the request ends. So the set_config and the queries after it share a connection — and the setting stays on it for whoever gets it next.

We checked that the lease is the reason. With ActiveRecord::Base.with_connection { |c| c.execute(…) } in the before_action instead — the connection goes back to the pool after the block — and 2 ms of other work in the action before the query, 155 and 160 of 600 tenant requests counted the other tenant’s rows; way 1 under the same load had none. Without that pause, with_connection also came out at zero in our runs, because the pool tends to hand a thread back the connection it just returned. That is luck, not a guarantee. .connection is soft-deprecated in favour of lease_connection, which leases the same way; with permanent_connection_checkout = :disallowed, .connection raises.

Two requests, 2 ms of other work in the action: where the count's connection comes from

before_action with ActiveRecord::Base.connection (way 1)

  1. Request A, tenant 1Leases connection X for its thread and sets tenant 1 on it.
    X: leased by A, tenant 1
  2. Request B, tenant 2X is leased, so B leases another connection and sets tenant 2 there.
    X untouched
  3. Request AInvoice.count runs on its lease, X.
    counts 20

before_action with ActiveRecord::Base.with_connection

  1. Request A, tenant 1Checks out X, sets tenant 1, and gives X back after the block.
    X: idle, tenant 1
  2. Request B, tenant 2Checks out X while A is busy, sets tenant 2, gives X back.
    X: idle, tenant 2
  3. Request AInvoice.count checks out a connection and gets X.
    counts 10: tenant 2's invoices

Measured: 0 of 600 wrong with the lease, 155 and 160 of 600 with with_connection, under the same load. The interleaving in the second panel is one that produces that result; which connection each request got was not logged.

What does a thread inside the request see?

A connection is leased per thread. We started a thread inside the request, Thread.new { Invoice.count }.value, and read the setting on the connection it used:

WayRequest for tenant 1: the request’s countThe thread’s countSetting the thread saw
1 · session-level, only with a tenant20201, left by an earlier request — in the tenant 2 request of the same probe, the thread also counted tenant 1’s 20
3 · transaction per request200empty
4 · session-level, '' without a tenant20102, left by an earlier request

The thread got another connection from the pool, with whatever the last request on that connection had set. With way 4 that was tenant 2, so a request for tenant 1 counted tenant 2’s invoices in its own thread. With the transaction per request, connections go back to the pool without a tenant, and the thread saw nothing: wrong, but not someone else’s. connection_pool.with_connection on the request’s own thread used the request’s connection, because the request had already leased one. One probe of three requests per way; a plain thread is all we measured. load_async runs on the request’s connection by default (no async_query_executor is configured) and inside a transaction; only with an executor configured and outside a transaction does it go to another pooled connection — that is from the source, not measured.

Why did a request that failed commit its insert?

The around_action that held everywhere:

around_action :with_tenant

def with_tenant
  ActiveRecord::Base.transaction do
    ActiveRecord::Base.connection.execute(
      ActiveRecord::Base.sanitize_sql_array(
        ["select set_config('app.tenant', ?, true)", current_tenant_id.to_s]  # '' without a tenant
      )
    )
    yield
  end
end

We inserted an invoice for tenant 2 in three actions and ended each with a 500:

The action, after the insertResponseThe insert
raise an exception that nothing handles500rolled back
raise an exception that a rescue_from handler renders as a 500500rolled back
render json: …, status: 500, no exception500committed

ActiveRecord::Base.transaction rolls back when an exception is raised inside its block (ActiveRecord::Rollback included, which it swallows), and commits when the block returns normally. A rescue_from handler still rolled back in our run, because the exception passed through the around_action before the handler rendered it. An action that renders an error itself returns normally, and the transaction commits. If your actions answer errors without raising, decide in the around_action on the response: after yield, raise ActiveRecord::Rollback if response.status >= 500. With that line, the insert of the action that rendered a 500 was rolled back in two runs, an ordinary insert answered with a 201 still committed, and the concurrent reads were unchanged (600 of 600 correct, with and without a tenant).

The usual costs of a transaction per request apply: it holds the connection and any row locks until the response, and a slow external call inside the action keeps the transaction open that long. Background jobs do not pass through controller callbacks and need their own way to set the tenant; a worker that serves every tenant is measured in PostgreSQL job workers: retries, crashes, tenant context.

What does it cost?

Average over 3,000 sequential requests alternating between tenant 1 and tenant 2, over one keep-alive connection, after 100 warm-up requests, two runs each, on one machine with the database next door:

WayPer request
1 · session-level, only with a tenant (leaks)0.80 ms
3 · transaction per request0.94 ms
4 · session-level, '' without a tenant0.79–0.81 ms

The transaction cost about 0.14 ms per request here; it adds a BEGIN and a COMMIT.

What we did not measure

Puma in cluster mode with several workers, load_async with an executor, multi-tenancy gems that separate tenants by schema or by a column in the query, PgBouncer in front of the database (measured in PgBouncer and row-level security), and Active Job. The same question for other stacks is measured for Django, SQLAlchemy, Laravel, Spring Boot, TypeORM, Prisma, Drizzle and Go with pgx; the other ways a policy leaks are in row-level security in PostgreSQL: where tenants leak. The timings come from one machine and are for comparison.

Sources: the ActiveRecord 8.1.4 source (ConnectionPool#checkin, #with_connection, #lease_connection, ExecutorHooks, PostgreSQLAdapter#reset!, the default of permanent_connection_checkout), read on 15 October 2026. All measurements are our own, on Rails 8.1.4 and PostgreSQL 17.11, on 15 and 16 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation