Skip to main content

Render Just Made PgBouncer Free. Here's the Connection-Pooling Homework You Inherit When You Self-Host Postgres

11 min readDora NodaDora Noda
Share
On this page

On July 1, 2026, Render quietly added a line to its changelog: paid Render Postgres databases now support connection pooling using PgBouncer at no additional cost. Flip a toggle, restart the database, point your app at port 6432 instead of 5432, and your database can absorb thousands more client connections than its instance type would otherwise allow.

That toggle is doing more work than it looks like. Behind it, Render made about seven engineering decisions on your behalf — which pooler to run, which pooling mode, how to size the pool, where to run it, how to authenticate through it, how to handle prepared statements, and how to keep the whole thing patched. If you're self-hosting Postgres — because you migrated off a managed platform, or because your platform (like a self-hosted PaaS) treats the database as your workload — you inherit every one of those decisions as homework.

This post is the answer key and the homework in one: exactly what Render chose, why, and a complete, copy-pasteable configuration for reproducing it next to your own Postgres.

What Render Shipped — and the Seven Decisions Hidden Inside It

Render's own documentation is refreshingly specific about what the toggle does. Their bundled pooler is:

  1. PgBouncer — not pgcat, not Supavisor, not a homegrown proxy.
  2. Transaction pooling mode (pool_mode = transaction) — connections are borrowed per transaction, not per session.
  3. Co-located on the same host as the database — to minimize added latency.
  4. Listening on port 6432, with direct connections still available on 5432.
  5. Sized by formula: default_pool_size and max_db_connections are set to the database's max_connections minus 10, keeping 10 direct slots free for admin access and migrations.
  6. Fronted by a huge client ceiling: max_client_conn = 30000, with a client_idle_timeout of 86,400 seconds (24 hours).
  7. Free on paid instances only — connection pooling is not available for free databases, and enabling it requires a restart.

That list is your syllabus. Each line is a decision you now own when you self-host, and a few of them — mode selection and prepared statements especially — have sharp edges that a managed toggle deliberately hides. Let's work through them in order of how badly they can hurt you.

Why Pooling Is Non-Optional for Postgres

Postgres uses a process-per-connection model: every client connection forks a backend process on the server, each carrying real memory overhead (commonly several megabytes once caches warm up) and real scheduler cost. That's why the default max_connections is a conservative 100, and why DBAs resist raising it into the thousands — beyond a point, more connections make the database slower, not more available.

Modern application shapes collide with that model head-on:

  • Horizontal app scaling: 20 app instances × a 20-connection client-side pool = 400 connections against a database that comfortably handles 100.
  • Serverless and per-request runtimes: every function invocation may want its own connection, producing connection storms measured in thousands.
  • Deploy rollovers: during a rolling deploy, old and new app instances hold connections simultaneously, briefly doubling your footprint at exactly the moment you least want failures.

A transaction-mode pooler collapses all of that. Thousands of mostly-idle client connections multiplex over a few dozen real backend connections, because at any instant only the clients actively inside a transaction need a server slot. This is the entire trick — and it's why Render can advertise a 30,000-client ceiling in front of a database whose max_connections might be 120.

The homework starts with accepting that this layer is not optional. If you self-host Postgres for anything beyond a single small app, you will run a pooler. The only questions are which one, in which mode, and with which settings.

Decision One: PgBouncer, pgcat, or Something Bigger?

Render picked PgBouncer, and for a single-database deployment that's the boring, correct default. But it's worth knowing what the alternatives buy you, because self-hosting means you actually get to choose.

PgBouncerpgcatSupavisor
Language / modelC, single-threaded, event-drivenRust, multi-threaded (Tokio)Elixir (+ Rust for SQL parsing)
Pooling modessession, transaction, statementsession, transactiontransaction-oriented, cloud-scale
Prepared statements in transaction modeYes, since 1.21.0 (max_prepared_statements)YesYes (named prepared statements)
Read/write query routingNoYes — parses queries, routes SELECTs to replicasYes — load balancing across clusters
Failover handlingNo (external tooling)Built-in health checks, bans failed replicasCluster-native
ShardingNoExperimentalNo
Production pedigreeThe de-facto standard, ~20 years oldInstacart, PostgresML, OneSignal; hundreds of thousands of queries/secPowers every Supabase project; built for millions of connections

The honest decision rule:

  • One primary database, one box or one small cluster: PgBouncer. It's the most battle-tested option, it's what your managed platform ran, and its limitations are well-documented rather than discovered in production.
  • Primary plus read replicas, and you want the pooler to do the routing: pgcat. Its query parser sends SELECTs to replicas and writes to the primary, with health checks that automatically ban an unresponsive replica — capabilities PgBouncer simply doesn't have.
  • A multi-tenant platform fronting many databases at enormous connection counts: that's the problem Supavisor was built for, and it's almost certainly more machinery than a single team's stack needs.

One PgBouncer caveat worth knowing before you commit: it's single-threaded. One instance uses one CPU core, period. When it saturates — 100% CPU on the PgBouncer process, queries queueing at the pooler while the database sits underutilized — the standard fix is running multiple PgBouncer instances on the same port via the Linux SO_REUSEPORT socket option, letting the kernel load-balance across them. Crunchy Data documents this pattern with templated systemd units. It works well, but it's another piece of homework Render's toggle never showed you.

Decision Two: Transaction Mode vs. Session Mode — and What Silently Breaks

Render chose pool_mode = transaction, and so should you — it's the only mode that delivers the multiplexing math above. But transaction pooling works by handing your "connection" to a different backend for every transaction, which quietly breaks every Postgres feature that assumes session-scoped state.

Per PgBouncer's own feature matrix, broken in transaction mode:

  • SET / RESET (session-level settings like statement_timeout set via SQL)
  • LISTEN (the listening side of pub/sub)
  • WITH HOLD cursors
  • Protocol-level PREPARE / DEALLOCATEunless you do the prepared-statement homework below
  • Session-level advisory locks — the classic footgun, because migration tools like Rails' migrator and golang-migrate use them to serialize schema changes
  • PRESERVE / DELETE ROWS temp tables

Still works: NOTIFY (sending), WITHOUT HOLD cursors, ON COMMIT DROP temp tables, and transaction-level advisory locks (pg_advisory_xact_lock).

The failure mode here is nasty precisely because it's silent. A session SET lands on one backend; your next transaction runs on a different one without it. An advisory lock taken by your migration tool guards nothing. Nothing errors — behavior just becomes wrong, intermittently, under load.

Render's design shows the standard mitigation, and it's the one to copy: keep both doors open. The pool lives on 6432, the direct connection stays on 5432, and anything that needs session semantics — migrations, LISTEN-based workers, admin sessions — connects directly. This is also why Render's sizing formula reserves those 10 direct connections: your migration deploy step should never have to fight the app pool for a slot.

Decision Three: The Prepared-Statement Trap

This one deserves its own section because it's the most common way a working app breaks the day it moves behind a pooler.

Most Postgres drivers use protocol-level prepared statements by default — the extended query protocol that plans a query once and executes it repeatedly. Prepared statements live on a specific backend connection. In transaction mode, your next transaction may run on a different backend, which has never heard of statement S_1, and the driver gets prepared statement "S_1" does not exist.

For most of PgBouncer's life, the fix was to disable prepared statements client-side, and you'll still find that advice fossilized in ORM docs: prepareThreshold=0 for JDBC, statement_cache_size=0 for asyncpg, prepared_statements: false in various ORMs, PreferSimpleProtocol for pgx. It worked, at the cost of replanning every query.

Since PgBouncer 1.21.0 (October 2023) there's a better answer: set max_prepared_statements to a non-zero value and PgBouncer itself tracks protocol-level prepared statements across backends, re-preparing them on whichever server connection your transaction lands on. It was arguably the most-requested feature in the project's history, and current releases ship with a default of 200. pgcat and Supavisor support the equivalent natively.

Your homework, concretely: run PgBouncer ≥ 1.21 (current is 1.25.x), leave max_prepared_statements at 200 or size it to your ORM's statement-cache setting, and only reach for the old client-side kill switches if you're stuck on an older pooler. Note the boundary: this covers protocol-level prepares; SQL-level PREPARE statements remain broken in transaction mode.

The Full Homework: A pgbouncer.ini That Matches What Render Gave You

Here is the whole assignment in one file — a complete, runnable configuration reproducing the shape of what Render's toggle deploys, for a Postgres with max_connections = 120:

ini
;; /etc/pgbouncer/pgbouncer.ini
[databases]
;; one entry per database; pool connects to local Postgres on 5432
myapp = host=127.0.0.1 port=5432 dbname=myapp
 
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432            ; Render's convention: pool on 6432, direct on 5432
 
;; --- the decision Render made for you: transaction pooling ---
pool_mode = transaction
 
;; --- sizing, Render-style: max_connections (120) minus 10 ---
default_pool_size = 110
max_db_connections = 110
max_client_conn = 30000       ; raise OS file-descriptor limits to match
client_idle_timeout = 86400
server_login_retry = 2
 
;; --- the prepared-statement fix (PgBouncer >= 1.21) ---
max_prepared_statements = 200
 
;; --- auth: match your Postgres password_encryption ---
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
;; --- observability ---
admin_users = pgbouncer_admin
stats_users = metrics

And userlist.txt, which maps users to their SCRAM verifiers (copy each verifier straight out of Postgres with SELECT usename, passwd FROM pg_shadow):

text
"myapp_user" "SCRAM-SHA-256$4096:...$...:..."
"pgbouncer_admin" "SCRAM-SHA-256$4096:...$...:..."

That file gets you to parity with the toggle. The rest of the homework is everything Render's managed layer does invisibly:

  • Process supervision: a systemd unit with Restart=on-failure, so a pooler crash is a blip, not an outage. On Render, someone else's pager covers this.
  • The single-core ceiling: watch CPU on the PgBouncer process. At sustained 100%, scale out with additional instances on the same port via SO_REUSEPORT before the pooler becomes your bottleneck.
  • Monitoring: connect to the special pgbouncer admin database and scrape SHOW POOLS, SHOW STATS, and SHOW CLIENTS. The metric that matters most is cl_waiting — clients queued for a server connection — and maxwait, how long the head of the queue has been waiting. Rising maxwait is your earliest signal the pool is undersized.
  • Patch cadence: PgBouncer 1.25.2 (May 2026) fixed four CVEs (CVE-2026-6664 through 6667); 1.25.1 patched a SCRAM-related security issue. A pooler is a network-exposed credential-holding proxy — treat its upgrades like Postgres upgrades, not like optional tooling.
  • The direct-connection escape hatch: keep 5432 reachable (firewalled to trusted sources) for migrations, LISTEN workers, and anything else on the breakage list above — and point your migration tooling at it explicitly.

The Real Cost of "Free"

Render's announcement is genuinely good news for its customers, and the pricing is honest: PgBouncer was always free software. What you were paying for — what you're always paying a managed platform for — was the seven decisions and the invisible operations around them: the mode choice, the sizing formula, the prepared-statement flag, the restarts, the patches, the pager.

Self-hosting doesn't make that cost disappear; it makes it visible and puts it on your side of the ledger, in exchange for control and a much smaller bill at scale. The encouraging part is how bounded the homework actually is. Connection pooling is one config file, one decision framework, and two or three known footguns — all documented above, none of them secret. The gap between "managed" and "self-hosted" Postgres is not a moat; it's a syllabus.

And increasingly, the platforms on the self-hosted side ship the same conveniences. The pattern Render just productized — pooler co-located with the database, transaction mode, a formula-sized pool, an escape hatch for session semantics — is exactly the shape you should reproduce, whether by hand with the config above or through whatever platform runs your Postgres.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a Render-compatible API. Your database stays your workload, and homework like this stays a config file you control. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex