Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 045 / 100

Connection pooling in Python apps: a step-by-step guide

Too many connections kill databases.

Expona Admin8 min read

You deploy a Python web service that handles 1,000 requests per second. Everything runs smoothly for a week, then your database starts rejecting new connections. Your logs show "too many connections" errors, and your team is paging on-call. Connection pooling in Python apps: a step-by-step guide is not optional—it is the difference between a stable system and a cascading failure.

Why connection pooling matters

Creating a new database connection is expensive. Your application must perform a TCP handshake, authenticate, negotiate protocol settings, and warm up query caches. In production, this overhead compounds across thousands of requests. Without pooling, you either exhaust database connection limits or waste CPU spinning up fresh connections that live for milliseconds.

Most databases enforce hard limits on concurrent connections. PostgreSQL defaults to 100 per role; MySQL's max_connections typically sits at 151. Once you hit that ceiling, new connection attempts fail immediately. A single poorly configured Python service with 10 worker processes can easily exhaust a modest database if each worker opens a fresh connection per request.

Pooling solves this by maintaining a reusable set of open connections within your application process. Instead of creating a new connection for each query, your code borrows an idle connection from the pool, uses it, and returns it for reuse. This reduces latency and lets you handle more requests with fewer database resources.

SQLAlchemy pools by default

If you use SQLAlchemy (the de facto ORM for Python), pooling is already working—but you may not know it. SQLAlchemy's create_engine() automatically wraps your database URL with a QueuePool that maintains connections.

python
from sqlalchemy import create_engine

# This automatically uses QueuePool pooling
engine = create_engine('postgresql://user:pass@localhost/mydb')

# Under the hood:
# - Creates up to pool_size connections on demand
# - Reuses idle connections
# - Discards connections after pool_recycle seconds (3600 by default)

The default QueuePool maintains a minimum number of connections and grows up to pool_size when demand increases. When a connection is returned to the pool, it goes back into the queue for reuse. This is transparent to your code: you request a connection, use it, and the pool handles the rest.

However, defaults are rarely tuned for production load. SQLAlchemy's default pool_size is 5, which works for a single-threaded script but starves a multi-worker web service. Understanding and configuring the pool is essential.

Set pool_size per process

The pool_size parameter controls how many connections are maintained per Python process. If your process handles concurrent requests (via threading, async, or gevent), you need enough pooled connections to satisfy peak concurrency.

Consider a FastAPI application with a thread pool of 10 workers handling requests concurrently. If each request makes 1–2 database queries and holds a connection for 50ms, you might have 5–10 concurrent database queries at any moment. A pool_size of 5 will cause threads to wait in a queue for available connections; a pool_size of 15–20 leaves headroom.

python
from sqlalchemy import create_engine

# Set pool_size to match expected concurrent queries per process
engine = create_engine(
    'postgresql://user:pass@localhost/mydb',
    pool_size=20,
    max_overflow=10,  # Allow 10 extra connections during spikes
    pool_recycle=3600,  # Recycle connections every hour (good for RDS with connection timeouts)
    pool_pre_ping=True,  # Test connections before reuse (detects stale connections)
)

The max_overflow parameter allows the pool to create temporary extra connections during traffic spikes. Once the spike subsides, those overflow connections are closed and discarded. This is a safety valve but should not be your primary strategy; if you regularly exceed pool_size, increase pool_size instead.

pool_pre_ping=True is a lifesaver in cloud environments. AWS RDS, Google Cloud SQL, and Azure Database often close idle connections after 15 minutes. Without pool_pre_ping, your application will reuse a stale connection, get an error, and retry—adding latency. With it enabled, SQLAlchemy tests each connection before use and discards dead ones automatically.

Multiply by workers and instances

This is where most teams make their first mistake. If you set pool_size=20 but run 4 worker processes, you are creating 80 total connections to the database. Add another 2 application instances and you now have 160 connections. Scale to 10 instances and you have exhausted a default PostgreSQL installation.

You must calculate total connection consumption across your entire deployment. The formula is simple:

Total connections = pool_size × number_of_processes × number_of_instances

Let us work through a real scenario. You have a Django application deployed on 5 EC2 instances. Each instance runs Gunicorn with 8 worker processes. You want to tune the pool.

python
"""
Deployment config:
- 5 instances
- 8 Gunicorn workers per instance
- Target: leave 20 connections for other apps and maintenance
- PostgreSQL max_connections: 100

Calculation:
Available for this app: 100 - 20 = 80
Connections per instance: 80 / 5 = 16
Connections per worker: 16 / 8 = 2

pool_size should be 2-3 per worker.
With max_overflow=2, each worker can temporarily use up to 4 connections during spikes.
"""

