Now-Next

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.

Chris van Eijk, Now-Next · · 10 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 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, policy using (tenant_id = nullif(current_setting('app.tenant', true), '')::int). Go connects as an ordinary role, not the table owner. Why the policy has nullif in it is in current_setting and set_config in PostgreSQL, measured.
  • A pgxpool.Pool with pool_max_conns=10, or a database/sql pool 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 the set_config call 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 setTenant requests that counted another customer’s invoicesRequests without a tenant that saw rows
pool.Exec set_config(…, false), then pool.QueryRow193 and 178 of 400196 and 200 of 200
database/sql: db.Exec, then db.QueryRow199 and 193 of 400197 and 200 of 200
pool.Acquire, set and query on that connection, Release0 of 400196 and 200 of 200
PrepareConn sets, nothing resets0 of 400198 and 200 of 200
PrepareConn sets, AfterRelease resets0 of 4000 of 200
pgx.BeginFunc or db.BeginTx, set_config(…, true) first0 of 4000 of 200
pool.SendBatch with set_config(…, true) first0 of 4000 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 PgBouncerTenant requests with their own rowsAnother customer’s rowsNo rowsRequests without a tenant that saw rows
PrepareConn sets, AfterRelease resets96 and 100 of 40067 and 82237 and 21872 and 85 of 200
PrepareConn always sets, no reset138 and 171 of 400130 and 119132 and 110120 and 116 of 200
pool.Acquire, set and query on that connection213 and 216 of 400181 and 1846 and 0199 and 200 of 200
pgx.BeginFunc or db.BeginTx, set_config(…, true) first400 of 400000 of 200
pool.SendBatch with set_config(…, true) first400 of 400000 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.

One request for tenant 2 with pool hooks, through PgBouncer in transaction mode: where each statement lands
  1. PrepareConnset_config('app.tenant', '2', false). PgBouncer runs it on backend 584.
    Backend 584: tenant 2
  2. Queryselect count(*) from invoices. PgBouncer runs it on backend 583, where the setting is 1.
    The request counts 20: tenant 1's invoices
  3. AfterReleasereset 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:

VariantRound tripsPer request
pool.SendBatch: set_config(…, true) and the query10.06 ms
pool.Exec set_config(…, false), then pool.QueryRow (leaks)20.08–0.09 ms
PrepareConn sets, AfterRelease resets30.10 ms
pgx.BeginFunc, set_config(…, true) first40.14–0.15 ms
database/sql: db.BeginTx, set_config(…, true) first40.14–0.15 ms

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.

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