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.

  • nameconnection-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

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.

~/.claude/skills/connection-pooling/SKILL.md

--- name: connection-pooling description: Sizes and debugs database connection pools end to end - application pools (HikariCP, node-postgres, SQLAlchemy, Prisma, Go database/sql),… license: MIT ---

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.

ClientSettingDefaultStarting point
HikariCP (JVM)maximumPoolSize, minimumIdle10, same as max10; set minimumIdle = max (fixed pool)
node-postgresnew Pool({ max })1010 per process; idleTimeoutMillis: 10000, connectionTimeoutMillis: 5000
SQLAlchemypool_size, max_overflow5, 105 + 5; pool_pre_ping=True, pool_recycle=1800
Prismaconnection_limit URL paramcores x 2 + 1explicit value; pool_timeout
Go database/sqlSetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetimeunlimited, 2, none10-25, same idle, 30 min
DjangoCONN_MAX_AGE (persistent per thread), OPTIONS: {"pool": True} on Django 5.1+ with psycopg 30 (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

ModeServer connection held forBreaks
sessionthe whole client sessionnothing; only helps with connection churn
transactionone transactionsession state: SET (use SET LOCAL), session advisory locks, LISTEN/NOTIFY, temp tables across transactions, WITH HOLD cursors
statementone statementmulti-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-level PREPARE still does not.
  • Older setups needed prepareThreshold=0 (JDBC), statement_cache_size=0 (asyncpg), or pgbouncer=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.

PlatformApproach
AWS Lambda + RDS/AuroraRDS Proxy; pool max: 1 per instance; create the client outside the handler
SupabaseSupavisor transaction-mode port (6543) for serverless, direct or session port for migrations
Neonpooled connection string (-pooler host, PgBouncer-based) for app traffic
Cloudflare WorkersHyperdrive in front of Postgres/MySQL; open a client per request through it
Vercel/edge functionsHTTP 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

  1. Confirm where the queue is: app pool wait time metrics vs pg_stat_activity counts vs PgBouncer SHOW POOLS (cl_waiting, maxwait).
  2. Run the queries in references/pool-diagnostics.md: connections by state and application, idle in transaction sessions with age, long queries, and lock waits.
  3. Classify:
EvidenceCauseFix
many idle in transaction, old xact_startcode opens a transaction then does slow work or forgets commitrelease before I/O, fix missing commit/rollback in error paths, set idle-in-transaction timeout
many active, waiting on lockslock contentionfix the locking query (see transactions-locking)
many active, CPU saturatedslow queriestune queries; do not enlarge pools
connections near max, mostly idletoo many pools / instancessmaller pools, pooler, fewer workers
app pool waits but database is quietleak: connections checked out and never returnedenable leak detection (Hikari leakDetectionThreshold), audit acquire/release paths
  1. Reproduce with a load test at expected peak plus 50%, watching pool wait time and pg_stat_activity counts. 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 1 on every borrow) add round trips; prefer validation on idle connections only (Hikari default) or pool_pre_ping at modest rates.
  • max_connections changes require a Postgres restart; plan it with the user.
  • Kubernetes rolling deploys briefly run old and new pods together: budget for maxSurge extra pods.
  • Transaction-mode poolers make search_path and tenant SET leak between clients; pass tenant context with SET LOCAL inside the transaction (see row-level-security).

Verification

  • Written budget: every client’s max connections summed, below 80% of max_connections at 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_activity shows no idle in transaction sessions older than the timeout.
  • PgBouncer SHOW POOLS has cl_waiting = 0 in steady state.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/pool-diagnostics.md 104 lines · 4.0 KB
# 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.

esc