A service that autoscales on request rate scales its connection count along with it. The database’s connection ceiling does not move. The two meet at a point nobody chose, and the symptom rarely looks like a connection problem.
The failure has a recognizable shape. Traffic climbs, target tracking adds tasks, and latency gets worse. Someone adds more tasks, because that is what the runbook says to do, and it gets worse again. The database CPU is unremarkable. Query latency is unremarkable. What has actually happened is that the fleet has opened more connections than the instance will accept, and new tasks are now failing to connect while the ones that already hold connections wait behind them.
The reason this is easy to miss is that no single number involved looks wrong. Thirty tasks is not a lot. Four workers per task is correct. A pool limit of ten is conservative. It is the product that is wrong, and nothing in the system reports the product.
The ceiling is made of memory, not of traffic
On Aurora PostgreSQL, max_connections is not a round number somebody picked. The
default parameter value is a formula:
LEAST({DBInstanceClassMemory/9531392},5000)Two things follow from that, and both matter more than the formula itself.
One connection costs roughly 9.1 MiB of instance memory. That is the divisor.
PostgreSQL forks a backend process per connection, and AWS sizes the default ceiling so
those processes cannot exhaust the instance. So the ceiling scales with the instance
class — a 16 GiB instance lands somewhere near 1,800 connections. Near, not exactly:
DBInstanceClassMemory is the memory available to the engine, which is a little below
nominal RAM. Run SHOW max_connections; against your own cluster rather than trusting
my arithmetic.
The ceiling stops at 5,000, whatever you buy. Past roughly 45 GiB of instance
memory the LEAST clause binds and the formula stops mattering. This is the part that
surprises people midway through an incident: scaling the writer up a class is a real
lever until it abruptly isn’t, and there is no instance class that gets you to 8,000
connections.
Three of those slots are not yours either. superuser_reserved_connections defaults to
3 and is not modifiable, which is what keeps a saturated cluster reachable at all.
The floor is made of multiplication
Now the other side. A containerized Python service typically runs one worker process per vCPU, and connection pools are held per worker process, not per task. So the connection count is a product of three independent numbers:
Each term is individually reasonable; only the product exceeds the ceiling. Of the three, autoscaling owns the first, the instance size owns the second, and you own the third.
Two of those terms are not under your control at the moment it matters. Task count is set by the scaling policy, which is to say by your users. Workers per task is set by the CPU allocation. The pool limit is the only term you get to choose, and it is therefore the term that absorbs every mistake in the other two.
A detail worth checking before you trust your own numbers: don’t let the worker count
come from $(nproc) inside a container. Under Kubernetes CPU limits it generally
reports the node’s core count rather than your allocation, so a 2 vCPU pod on a 64-core
node will happily start 64 workers, each with its own pool. That single line turns a
comfortable budget into an outage.
Why the obvious fix is a trap
The arithmetic suggests an obvious response: lower the pool limit until the product fits. That works, right up until it doesn’t, because the pool limit is not free to shrink. Each worker’s pool is what lets it overlap concurrent requests against the database. Drive it to two and you have not solved the connection problem — you have converted it into a queueing problem inside every worker, where it is much harder to see. The calculator above says “fits” and then tells you it starves; both are true.
That is the squeeze. Above some fleet size there is no pool limit that is simultaneously small enough for the ceiling and large enough for the worker. The honest read is that per-worker pooling has stopped being the right architecture, not that you picked a bad number.
RDS Proxy, and the trap inside that
Amazon RDS Proxy is the structural answer: it holds the database connections and multiplexes many client connections over them, so task count stops translating one-for-one into backend connections. It is the right tool. It also has a failure mode that will quietly give you none of the benefit.
When RDS Proxy cannot safely hand a database connection to a different client — because the session carries state that would leak — it pins the client to that connection for the rest of the session. A pinned connection is an ordinary connection with extra hops. Pin everything and you have paid for a proxy that multiplexes nothing.
For Aurora PostgreSQL, AWS documents the pinning triggers. Abridged to the ones an application framework hits by accident:
| Trigger | Where it usually comes from |
|---|---|
SET commands, or set_config | Session setup: timezone, search_path, statement timeouts |
PREPARE, EXECUTE, DEALLOCATE, DISCARD | Prepared-statement management in the driver |
| Temporary tables, sequences or views | Migration tooling, some ORM query patterns |
| Declaring cursors | Streaming large result sets |
nextval / setval | Sequence manipulation outside an insert |
pg_advisory_lock | Application-level locking, leader election, migrations |
| Listening on a notification channel | LISTEN/NOTIFY job queues |
Two entries deserve to be pulled out.
DISCARD ALL as a pool reset query. Several connection-pooling libraries are
configured to issue DISCARD ALL when returning a connection to the pool. It is a
sensible hygiene default, and against RDS Proxy it pins your client connection on
release. The library that exists to conserve connections is what stops the proxy
conserving them.
Advisory locks, but not all of them. pg_advisory_lock pins. The transaction-scoped
variants — pg_advisory_xact_lock, pg_try_advisory_xact_lock and their shared forms —
do not, because their state cannot outlive the transaction. If you hold advisory locks
for migrations or leader election, that is a small change with a large effect.
There is also a piece of good news that invalidates older advice. Pinning on PostgreSQL
Extended Query Protocol — the A parse message was detected log line, which many
drivers and ORMs triggered on essentially every query — is handled: RDS Proxy
multiplexes Extended Query Protocol automatically, with nothing to enable. If you
evaluated RDS Proxy behind a modern driver a while ago, measured near-total pinning and
concluded it was incompatible, that conclusion is worth re-testing.
What to do, in order
This ordering is my engineering assessment rather than a benchmark. It runs cheapest and most reversible first.
- Compute the product. Tasks at maximum scale, times workers per task, times pool
limit. Compare against
SHOW max_connections;, not against the instance class you remember buying. Most teams have never written this number down. - Set the worker count explicitly. Never
$(nproc)in a container. - Get writes off the request path. Anything appended per request — spend ledgers, audit rows, metrics — should batch on an interval or buffer through a cache rather than opening the transaction inline. This usually buys more headroom than pool tuning does, because it shortens how long each connection is held.
- Tune the pool limit to the 80% budget, and treat a limit at or below two as a signal that you have outgrown per-worker pooling rather than as a solution.
- Add RDS Proxy, then immediately check the pinning ratio under load. Fix the reset query and the advisory-lock variants before concluding it doesn’t work.
- Only then consider a larger instance class, remembering the 5,000 ceiling is waiting at the end of that road.
The general lesson is smaller than the mechanics: when a system autoscales one side of a relationship and not the other, the fixed side becomes the capacity limit, and it will be discovered at the worst possible moment by the autoscaler itself. Connection counts are the most common instance of that pattern, not the only one.
Sources
- Aurora PostgreSQL parameters —
max_connectionsdefault formula andsuperuser_reserved_connections - Avoiding pinning an RDS Proxy — the full list of pinning conditions
- RDS Proxy multiplexing support for PostgreSQL Extended Query Protocol
- Performance impact of idle PostgreSQL connections
- Monitoring RDS Proxy metrics with CloudWatch