Updated Nov 5, 20267 min read

Database Connection Pooling: How to Size and Run Postgres Pools in 2026

Why Postgres needs pooling, how to size an application pool against max_connections, when to add PgBouncer or RDS Proxy, and the pool mistakes that cause timeouts in production.

Connection pooling is the reason a Postgres-backed API can serve thousands of requests per second over a few dozen database connections, and it is also behind a familiar class of production incidents: too many clients already after a scale-out, requests timing out while the database sits idle, and serverless functions exhausting connections within minutes of a traffic spike. Each of these comes from a pool that is sized without looking at the database on the other end.

This guide covers what a pool does, how to size one, when an external pooler such as PgBouncer or RDS Proxy is worth adding, and how Encore (infrastructure from code) handles pooling for Go services running locally and in your own AWS or GCP account.

At a glance

QuestionShort answer
Why pool?Opening a Postgres connection costs a handshake, authentication and a server process
Where does the pool live?In each application instance, and optionally in a proxy in front of Postgres
How big?Small: a few connections per instance, total below max_connections
When to add PgBouncer or RDS ProxyMany instances or short-lived processes opening their own connections
Most common bugHolding a connection while waiting on something other than the database

Why Postgres needs a pool

Postgres starts a separate backend process for every client connection, so each one costs memory on the server and takes several network round trips to establish before the first query runs. The server caps the number of clients with max_connections, which defaults to 100 on a stock install, with a few slots held back for superusers. Managed services compute their own defaults; on Amazon RDS the default for PostgreSQL scales with instance memory, as described in the RDS quotas documentation.

A pool opens connections once and reuses them, so a request borrows an already authenticated connection, runs its queries and hands it back in well under a millisecond of overhead.

How to size a pool

Adding connections stops adding throughput once active connections compete for the same CPU cores and disks, and past that point latency goes up for all of them. The PostgreSQL wiki on the number of database connections gives a starting point of roughly twice the server's core count for active connections, which on a 4-core instance is around 10, and that number covers every client combined.

The arithmetic that matters in production is instances multiplied by pool size. Ten API instances with a pool of 20 each can open 200 connections, which already exceeds the stock limit of 100, and an autoscaler that doubles the instance count during a spike doubles the connection count with it. Budget the total with headroom for migrations, background workers and a person opening psql during an incident.

Common defaults to check in your own stack:

  • pgx (Go): pgxpool defaults MaxConns to the larger of 4 and the number of CPUs.
  • database/sql (Go): SetMaxOpenConns defaults to unlimited, so set it explicitly.
  • node-postgres: Pool defaults max to 10 connections per process.

Watch the pool, not only the database: the time requests spend waiting for a connection is the number that tells you the pool is too small, and SELECT state, count(*) FROM pg_stat_activity GROUP BY state on the server tells you whether connections are busy or sitting idle in a transaction.

The mistakes that cause pool timeouts

Most pool exhaustion is a code problem rather than a sizing problem. A handler that begins a transaction, calls a payment API that takes two seconds and then commits holds a connection for those two seconds, so twenty such requests at once drain a pool of twenty. Fetch external data before the transaction, or after it, and keep transactions to the database work itself.

The other frequent causes are rows that are never closed in Go, which keep the connection checked out, and transactions left open on an error path. Postgres shows those as idle in transaction in pg_stat_activity, and idle_in_transaction_session_timeout closes them on the server side as a safety net.

When to add PgBouncer or RDS Proxy

An external pooler sits between the application and Postgres and multiplexes many client connections onto fewer server connections. It earns its place when the number of processes opening connections is large or unpredictable, which is the normal case for serverless functions, where every concurrent invocation is its own process with its own pool.

PgBouncer has three modes: session pooling keeps a server connection for the whole client session, transaction pooling returns it after each transaction and gives the largest reduction, and statement pooling forbids multi-statement transactions. Transaction mode breaks features that depend on session state, such as session-level advisory locks, SET without LOCAL, and LISTEN. RDS Proxy is the managed option on AWS and adds IAM authentication and faster failover handling as a managed component you configure next to the database.

For a handful of long-running containers with sensible pools, an external pooler adds a hop without solving a problem you have.

A pool per database with Encore, locally and in your cloud

Encore

Encore is infrastructure from code for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. In Go, a database is declared in the service that owns it, and the Encore runtime opens a pgx connection pool for that database in each running instance, so there is no pool setup code to write or copy between services.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.
Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create payments --example=hello-world$ cd payments && mkdir -p orders/migrations

The endpoint below charges a card before it opens the transaction, so the connection is only checked out for the two writes:

// orders/orders.go package orders import ( "context" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type PayParams struct { OrderID int64 `json:"order_id"` Cents int64 `json:"cents"` } //encore:api public method=POST path=/orders/pay func Pay(ctx context.Context, p *PayParams) error { // Slow external work happens before a connection is borrowed. chargeID, err := charge(ctx, p.Cents) if err != nil { return err } tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() // ignored once Commit has succeeded if _, err := tx.Exec(ctx, `UPDATE orders SET status = 'paid' WHERE id = $1`, p.OrderID); err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO payments (order_id, charge_id, cents) VALUES ($1, $2, $3)`, p.OrderID, chargeID, p.Cents); err != nil { return err } return tx.Commit() } // charge stands in for a call to your payment provider. func charge(ctx context.Context, cents int64) (string, error) { return "ch_test", nil }

The migration in orders/migrations/1_create_tables.up.sql creates the two tables:

CREATE TABLE orders (id BIGSERIAL PRIMARY KEY, status TEXT NOT NULL DEFAULT 'new'); CREATE TABLE payments ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id), charge_id TEXT NOT NULL, cents BIGINT NOT NULL );

encore run starts Postgres locally and applies the migrations, and the local development dashboard shows every query in each request trace, which makes a transaction that waits on something slow easy to spot. Code that needs the standard library interface can call db.Stdlib(), which returns a *sql.DB capped at the same maximum as the pool. The database docs cover migrations, sharing a database between services and connecting to an existing one.

On deploy to AWS, Encore provisions RDS or Aurora with Multi-AZ, a default of 1,000 max connections that you can change per environment, and the database in private subnets, and each service gets its own database role with access to only the databases it uses, as listed in the AWS infrastructure docs. On GCP the same code runs against Cloud SQL. For self-hosted builds, max_connections and min_connections for each database are set in the infrastructure config. A coding agent working in this codebase sees the database declaration next to the queries that use it.

Best fit: Go teams running Postgres-backed services on AWS or GCP who want pooling, local Postgres and per-service database access from the database declaration.

Frequently asked questions

What is database connection pooling?

A connection pool keeps a set of open database connections inside your application and lends one to each query or transaction, returning it to the pool afterwards. It avoids opening a new Postgres connection per request, which costs a TCP and TLS handshake, authentication and a new backend process on the server.

How many connections should a Postgres pool have?

The PostgreSQL wiki suggests roughly twice the number of CPU cores on the database server as a starting point for active connections, and the total across every application instance must stay below max_connections with room for migrations and admin sessions. Measure wait time on the pool and raise the size only if requests are waiting while the database still has CPU to spare.

Do I need PgBouncer if my application already has a pool?

Not usually for a few long-running instances with their own pools. PgBouncer or RDS Proxy helps when many instances or short-lived processes, such as serverless functions, each open their own connections and the total would exceed what Postgres can handle.

How do I set up a pooled Postgres connection in Encore?

Declare the database with sqldb.NewDatabase in a Go service and query it with db.QueryRow or db.Begin. Encore opens a pgx connection pool for the database in each service instance, runs Postgres locally with encore run, and provisions RDS or Cloud SQL on deploy.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders