Connection Pooling skill for Codex
Fit every pool inside max_connections. Codex counts every pool that can exist at peak (replicas, workers, deploy overlap, cron) before touching a setting, then decides between app-side pools, PgBouncer, RDS Proxy or a hosted pooler. It flags session features that break in transaction mode, sets driver flags for prepared statements, and proves the fix with pg_stat_activity and PgBouncer SHOW POOLS under load.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- connection-pooling Installs to ~/.agents/skills/connection-pooling/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install connection-pooling The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project connection-pooling Installs to .agents/skills/connection-pooling/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/connection-pooling.zipunzip -o connection-pooling.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) connection-pooling PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $connection-pooling, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sizes and configures database connection pools end to end - app-side pools (node-postgres, SQLAlchemy, HikariCP, Go database/sql, Rails), PgBouncer in transaction mode, RDS Proxy and hosted poolers such as Supavisor and Neon - against the server's max_connections budget, including serverless and autoscaled fleets. Use when you see "too many connections" or "remaining connection slots are reserved", pool timeouts, prepared statement errors behind PgBouncer, Lambda or edge functions exhausting Postgres, or when choosing pool sizes before scaling out.
Use it when
- Errors mention too many connections, reserved slots or pool acquire timeouts
- You are adding replicas, workers or autoscaling and need pool sizes
- Introducing PgBouncer, RDS Proxy, Supavisor or the Neon pooler
- Prepared statement errors appear after putting a pooler in the path
Use something else when
- Neon or Supabase branching and cold-start behaviour is the real question: use serverless-postgres
- Connections are fine but individual queries are slow: use postgres-query-tuning
How to check the result
Under a load test at expected peak, pg_stat_activity stays below 80% of usable connections and PgBouncer SHOW POOLS shows cl_waiting draining to zero between bursts.
The mistake it prevents
Raising max_connections instead of pooling, which spends memory per backend and lowers throughput once active queries exceed a few per CPU core.
Done well means: the sum of every pool that can exist at peak fits inside the database’s connection budget with headroom, each process waits briefly for a connection instead of opening more, and nothing in the app relies on session state the pooler does not keep.
Inspect before changing anything
- Read
AGENTS.mdand find every place a connection is created: ORM config,DATABASE_URLvariants, background workers, cron jobs, migration runners, BI tools. - Count the fleet at peak, not at rest: replicas x processes per replica x pool size, plus autoscaling maximums, deploy overlap (old and new pods alive together) and workers.
- Read the server limits (needs DB access; ask before connecting to production):
SHOW max_connections;
SHOW superuser_reserved_connections;
SELECT count(*), state, usename, application_name
FROM pg_stat_activity GROUP BY 2, 3, 4 ORDER BY 1 DESC;
- Note the managed-service ceiling. On RDS and Cloud SQL,
max_connectionsderives from instance memory; Supabase and Neon publish per-plan limits for direct and pooled access.
The budget
usable = max_connections
- superuser_reserved_connections
- admin/migration/replication slack (5-10)
peak = sum over services of (max replicas x processes x pool max)
+ deploy overlap + workers + cron + humans
rule: peak <= 0.8 x usable, or put a pooler in front
Worked example: max_connections = 200, 3 reserved, 10 slack leaves 187 usable. An API
with up to 12 pods x 4 Puma workers x pool 5 = 240 is already over before Sidekiq. Either
drop per-process pools, or route through PgBouncer so 240 clients share about 40 server
connections.
More connections is rarely faster. Postgres runs one backend process per connection; past
roughly 2-4 x CPU cores of concurrently active queries, throughput flattens and latency
climbs. Size the server side for active work, the client side for concurrency.
Decide where pooling happens
| Situation | Default | Why |
|---|---|---|
| A few long-lived app processes, fits budget | App-side pool only | Simplest; no extra hop |
| Many processes or replicas, budget exceeded | PgBouncer (transaction mode) between app and DB | Multiplexes thousands of clients onto tens of backends |
| AWS Lambda against RDS/Aurora | RDS Proxy | Managed, IAM auth, survives function bursts |
| Supabase | Supavisor transaction mode (port 6543) for serverless; direct or session mode (5432) for long-lived servers and migrations | Matches each workload’s session needs |
| Neon | The -pooler hostname for app traffic; direct host for migrations and pg_dump | Pooler is PgBouncer in transaction mode |
| Edge runtimes without TCP | Provider HTTP/WebSocket drivers (Neon serverless driver, Turso, Hyperdrive on Cloudflare) | No raw sockets available |
| MySQL with many clients | ProxySQL, or the managed proxy | Same multiplexing idea |
Branching and pooling specifics of Neon and Supabase live in $serverless-postgres.
PgBouncer configuration
[databases]
app = host=10.0.0.5 port=5432 dbname=app
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 5000 ; client sockets are cheap
default_pool_size = 20 ; server connections per (db, user) pair
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 60 ; hard cap per database across users
server_lifetime = 3600
server_idle_timeout = 600
query_wait_timeout = 30 ; fail fast instead of queueing forever
max_prepared_statements = 200 ; protocol-level prepared statements (1.21+)
ignore_startup_parameters = extra_float_digits,options
default_pool_sizeapplies per database and user pair. Ten app roles x 20 = 200 server connections; cap withmax_db_connectionsormax_user_connections.- PgBouncer is single-threaded. Past roughly one saturated core, run several instances
with
so_reuseport = 1or behind a load balancer. - Release 1.24 made
max_prepared_statements = 200the default; older installs default to 0 (disabled). Checkpgbouncer --versionbefore assuming either.
What breaks in transaction mode
A server connection returns to the pool at COMMIT, so the next transaction may run on
a different backend. These features assume a stable session:
| Feature | Symptom | Fix |
|---|---|---|
SET search_path / SET statement_timeout at session level | Settings leak to other clients or vanish | SET LOCAL inside the transaction, or set on the role: ALTER ROLE app SET statement_timeout = '5s' |
SQL-level PREPARE / EXECUTE | “prepared statement does not exist” | Use protocol-level prepares with max_prepared_statements, or disable prepares in the driver |
LISTEN / NOTIFY listeners | Notifications lost | Dedicated direct connection for listeners |
Session advisory locks (pg_advisory_lock) | Lock held by a backend another client now uses | pg_advisory_xact_lock |
Temp tables across statements, WITH HOLD cursors | Objects missing | Keep inside one transaction or use a direct connection |
Driver flags for poolers without prepared statement support:
- asyncpg:
create_pool(..., statement_cache_size=0) - Prisma: append
?pgbouncer=trueto the pooled URL; givedirectUrlthe direct host for migrations. - pgx (Go):
default_query_exec_mode=simple_protocolorexecin the DSN. - Rails:
prepared_statements: falseindatabase.ymlwhen the pooler cannot track them.
Admin console
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer -c 'SHOW POOLS;'
Watch cl_waiting (clients queued) and maxwait (seconds the oldest has waited). Sustained
cl_waiting > 0 with sv_active at the pool size means the pool, or the database behind
it, is the bottleneck. SHOW STATS gives per-database query and wait totals.
App-side pool settings
Set a small max, a short acquire timeout, and a lifetime below anything in the path that
kills idle connections (load balancers, NAT gateways, the pooler’s server_lifetime).
// node-postgres
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
# SQLAlchemy 2.x, long-lived process
engine = create_engine(url, pool_size=5, max_overflow=5, pool_timeout=10,
pool_recycle=1800, pool_pre_ping=True)
# Behind PgBouncer or in short-lived processes: let the external pooler pool
from sqlalchemy.pool import NullPool
engine = create_engine(pooled_url, poolclass=NullPool)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
- HikariCP:
maximumPoolSize(fixed size: leaveminimumIdleunset),connectionTimeoutof a few seconds,maxLifetimeat least 30 s below any infrastructure idle cutoff. - Rails:
poolindatabase.ymlshould equal the thread count (RAILS_MAX_THREADS) of that process type; Sidekiq needspool >= concurrency. - Thread pools and connection pools interact: a pool smaller than the worker threads that need it causes timeouts; a much larger pool only wastes budget.
Serverless and autoscaled fleets
- Every function instance holds its own pool, so pool
maxshould be 1-2 per instance, and concurrency limits on the function become your connection ceiling. - Create the client outside the handler so warm invocations reuse it; never
end()the pool at the end of each request. - Set reserved or maximum concurrency on Lambda (and max instances on Cloud Run, Vercel) to keep a burst from opening thousands of connections.
- Vercel Fluid compute: recent
@vercel/functionsexposesattachDatabasePool(pool)to close idle connections before an instance is suspended; check the installed version. - Prisma in serverless:
connection_limit=1on the pooled URL, plus the pooler.
Verification
- Peak connection arithmetic written down and under 80% of usable
-
SELECT count(*) FROM pg_stat_activityduring a load test stays flat as clients grow - PgBouncer
SHOW POOLS:cl_waitingreturns to 0 between bursts - Pool acquire timeouts appear in app logs as a distinct error, not a generic 500
- Migrations and
pg_dumpuse the direct URL, app traffic the pooled one - A rolling deploy (old and new pods together) stays under budget
If acquire timeouts persist while the database is idle, the pool is too small for the
thread count. If they persist while pg_stat_activity shows many active queries, the
database is saturated: fix slow queries ($postgres-query-tuning) instead of raising limits.
Gotchas
- Raising
max_connectionscosts memory per backend (work_memis per operation, per backend) and requires a restart. It hides the problem and rarely helps throughput. idle in transactionsessions pin connections and block vacuum. Setidle_in_transaction_session_timeouton the role (for example'60s').- Health checks that open a new connection each probe consume slots; reuse the pool.
- Migration tools that take advisory locks or run DDL in session state need the direct connection, never the transaction-mode pooler.
- Read replicas need their own budget; replica
max_connectionsmust be at least the primary’s or the standby refuses to start.
Output template
**Fleet at peak:** api 12 pods x 4 workers x pool 5 (240), sidekiq 4 x 10 (40), cron 5
**Server budget:** max_connections 200, usable 187
**Change:** PgBouncer transaction mode, default_pool_size 25, max_db_connections 60;
app pools unchanged (clients 285 -> server 60)
**App changes:** session SET -> SET LOCAL in tenant middleware; LISTEN worker on direct URL
**Verified:** load test 3x traffic, pg_stat_activity peak 58, cl_waiting max 0.4 s
**Risks:** prepared statements rely on PgBouncer >= 1.21 (installed 1.24.1) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Connection Pooling"
short_description: "Fit every pool inside max_connections"
default_prompt: "$connection-pooling We keep hitting 'too many connections' on Postgres after scaling to 12 pods. Fix it properly" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.