POOL_SIZE = 2
MAX_OVERFLOW = 2

This conservative sizing ensures no single instance exhausts the database. If you need more throughput, scale the database (add connections, use read replicas, or move to managed services that auto-scale). Do not blindly increase pool_size across the board—it will cascade into connection exhaustion.

Use PgBouncer for many small processes

As your system grows, you may deploy services with hundreds of small processes—Lambda functions, Kubernetes pods with low concurrency, or serverless workers. In these architectures, application-level pooling breaks down. A Lambda function that runs for 15 minutes and opens a connection per request will leak connections; a Kubernetes autoscaler that spins up 50 pods will overwhelm the database in seconds.

This is where PgBouncer (or similar connection poolers like Pgpool or ProxySQL for MySQL) becomes essential. PgBouncer sits between your application and PostgreSQL, pooling connections at the service layer rather than the process layer.

Without PgBouncer With PgBouncer
Each app instance: pool_size × workers = many connections per instance Each app instance: 1 connection to PgBouncer; PgBouncer holds larger pool to PostgreSQL
Total: 50 instances × 5 workers × 5 pool_size = 1,250 connections (overload) Total: 50 instances × 1 connection + PgBouncer pool (e.g., 50 connections) = 50 connections (controlled)

Setting up PgBouncer is straightforward. Deploy it on a dedicated host or sidecar container between your app layer and the database.

python
"""
pgbouncer.ini configuration:

[databases]
mydb = host=postgres.example.com port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction  # Connection pooling per transaction
max_client_conn = 1000   # Max connections from apps
default_pool_size = 25   # Connections to hold open to PostgreSQL
reserve_pool_size = 5    # Extra connections for emergencies
"""

# In your Python app, connect to PgBouncer instead of PostgreSQL directly:
engine = create_engine(
    'postgresql://user:pass@pgbouncer.local:6432/mydb',
    pool_size=5,  # Small pool since PgBouncer is the real pooler
    max_overflow=1,
)

With PgBouncer in front, you can afford to reduce application-level pool sizes. Each process only needs 1–2 connections to the pooler; PgBouncer multiplexes thousands of client connections into a smaller set of backend connections.

PgBouncer also offers transaction-level pooling (pool_mode = transaction), which closes the connection after each transaction. This works well for stateless web services but breaks prepared statements and temporary tables. For long-lived connections or prepared statement caching, use session-level pooling.

Common mistakes

Mistake 1: Ignoring pool_pre_ping in cloud environments. AWS RDS closes idle connections after ~900 seconds. Without pool_pre_ping=True, your app will silently reuse dead connections and fail with "connection lost" errors. Enable it always in production.

Mistake 2: Setting pool_size too high to avoid queuing. If you set pool_size=100 across all workers to eliminate any connection wait, you will exceed database limits. Queuing is normal and healthy; it back-pressures your application and prevents overload. Brief waits (10–50ms) are acceptable and force you to scale properly.

Mistake 3: Not accounting for connection leaks. If your code opens a connection and forgets to close it (or catches an exception without proper cleanup), connections accumulate in the pool until it is exhausted. Use context managers and connection pooling tests to catch leaks early:

python
# Good: context manager ensures cleanup
from sqlalchemy import create_engine

engine = create_engine('postgresql://...', pool_pre_ping=True)

with engine.connect() as conn:
    result = conn.execute('SELECT 1')

# Bad: connection not explicitly closed (relies on garbage collection)
conn = engine.connect()
result = conn.execute('SELECT 1')
# Never call conn.close() or use it in a context manager

Mistake 4: Deploying PgBouncer without monitoring. PgBouncer is another service to maintain. Monitor its connection counts, latency, and error rates. If PgBouncer becomes a bottleneck, you will see queries queuing there instead of at the database, which is harder to debug.

Key takeaways

  • SQLAlchemy pools connections by default with QueuePool, but the default pool_size=5 is too small for production multi-worker services.
  • Set pool_size to match concurrent queries per process; typical values are 5–20 depending on query duration and concurrency.
  • Multiply pool_size by all worker processes and instances to calculate total database connections; stay well below your database limit.
  • Enable pool_pre_ping=True to detect and discard stale connections in cloud environments.
  • For serverless, autoscaling, or high-process-count deployments, add PgBouncer as a connection pooling layer to decouple application counts from database connection limits.
  • Monitor pool exhaustion, connection leaks, and pooler latency in production; queuing at the pool is a signal to scale, not a sign of misconfiguration.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles