Render Tutorials
Postgres on Render: a deep dive

Connection limits and pooling

⏱ 8 min

The single most common Postgres incident on Render is “too many connections”. The cause is almost always the same: someone added a new service, scaled up the number of instances, or bumped the pool size - without checking against the database’s hard limit.

Render does not run a connection pooler in front of Render Postgres. That’s deliberate, and it means the math is on you. Once you know it, the rest of this is mechanical.

The hard limit by plan

Maximum concurrent connections scale with the database’s RAM:

MemoryMax connections
< 8 GB100
8 GB <= memory < 16 GB200
16 GB <= memory < 32 GB400
>= 32 GB500
flowchart LR
  small["< 8 GB → 100"]
  med["8-16 GB → 200"]
  large["16-32 GB → 400"]
  big["32 GB+ → 500"]

Two important notes:

  • These are the caps for current-generation plans. Legacy instance types have lower limits (97 / 197 / 397) - check your specific plan in the Render Dashboard.
  • The cap is hard. Hitting it doesn’t queue requests - it returns an error. New connections fail with FATAL: sorry, too many clients already.

A few of the connection slots are reserved for Postgres itself (replication, autovacuum, monitoring). Plan as if you have ~10 fewer than the headline number - call it 90, 190, 390, 490.

The pool sizing math

The number of connections your app uses isn’t the pool size you set. It’s:

total_connections = num_instances × pool_size_per_instance

If you have 4 web service instances and each one keeps a pool of 25 connections, that’s 100 - already at the cap of an under-8GB plan, before the worker has even said hello.

A useful budget worksheet for a typical app:

Connection budget - under-8GB plan, max 100
Reserved by Postgres ~10
Web service: 4 instances × 10 pool 40
Worker: 2 instances × 10 pool 20
Cron jobs: transient, ~5 max 5
Migrations / shell / `psql` / debug 10
─────────────────────────────────────────
Total 85
Headroom 15

The headroom matters. A deploy briefly doubles connections (old instances draining, new ones starting) - without headroom, deploys themselves trigger “too many connections” errors.

Setting pool sizes in your framework

A quick reference for the three frameworks Render users hit most often.

db.js
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // pool size per process
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
export default pool;

Reuse pool across requests. Don’t new pg.Pool() per request - that’s how you discover connection limits the hard way.

settings.py
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 60, # keep connections alive 60s
# Django itself doesn't have a true pool; pair with pgbouncer
# or use psycopg's pool for more control
}
}

Django’s connection-per-request model means each Gunicorn/Uvicorn worker is the unit you size for. With 4 workers per instance and 4 instances, you’re looking at 16 concurrent connections under load - fine for an under-8GB plan, painful at scale without PgBouncer.

config/database.yml
production:
adapter: postgresql
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
checkout_timeout: 5

Rails’ pool: is per-process. With Puma running 2 workers × 5 threads = 10 connections per instance. Multiply by your instance count.

The general principle: make pool size an env var, set it conservatively, and double-check the math when you scale.

When you need PgBouncer

Framework pools are great for steady, mostly-idle connections. They struggle when:

  • You have many short requests that each need a connection briefly. Connection setup itself becomes the bottleneck.
  • You autoscale aggressively and individual instance pool sizes don’t divide cleanly.
  • You’re running lots of small services (e.g. a fleet of Lambda-style microservices) and each one wants 5-10 connections.

The right tool is PgBouncer - a lightweight Postgres connection pooler that sits between your apps and the database. Apps connect to PgBouncer; PgBouncer multiplexes those connections onto a small, fixed set of upstream connections to Postgres.

flowchart LR
  subgraph apps [Your services]
    a1["api × 5 instances<br/>10 conn each = 50"]
    a2["worker × 3 instances<br/>10 conn each = 30"]
  end
  bouncer["PgBouncer<br/>(1 instance, transaction pool)"]
  pg[("Postgres<br/>max 100 conn")]

  apps -->|"~80 connections"| bouncer
  bouncer -->|"~30 multiplexed connections"| pg

The win: 80 application-side connections become 30 actual Postgres connections, well under the limit, with no app-side coordination.

Running PgBouncer on Render

There’s no first-class “PgBouncer service” on Render, but it’s straightforward to run one as a private service:

render.yaml - PgBouncer pserv
services:
- type: pserv
name: pgbouncer
runtime: docker
plan: starter
image:
url: edoburu/pgbouncer:latest
envVars:
- key: DB_HOST
fromDatabase:
name: app-db
property: host
- key: DB_USER
fromDatabase:
name: app-db
property: user
- key: DB_PASSWORD
fromDatabase:
name: app-db
property: password
- key: DB_NAME
fromDatabase:
name: app-db
property: database
- key: POOL_MODE
value: transaction
- key: MAX_CLIENT_CONN
value: "200"
- key: DEFAULT_POOL_SIZE
value: "20"
- type: web
name: api
runtime: node
plan: starter
envVars:
- key: DATABASE_URL
value: postgres://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME}
# ... or build the URL in app code from individual props

The web service connects to PgBouncer over the private network (pserv services are private by default), and PgBouncer holds the connections to the actual Postgres.

A few PgBouncer knobs worth knowing:

  • POOL_MODE: transaction is what you want for most apps - connections are checked out for one transaction, then returned. The most efficient mode.
  • POOL_MODE: session holds the connection for the whole client session. Closer to “what your app expects” but less efficient.
  • MAX_CLIENT_CONN is how many app-side connections PgBouncer accepts.
  • DEFAULT_POOL_SIZE is how many upstream Postgres connections it opens per (database, user) pair.

Monitoring connection use

The fastest sanity check, run in psql:

psql - connection states right now
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY count DESC;

Healthy patterns:

  • active - currently running a query. Should be a small number relative to your total pool.
  • idle - connection is open, holding no transaction. Most of the pool, most of the time.
  • idle in transaction - connection is in a transaction but not running a query. A growing count here is a problem - usually a missing COMMIT or a long-running transaction blocking vacuum.

For continuous monitoring, the get_metrics endpoint (or MCP get_metrics) returns active_connections over time:

MCP query
get_metrics(
resourceId: "<postgres-id>",
metricTypes: ["active_connections"]
)

Wire that into your existing alerting at, say, 70% of max_connections. Way before you’d panic, but early enough to add capacity calmly.

Your `api` web service has `pool.max = 25` and runs on 4 instances. Your `worker` has `pool.max = 10` and runs on 2 instances. Your Postgres is on a `basic-1gb` plan (under 8 GB RAM). How does this hold up?

What you learned

  • Connection caps are a hard limit by plan RAM: ~100 (under 8GB), ~200 (8GB), ~300 (16GB), ~500 (32GB+). Reserve ~10 for Postgres itself
  • Connection budget: `instances × pool_size_per_instance + reserved + headroom`. Default pool sizes are sized for one process, not horizontally-scaled fleets
  • Set pool sizes via env vars (`DATABASE_POOL_SIZE`, `RAILS_MAX_THREADS`, etc.) so they're easy to tune as you scale
  • Reach for PgBouncer when many small services or short transactions outgrow framework pools. `transaction` mode is the most efficient - but breaks LISTEN/NOTIFY and prepared statements
  • Monitor `pg_stat_activity` (live) and `get_metrics` with `active_connections` (continuous). Alert at ~70% of the cap