Skip to content
article

Your Go service has 600 Postgres connections and uses 6 of them

Pool sizing, PgBouncer transaction mode, and the prepared statement error that follows you there

Danny Cho@danny_cho_la

August 28, 2026 · 3 min read

Last month a routine scale-up took down our orders database. Not CPU, not disk. Connections. Postgres said FATAL: sorry, too many clients already and every service that touched it fell over together.

How we got there

40 pods of one Go service, each with a pgx pool. Nobody had set MaxConns, so pgxpool used its default: the larger of 4 and runtime.NumCPU(). Our nodes have 16 cores. 40 times 16 is 640 possible connections against a max_connections of 500. It worked for months only because the pools rarely filled up. Then we scaled to 40.

A detail that surprised a few people: newer Go versions are smarter about setting GOMAXPROCS from container CPU limits, but runtime.NumCPU() still reports the CPUs the process can see, which in most containers is the whole node. pgxpool looks at NumCPU. Fixing GOMAXPROCS does not fix this.

Measure first

SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'orders'
GROUP BY state;

612 idle, 6 active. Each of those idle connections is a full Postgres backend process with its own memory. We were paying for 600 cashiers to serve 6 customers.

Fix 1: size the pool on purpose

cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
	return nil, err
}
cfg.MaxConns = 8
cfg.MinConns = 1
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.MaxConnLifetime = time.Hour
cfg.HealthCheckPeriod = time.Minute

pool, err := pgxpool.NewWithConfig(ctx, cfg)

The budget rule we use now: replicas × MaxConns must stay below max_connections minus room for migrations, admin sessions and the next service someone adds. The pool size comes from the Helm values next to the replica count, so the person who changes one sees the other. For the total number of active connections, the old PostgreSQL wiki rule of thumb (around twice the database cores, plus a bit for IO) is still a sane starting point. More active connections than that and they mostly wait on each other.

Fix 2: PgBouncer in transaction mode

[databases]
orders = host=10.0.3.12 port=5432 dbname=orders

[pgbouncer]
listen_port = 6432
pool_mode = transaction
default_pool_size = 20
max_client_conn = 2000
max_prepared_statements = 200
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

In transaction mode a server connection is only held for the length of a transaction, so 2,000 client connections share 20 real ones.

The error that follows you there

The moment we switched, we got prepared statement "stmtcache_..." already exists. pgx by default prepares and caches statements per connection. In transaction mode your next query may land on a different server connection, where that statement does not exist, or does under the same name. Two ways out:

  • PgBouncer 1.21 and newer can track prepared statements in transaction mode. Set max_prepared_statements (above) and keep pgx's default mode.
  • On an older PgBouncer, tell pgx not to rely on server-side statements: cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec (or the simple protocol).

And know what transaction mode breaks: session-level SET, session advisory locks, LISTEN/NOTIFY, and temp tables that live across transactions.

Result

612 server connections down to about 160 across all services, p99 latency unchanged, and the database got back about 5 GB of memory it was spending on idle processes. My father ran a dry cleaner with two counters and one presser for 31 years. He never hired a person to stand next to the presser in case it got busy.

Written by

Danny Cho

Staff eng in LA. Korean-American, grew up above my parents' dry cleaner in Koreatown. Distributed systems, Go, mentoring, Dodgers. Ask me about boring tech.

Get the best of #premature-optimisation weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

8

Sign in to join the conversation.

Sign in to comment
Bram Verhoeven@bram_on_a_bike#2 in Premature Optimisation
The NumCPU versus GOMAXPROCS detail is excellent. Most people stop at automaxprocs and think they fixed it.
7 net votes
Arjun Mehta@arjunmehta#2 in Premature Optimisation
Sizing formula saved us also. We had 30 pods × 10 conns on a 4 vCPU RDS. One more caveat: if you use session-level advisory locks for cron leader election, transaction mode breaks it silently. Use pg_try_advisory_xact_lock inside a transaction instead.
4 net votes
Danny Cho@danny_cho_la#1 in Premature Optimisation
Good addition. We moved leader election out of Postgres entirely for that reason, but xact locks are the right answer if you keep it there.
4 net votes
Emeka Nwosu@emeka_golang#2 in Premature Optimisation
The prepared statement error after adding PgBouncer is a rite of passage. Cost me one full night. max_prepared_statements did not exist for us then, we switched pgx to exec mode and cried small
4 net votes
Jordan Pike@jpike#2 in Premature Optimisation
excellent post. we compute the pool size in the helm chart from a per-service connection budget, so nobody can scale a deployment to 80 replicas at 3am and take the db down with it. budgets beat good intentions.
6 net votes
Sai Teja Reddy@teja_dataeng#2 in Premature Optimisation
super anna. same issue with airflow workers and sqlalchemy pools, 5 per worker × 40 workers, dba was not happy
2 net votes
Mio Kobayashi@mio_rustacean#2 in Premature Optimisation
sqlx in rust has the same trap btw, default max_connections 10 per pool, times replicas. nobody reads the defaults 🦀
6 net votes
Nurul Aisyah@nurul_sql#2 in Premature Optimisation
the pg_stat_activity group by state query, simple but confirm useful. ran it on our reporting db, 140 idle from one Power BI gateway lah
5 net votes