SaaS architecture
Go, pgx and PostgreSQL row-level security, measured
Setting the tenant for PostgreSQL row-level security in Go with pgx 5.11, measured: pgxpool, PrepareConn hooks, transactions, batches and PgBouncer.
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 Go with pgx, pool.Exec and pool.QueryRow each take a connection from the pool for one statement, so the first question is not which form of set_config to use but whether the query that follows runs on the same connection at all.
We measured nine ways of setting it on pgx 5.11, with pgxpool and through database/sql. With the tenant set by pool.Exec and the query run by pool.QueryRow, about half of the requests that had set their tenant counted the other customer’s invoices under concurrent load — and that is with two tenants, where a wrong connection is the right tenant half the time. Hooks on the pool, which set the tenant when a connection is acquired and reset it on release, kept every request right against PostgreSQL directly, and fell apart behind PgBouncer in transaction mode: three quarters of the requests that set a tenant got no rows or another customer’s, and more than a third of those without a tenant saw rows. What held everywhere was a transaction that sets the tenant as its first statement, and a pgx batch that does the same in one round trip, at 0.06 ms per request the cheapest variant we measured.
The setup
pgx 5.11.0 on Go 1.25.14; PostgreSQL 17.11 in a throwaway container; for the pooler test PgBouncer 1.26.0 in transaction mode with default_pool_size = 5. Measured on 12 October 2026.
- One table
invoices, 20 rows for tenant 1 and 10 for tenant 2, row-level security forced, policyusing (tenant_id = nullif(current_setting('app.tenant', true), '')::int). Go connects as an ordinary role, not the table owner. Why the policy hasnullifin it is in current_setting and set_config in PostgreSQL, measured. - A
pgxpool.Poolwithpool_max_conns=10, or adatabase/sqlpool on the pgx driver with at most 10 open and 10 idle connections. Everything else is the default, including pgx’s statement cache. - A “request” sets the tenant, if it has one, and runs
select count(*) from invoices. “No tenant” stands for any path that does not set one — a public page, a health check, a background goroutine, a handler someone forgot. In the tables below, such a request skips theset_configcall altogether.
The correct answers are 20 for tenant 1, 10 for tenant 2 and 0 without a tenant.
What did each request see?
One request at a time — tenant 1, no tenant, tenant 2, no tenant — the leaking variants counted 20, 20, 10, 10 and the others 20, 0, 10, 0. That hides the worst of it. The same variants with 600 requests, 30 at a time — 200 for each tenant and 200 without one, in random order, two runs:
| How the tenant is set | Tenant requests that counted another customer’s invoices | Requests without a tenant that saw rows |
|---|---|---|
pool.Exec set_config(…, false), then pool.QueryRow | 193 and 178 of 400 | 196 and 200 of 200 |
database/sql: db.Exec, then db.QueryRow | 199 and 193 of 400 | 197 and 200 of 200 |
pool.Acquire, set and query on that connection, Release | 0 of 400 | 196 and 200 of 200 |
PrepareConn sets, nothing resets | 0 of 400 | 198 and 200 of 200 |
PrepareConn sets, AfterRelease resets | 0 of 400 | 0 of 200 |
pgx.BeginFunc or db.BeginTx, set_config(…, true) first | 0 of 400 | 0 of 200 |
pool.SendBatch with set_config(…, true) first | 0 of 400 | 0 of 200 |
The ninth variant, set_config(…, true) in a pool.Exec followed by pool.QueryRow, returned no rows for any request.
Why do Exec and QueryRow on the pool go wrong?
Because pool.Exec and pool.QueryRow each acquire a connection, run one statement and give it back. pool.Exec sets the tenant on whichever connection it got; pool.QueryRow runs on whichever connection it gets next. One at a time that is the same connection, which is why, one at a time, it looks like an ordinary leak: 20, 20, 10, 10. With 30 requests in flight it is whichever connection is free, set last by whichever request had it, and with two tenants that was the wrong tenant about half the time: a request for tenant 1 counts 10 invoices. database/sql does the same thing with db.Exec and db.QueryRow.
set_config(…, true) in a pool.Exec returned no rows at all, for every request. The setting is transaction-local, that pool.Exec is its own transaction, and the setting ended with it. That errs on the safe side, and is useless.
Acquiring one connection and doing both on it fixes the requests that set a tenant, and nothing else: the setting stays on the connection when it goes back to the pool, and the next request without a tenant reads it. In both runs nearly every such request saw a customer’s invoices.
Do hooks on the pool work?
Against PostgreSQL directly, yes — if there are two of them, and if every request acquires its own connection. pgxpool.Config has a hook that runs when a connection is handed out and one that runs when it comes back. In pgx 5.11 the first is PrepareConn; the older BeforeAcquire is marked in the source as “Deprecated: Use PrepareConn instead”.
cfg.PrepareConn = func(ctx context.Context, conn *pgx.Conn) (bool, error) {
if t := tenantFrom(ctx); t != "" {
_, err := conn.Exec(ctx, "select set_config('app.tenant', $1, false)", t)
return err == nil, err
}
return true, nil
}
cfg.AfterRelease = func(conn *pgx.Conn) bool {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := conn.Exec(ctx, "reset app.tenant")
return err == nil
}
With only PrepareConn, a request whose context carried no tenant read the previous one’s: 198 and 200 of 200. With AfterRelease as well, all 1,200 requests in the two runs were right. A PrepareConn that always sets the value, an empty string when the context has no tenant, did the same without the reset. We measured the reset without the timeout shown here; pgx runs AfterRelease in a goroutine of its own, so a reset that hangs would otherwise hold a connection without anyone noticing.
Three things this variant does not give you.
The hook reads the context of the acquire, not of the statement. On a connection taken with pool.Acquire for tenant 1, a query whose context said tenant 2 counted 20, and so did a query whose context had no tenant. Anything that holds a connection across two requests keeps the first one’s tenant.
It gives you a tenant, not a transaction. An insert through pool.Exec is committed at once, and a handler that fails afterwards leaves it there: 11 invoices instead of 10. pool.Begin with the request’s context did work with the hooks; an insert in it that was rolled back left 10.
It is a session-level setting, which is exactly what a transaction pooler cannot carry.
What happens to the hooks behind PgBouncer?
They stop working. We put PgBouncer in transaction mode between the same program and the same database, restarted it before each variant so that no setting was left from the one before, and ran the same 600 requests.
| How the tenant is set, through PgBouncer | Tenant requests with their own rows | Another customer’s rows | No rows | Requests without a tenant that saw rows |
|---|---|---|---|---|
PrepareConn sets, AfterRelease resets | 96 and 100 of 400 | 67 and 82 | 237 and 218 | 72 and 85 of 200 |
PrepareConn always sets, no reset | 138 and 171 of 400 | 130 and 119 | 132 and 110 | 120 and 116 of 200 |
pool.Acquire, set and query on that connection | 213 and 216 of 400 | 181 and 184 | 6 and 0 | 199 and 200 of 200 |
pgx.BeginFunc or db.BeginTx, set_config(…, true) first | 400 of 400 | 0 | 0 | 0 of 200 |
pool.SendBatch with set_config(…, true) first | 400 of 400 | 0 | 0 | 0 of 200 |
A pgx connection to PgBouncer is not a PostgreSQL session. In transaction mode PgBouncer picks a server connection per transaction, and the hook’s set_config, the query and the hook’s reset are three transactions. We had each request record the backend process ID in the hook and again in the query, in a separate trace of 400 tenant requests, 30 at a time, run twice. 116 and 100 of them found their own tenant where the query ran. 251 and 276 got the wrong answer with their query on a different backend than their hook. 16 and 18 ran on the same backend as their hook and found the setting emptied, which only another request’s reset can have done, and 17 and 6 found another tenant’s value there. Pinning a pgx connection with pool.Acquire does not help for the same reason.
- PrepareConn
set_config('app.tenant', '2', false). PgBouncer runs it on backend 584.Backend 584: tenant 2 - Query
select count(*) from invoices. PgBouncer runs it on backend 583, where the setting is1.The request counts 20: tenant 1's invoices - AfterRelease
reset app.tenant, on whichever backend PgBouncer picks.That backend loses the tenant another request may just have set there
Steps one and two are one request from our trace: hook on backend 584, query on backend 583, setting there 1, count 20. The trace records the value on backend 583, not which request put it there. Step three is what the 16 and 18 requests that found their setting emptied on their own backend ran into; we did not trace which reset emptied which.
What works?
A transaction per request that sets the tenant as its first statement, with every query of the request going through tx:
func withTenant(ctx context.Context, pool *pgxpool.Pool, tenant string,
fn func(tx pgx.Tx) error) error {
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
"select set_config('app.tenant', $1, true)", tenant); err != nil {
return err
}
return fn(tx)
})
}
The setting ends with the transaction. It gave 20, 0, 10, 0, no wrong count in 1,200 concurrent requests directly and none through PgBouncer. db.BeginTx in database/sql did the same. It also held on each failure we tried: when fn returned an error after inserting an invoice the count stayed 10; when fn panicked, the count stayed 10; when the context’s deadline passed in the middle of a slow statement, BeginFunc returned timeout: context deadline exceeded and the count stayed 10. An insert for tenant 1 with the tenant set to 2 failed with new row violates row-level security policy for table "invoices".
Always pass the tenant, even when it is empty. The function above calls set_config with whatever it is given, and that matters. A transaction that sets nothing reads what is on the connection. We left a session-level set_config(…, false) on a pool’s connection — one forgotten code path, a migration tool, a debugging session — and then ran 200 transactions without a tenant that skipped the set_config: all 200 saw rows. The same 200 with set_config('app.tenant', '', true) as their first statement: none. Through PgBouncer it was the same: after one run of a leaking variant, 200 of 200 transactions that set nothing saw a customer’s rows, and 0 of 200 of those that set an empty tenant.
Every query goes through tx. A query sent to pool inside fn runs on another connection, outside the transaction and without the tenant. On a clean pool it counted 0 where tx counted 10; on a pool with tenant 1 left on its connections it counted 20, tenant 1’s invoices, inside tenant 2’s request. And it needs a second connection while the first is held: with 30 such requests on a pool of 10, all 30 waited for a connection until their three-second deadline and failed.
The tenant is text. set_config takes a string; passing the Go integer 2 as the argument failed in pgx with unable to encode 2 into text format for text. Format the ID first.
For a single query, a batch does it in one round trip. pgx sends the statements of a Batch together, and they succeeded or failed together in our test: a batch of set_config(…, true), an insert and a select 1/0 returned the division error and left the count at 10.
b := &pgx.Batch{}
b.Queue("select set_config('app.tenant', $1, true)", tenant)
b.Queue("select count(*) from invoices")
br := pool.SendBatch(ctx, b)
defer br.Close() // releases the connection
if _, err := br.Exec(); err != nil {
return 0, err
}
var n int
if err := br.QueryRow().Scan(&n); err != nil {
return 0, err
}
return n, br.Close()
It gave the right rows in every run, directly and through a freshly started PgBouncer. The same rule holds as for the transaction: the batch we measured left out the set_config for a request without a tenant, and after a run of a leaking variant those requests saw rows 200 times out of 200. The results come back in the order they were queued, so the br.Exec() for the set_config cannot be skipped. And a batch only fits requests whose statements do not depend on each other’s results; anything that reads, decides and then writes needs the transaction.
What does it cost?
One query per request, 2,000 requests one after another, median of five series, database on the same machine. Measured twice, a few minutes apart:
| Variant | Round trips | Per request |
|---|---|---|
pool.SendBatch: set_config(…, true) and the query | 1 | |
pool.Exec set_config(…, false), then pool.QueryRow (leaks) | 2 | |
PrepareConn sets, AfterRelease resets | 3 | |
pgx.BeginFunc, set_config(…, true) first | 4 | |
database/sql: db.BeginTx, set_config(…, true) first | 4 |
The line under each number shows it against the largest in the column; a range is drawn at its upper end. The time follows the round trips, not the variant: begin, set, query and commit are four, the batch is one. On a network with a millisecond between application and database, the difference between one and four round trips is the number to look at, not these fractions. A request that runs ten queries in its transaction pays the three extra round trips — begin, set_config, commit — once.
What we did not measure
Other Go database libraries — GORM, sqlc, Bun, ent, sqlx — each of which adds its own way of holding a connection; lib/pq; pgx’s simple-protocol and other query modes; PgBouncer in session mode and other poolers; more than two tenants; and a pool larger than the number of concurrent requests. The tables hold 30 rows; which rows come back follows from which connection carries which setting, not from the size of the table. Whether pgx’s statement cache hands one tenant’s query plan to the next is the same question as in PgBouncer and RLS: tenant context, SET LOCAL and shared plans, and we did not measure it again here; through PgBouncer 1.26 the default statement cache gave no errors in any run.
The same question for five other stacks, and everything else we measured about row-level security, is indexed in PostgreSQL row-level security for SaaS: what we measured.
Sources: the pgx 5.11.0 source, pgxpool/pool.go (the comments on BeforeAcquire, PrepareConn and AfterRelease) and pgxpool/conn.go (Release), read on 12 October 2026. All measurements are our own, on pgx 5.11.0, Go 1.25.14, PostgreSQL 17.11 and PgBouncer 1.26.0, on 12 October 2026.