Connection Pooling skill for Claude Code
Pools sized to database cores, not request counts. Claude writes down every process that connects and sums its maximum connections against the database limit at full autoscale before touching a setting. Pools are sized from database parallelism, PgBouncer transaction mode is configured with its session-state caveats and prepared statement support, serverless platforms get a proxy, and exhaustion is diagnosed from pg_stat_activity and SHOW POOLS.
- name
connection-pooling - size173 lines · ≈2,150 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Sizes and debugs database connection pools end to end - application pools (HikariCP, node-postgres, SQLAlchemy, Prisma, Go database/sql), PgBouncer session vs transaction mode with prepared statements, max_connections budgets across replicas and workers, serverless and edge connections (RDS Proxy, Supavisor, Neon, Hyperdrive), and diagnosing "too many connections", pool timeouts, and idle-in-transaction leaks. Use when connections run out, requests hang waiting for a connection, deploying to Lambda or edge functions, adding PgBouncer, or choosing pool sizes. Try asking
- We keep hitting FATAL: sorry, too many clients already after scaling to 12 pods
- Put PgBouncer in front of Postgres for our Django app without breaking anything
- Our Lambda functions exhaust RDS connections under load - fix the setup
Use it when
- The database rejects connections or requests time out waiting for a pool
- Adding PgBouncer, RDS Proxy, Supavisor or Hyperdrive
- Deploying database clients to Lambda or edge functions
- Choosing pool sizes for a new service or after scaling out
Skip it when
- Connections are fine but individual queries are slow - use sql-query-tuning
- Sessions block each other on row locks - use transactions-locking
How to check the result
A written budget keeps total max connections under 80% of max_connections at full scale, and a load test at peak plus 50% shows no acquire timeouts and no long idle-in-transaction sessions.
The common mistake it prevents
Raising pool sizes to cure timeouts: more connections let more slow queries compete for the same CPU and locks, so latency rises and the database tips over sooner.
A pool setup is right when the sum of every client’s maximum connections stays under the database limit with headroom, each pool is sized to what the database can execute in parallel (not to request concurrency), connections are returned promptly, and timeouts fail fast with a clear error instead of hanging.
Workflow
- [ ] 1. Draw the connection topology: every process that connects, its pool max, replicas/instances
- [ ] 2. Compute the connection budget against max_connections
- [ ] 3. Size each pool from database parallelism, not traffic
- [ ] 4. Decide whether an external pooler is needed (and which mode)
- [ ] 5. Set timeouts on acquire, idle, lifetime, statement, and idle-in-transaction
- [ ] 6. Reproduce exhaustion under load; check for leaks
- [ ] 7. Monitor pool wait time and server connection counts
1-2. Connection budget
total = sum over services (instances x workers_per_instance x pool_max)
+ migration/cron/admin tools + replication + superuser_reserved_connections
total must be <= max_connections x 0.8
Example: 6 pods x 4 Gunicorn workers x pool 10 = 240 connections before cron jobs. On a
database with max_connections = 200, this fails during the next scale-out. Autoscaling
multiplies the first term; size for the maximum replica count, not the current one.
Postgres connections are processes (several MB each, more with work_mem use). Hundreds of
mostly-idle connections still cost memory and snapshot overhead; beyond a few hundred,
add a pooler rather than raising max_connections.
3. Pool size
Start small. Throughput peaks when active connections roughly equal what the database can
run at once - about 2 x CPU cores (plus disk parallelism) on the database server, shared
across all clients. Per-process pool max is that budget divided among processes.
| Client | Setting | Default | Starting point |
|---|---|---|---|
| HikariCP (JVM) | maximumPoolSize, minimumIdle | 10, same as max | 10; set minimumIdle = max (fixed pool) |
| node-postgres | new Pool({ max }) | 10 | 10 per process; idleTimeoutMillis: 10000, connectionTimeoutMillis: 5000 |
| SQLAlchemy | pool_size, max_overflow | 5, 10 | 5 + 5; pool_pre_ping=True, pool_recycle=1800 |
| Prisma | connection_limit URL param | cores x 2 + 1 | explicit value; pool_timeout |
| Go database/sql | SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime | unlimited, 2, none | 10-25, same idle, 30 min |
| Django | CONN_MAX_AGE (persistent per thread), OPTIONS: {"pool": True} on Django 5.1+ with psycopg 3 | 0 (new conn per request) | 60 with health checks, or the psycopg pool |
Rules:
- Async apps (asyncio, Node) with one process still need a bounded pool; unbounded concurrency simply moves the queue to the database.
- Bigger pools do not fix slow queries; they let more slow queries compete for CPU and locks.
- A thread or task that holds a connection while calling an external API (HTTP, LLM, S3) is the most common exhaustion cause. Release the connection before slow I/O.
4. External pooler: PgBouncer modes
| Mode | Server connection held for | Breaks |
|---|---|---|
| session | the whole client session | nothing; only helps with connection churn |
| transaction | one transaction | session state: SET (use SET LOCAL), session advisory locks, LISTEN/NOTIFY, temp tables across transactions, WITH HOLD cursors |
| statement | one statement | multi-statement transactions; rarely appropriate |
Default for web apps: transaction mode. Use a direct (non-pooled) connection for
migrations, LISTEN, long sessions, and admin work.
; pgbouncer.ini (1.21+)
[databases]
app = host=10.0.0.5 port=5432 dbname=app
[pgbouncer]
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 20 ; server connections per (db, user) pair
reserve_pool_size = 5
reserve_pool_timeout = 3
max_prepared_statements = 200 ; protocol-level prepared statements in transaction mode
server_idle_timeout = 600
query_wait_timeout = 30 ; clients waiting longer than this get an error
server_reset_query = DISCARD ALL ; only applied in session mode
- Prepared statements in transaction mode work since 1.21 via
max_prepared_statements(default 200 since 1.24). Drivers using protocol-level prepares (pgx, node-postgres named statements, JDBC) then work; SQL-levelPREPAREstill does not. - Older setups needed
prepareThreshold=0(JDBC),statement_cache_size=0(asyncpg), orpgbouncer=true(Prisma); remove those only after confirming the PgBouncer version. - Pool size is per database/user pair: 3 users x
default_pool_size 20= 60 server connections. Budget accordingly. - Run PgBouncer close to the app or as a sidecar per node; one PgBouncer is single-threaded,
so scale out with several instances (or
so_reuseport) at very high throughput.
Serverless and edge
Each function instance opens its own connections, and instances scale to hundreds in seconds.
| Platform | Approach |
|---|---|
| AWS Lambda + RDS/Aurora | RDS Proxy; pool max: 1 per instance; create the client outside the handler |
| Supabase | Supavisor transaction-mode port (6543) for serverless, direct or session port for migrations |
| Neon | pooled connection string (-pooler host, PgBouncer-based) for app traffic |
| Cloudflare Workers | Hyperdrive in front of Postgres/MySQL; open a client per request through it |
| Vercel/edge functions | HTTP or WebSocket drivers (Neon serverless driver, PlanetScale) or a pooled endpoint |
Never cache a TCP connection in a global that outlives the platform’s freeze/thaw without a health check; the first query after thaw fails on a dead socket.
5. Timeouts
ALTER ROLE app SET statement_timeout = '15s';
ALTER ROLE app SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app SET lock_timeout = '5s';
Plus the client acquire timeout (fail in 2-5 s with “pool exhausted” rather than hang),
idle timeout, and max lifetime shorter than any network/LB idle cutoff (often 350 s on AWS
NAT, or a firewall’s limit). These role settings persist through transaction pooling; plain
SET does not.
Diagnosing exhaustion
- Confirm where the queue is: app pool wait time metrics vs
pg_stat_activitycounts vs PgBouncerSHOW POOLS(cl_waiting,maxwait). - Run the queries in references/pool-diagnostics.md:
connections by state and application,
idle in transactionsessions with age, long queries, and lock waits. - Classify:
| Evidence | Cause | Fix |
|---|---|---|
many idle in transaction, old xact_start | code opens a transaction then does slow work or forgets commit | release before I/O, fix missing commit/rollback in error paths, set idle-in-transaction timeout |
many active, waiting on locks | lock contention | fix the locking query (see transactions-locking) |
many active, CPU saturated | slow queries | tune queries; do not enlarge pools |
connections near max, mostly idle | too many pools / instances | smaller pools, pooler, fewer workers |
| app pool waits but database is quiet | leak: connections checked out and never returned | enable leak detection (Hikari leakDetectionThreshold), audit acquire/release paths |
- Reproduce with a load test at expected peak plus 50%, watching pool wait time and
pg_stat_activitycounts. Leaks show as checked-out count climbing without falling.
Gotchas
- Read replicas need their own budget; failover can move every client to one node at once.
- Health-check queries (
SELECT 1on every borrow) add round trips; prefer validation on idle connections only (Hikari default) orpool_pre_pingat modest rates. max_connectionschanges require a Postgres restart; plan it with the user.- Kubernetes rolling deploys briefly run old and new pods together: budget for
maxSurgeextra pods. - Transaction-mode poolers make
search_pathand tenantSETleak between clients; pass tenant context withSET LOCALinside the transaction (see row-level-security).
Verification
- Written budget: every client’s max connections summed, below 80% of
max_connectionsat maximum autoscale. - Load test at peak + 50% shows p99 pool acquire time under the agreed bound (e.g. under 50 ms) and zero acquire timeouts.
pg_stat_activityshows noidle in transactionsessions older than the timeout.- PgBouncer
SHOW POOLShascl_waiting = 0in steady state.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/pool-diagnostics.md
# Pool diagnostics
Read-only queries and commands for finding where connections go. Postgres first, then
PgBouncer, then MySQL.
## Postgres
```sql
-- Budget at a glance
SELECT current_setting('max_connections')::int AS max_conn,
current_setting('superuser_reserved_connections')::int AS reserved,
count(*) AS in_use
FROM pg_stat_activity;
-- Connections by application, user, and state
SELECT application_name, usename, client_addr, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3, 4
ORDER BY count(*) DESC;
-- Idle-in-transaction sessions: the usual leak
SELECT pid, usename, application_name, now() - xact_start AS xact_age,
now() - state_change AS idle_for, left(query, 120) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
-- Long-running active queries
SELECT pid, now() - query_start AS runtime, wait_event_type, wait_event, left(query, 120)
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '5 seconds'
ORDER BY runtime DESC;
-- Who blocks whom
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
left(blocked.query, 80) AS blocked_query, left(blocking.query, 80) AS blocking_query
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid;
```
Set `application_name` in every client's connection string (`?application_name=api`) so
these groupings mean something.
Terminating sessions (`SELECT pg_terminate_backend(pid)`) rolls back their work and can
break the app's pool state. Ask the user before running it on production, and prefer
fixing the timeout settings so the server does it automatically.
## PgBouncer admin console
```bash
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
```
```sql
SHOW POOLS; -- cl_active, cl_waiting, sv_active, sv_idle, sv_used, maxwait (seconds), pool_mode
SHOW STATS; -- per-db totals and averages: xact/query counts, avg wait time
SHOW CLIENTS; -- one row per client connection with state and wait
SHOW SERVERS; -- one row per server connection
SHOW CONFIG; -- effective settings
```
| Reading | Meaning |
|---|---|
| `cl_waiting > 0` with `sv_active = pool size` | pool saturated; queries too slow or pool too small for the database's parallelism |
| `cl_waiting > 0` with idle servers available | per-user/db pool limits or `max_db_connections` hit |
| `maxwait` climbing | clients queueing; correlate with app latency |
| `sv_used` high | servers waiting for `server_check_query`; usually harmless |
`RELOAD;` applies config changes without dropping clients. `PAUSE`/`RESUME` drain traffic
for a database switchover; confirm with the user before using them.
## MySQL
```sql
SHOW GLOBAL STATUS WHERE Variable_name IN
('Threads_connected','Threads_running','Max_used_connections','Aborted_connects','Connection_errors_max_connections');
SHOW VARIABLES LIKE 'max_connections';
SHOW VARIABLES LIKE 'wait_timeout';
SELECT user, host, db, command, time, state, LEFT(info, 100) AS q
FROM information_schema.processlist
ORDER BY time DESC LIMIT 20;
-- Open transactions with age (sleeping connections holding a transaction)
SELECT trx_mysql_thread_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s, trx_state
FROM information_schema.innodb_trx ORDER BY trx_started;
```
`Sleep` connections with an open transaction in `innodb_trx` are the MySQL equivalent of
`idle in transaction`. A `wait_timeout` shorter than the app pool's max lifetime produces
"server has gone away" errors; set the pool lifetime below `wait_timeout`.
## Application pool metrics to expose
| Pool | Metric source |
|---|---|
| HikariCP | Micrometer `hikaricp_connections_pending`, `_active`, `_acquire_seconds` |
| node-postgres | `pool.totalCount`, `pool.idleCount`, `pool.waitingCount` |
| SQLAlchemy | `engine.pool.status()`, `checkedout()`; pool events for timing |
| Go database/sql | `db.Stats()`: `WaitCount`, `WaitDuration`, `InUse`, `Idle` |
Alert on acquire wait p99 and on waiting count above zero for more than a minute.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.