Per-request, persistent, or pooled: the three modes, the setting everyone misconfigures, and the 2026 feature that changes the default advice.
Here's a question most Django developers can't answer confidently: when your view runs a query, where does the database connection come from, and what happens to it when the request ends?
We write Model.objects.get(...) thousands of times without ever thinking about the connection underneath. And that's fine, until the day your app is under load, connections become the bottleneck, and you're staring at settings named CONN_MAX_AGE and pool with no mental model for which one you need or why they conflict.
Let's build that model. By the end you'll understand:
- Exactly how Django opens, holds, and closes database connections
- The three modes you can run in
- The one setting people constantly misconfigure
- The 2026 change (native connection pooling) that shifts the standard advice
This is straight from the official docs, current for Django 6.x.
The Foundation: A Connection Is Expensive, and Django Opens One Lazily
First, why any of this matters. A database connection isn't free to create. Opening one means a network handshake, authentication, and session setup on the database server. On PostgreSQL, each connection is backed by a real server-side process holding memory.
Establishing a connection can take meaningful milliseconds before a single query runs. So how often Django opens connections directly affects your latency and your database's load.
Now, how Django gets one. Per the docs: Django opens a connection to the database when it first makes a database query. Not when the request starts, but lazily, the moment a query actually needs it. A view that never touches the database never opens a connection. Once open, Django keeps that connection and uses it for the rest of the work that needs it.
The real question, the one that defines the three modes, is:
What happens to that connection when the request finishes?
Mode 1: Per-Request Connections (the Historical Default)
By default, Django's answer is blunt: close the connection at the end of every request. Open it when the first query runs, use it for the duration of that request, throw it away when the response is sent. Next request? Open a brand-new one.
This is controlled by a setting called CONN_MAX_AGE, and its default value is 0, which per the docs preserves the historical behavior of closing the database connection at the end of each request.
# The default - a fresh connection per request, closed at the end
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# CONN_MAX_AGE defaults to 0 → open, use, close, every request
}
}
It's simple and safe: no stale connections, no leaks across requests. But it's also wasteful under load. If you're serving hundreds of requests per second, you're paying that connection-setup cost (handshake, auth, session) hundreds of times per second, for connections you use for a few milliseconds and discard.
That repeated open/close is pure overhead. Which leads to the first optimization.
Mode 2: Persistent Connections (CONN_MAX_AGE)
The idea: instead of throwing the connection away at the end of each request, keep it open and reuse it for the next request. That's a persistent connection, and per the docs it exists precisely to avoid the overhead of re-establishing a connection to the database in each HTTP request.
You enable it by setting CONN_MAX_AGE to a positive number of seconds: the maximum lifetime of a connection before Django closes and replaces it.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 60, # reuse each connection for up to 60s
"CONN_HEALTH_CHECKS": True, # revalidate a reused connection per request
}
}
Now Django opens a connection, and at the start of each request it checks: has this connection exceeded its max age? If not, reuse it. If yes (or if it's broken), close it and open a fresh one.
You've turned "open a connection per request" into "open a connection per minute," eliminating the vast majority of that setup overhead.
Two things the docs are firm about, and both bite people:
-
Each thread keeps its own connection. A persistent connection belongs to one worker thread. So your database must allow at least as many simultaneous connections as you have worker threads across all your app processes. Twenty Gunicorn workers with
CONN_MAX_AGEset means twenty held-open connections sitting on your database even when idle. Do the math against your database's connection limit (PostgreSQL defaults to 100). -
Set
CONN_HEALTH_CHECKS = True. A held-open connection can go dead: the database restarted, a proxy killed an idle connection. Health checks revalidate a reused connection once per request, so you don't hand a stale connection to your query and get an error. And if your database kills idle connections after some time, keepCONN_MAX_AGEbelow that timeout so Django doesn't try to use a connection the server already dropped.
Persistent connections were the standard performance win for years. But they have a limitation that native pooling now addresses, and this is the 2026 part.
Mode 3: Native Connection Pooling (the 2026 Change)
Here's the limitation of persistent connections: they reduce how often you open connections, but they don't share connections intelligently.
Each thread still monopolizes its own connection for the entire request, including the parts of the request that aren't touching the database at all. A request that runs one quick SELECT and then spends 200ms calling an external API keeps its database connection pinned that whole 200ms, doing nothing, unavailable to anyone else.
A connection pool fixes that. A small, shared set of connections is opened once and kept warm. A request borrows one only for the moment it needs it and returns it immediately, so a handful of connections can serve far more concurrent requests than persistent connections could.
Since Django 5.1, this is built in for PostgreSQL (via psycopg 3). No third-party library needed. You enable it in OPTIONS:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 0, # REQUIRED - see the trap below
"OPTIONS": {
"pool": {
"min_size": 4, # keep this many connections warm
"max_size": 16, # cap under load
},
},
}
}
("pool": True accepts sensible defaults if you don't want to tune it.)
Reported latency improvements are real. Teams cite cutting tens of milliseconds off database-touching requests by not paying setup cost per request and not pinning connections during non-database work.
The Trap Everyone Hits: Pooling and Persistent Connections Are Mutually Exclusive
This is the single most important configuration fact in the article, because it crashes apps on startup.
You cannot use native pooling and persistent connections at the same time. If you set "pool" in OPTIONS without also setting CONN_MAX_AGE = 0, Django refuses to start and throws:
ImproperlyConfigured: Pooling doesn't support persistent connections
The reason is clean once you see it. The pool manages connection lifetime itself (via its own max_lifetime / max_idle settings), so Django's persistent-connection lifetime management (CONN_MAX_AGE) would be a second, conflicting manager fighting over the same connections. They're two different strategies for the same job, so you pick exactly one:
| Strategy | CONN_MAX_AGE |
pool in OPTIONS
|
|---|---|---|
| Persistent connections | Positive number | Not set |
| Native pooling | 0 |
Set |
Set pool and forget to zero out CONN_MAX_AGE, and your app won't boot. It's a five-second fix once you know the rule, and a baffling crash if you don't.
The Other Trap: ASGI Changes the Answer
One more current caveat that's easy to miss. Everything above about persistent connections assumes the classic synchronous (WSGI) model, where one thread handles one request at a time.
Under ASGI (async Django), the docs are explicit: persistent connections should be disabled. The async model handles many requests concurrently in ways that don't map cleanly onto "one thread holds one connection," so CONN_MAX_AGE-style persistence is the wrong tool.
Instead, per the docs, use your database backend's built-in connection pooling (the pool option) or a third-party pooler. So if you're running async, pooling isn't just an option. It's the recommended path, and persistent connections are off the table.
Choosing, in Practice
Putting it together, here's the honest 2026 guidance:
-
Small app / low traffic / development: leave the default (
CONN_MAX_AGE = 0). The per-request overhead is negligible at low volume, and it's the simplest, safest behavior. (Don't bother enabling persistence on the dev server. It spawns a thread per request and gains nothing.) -
Moderate traffic, synchronous (WSGI): the classic win is persistent connections:
CONN_MAX_AGEto something like60, plusCONN_HEALTH_CHECKS = True. Easy, effective, well-proven. Just make sure your database's connection limit covers your total worker-thread count. -
Higher concurrency, or you want the best latency, on Postgres + psycopg 3 / Django 5.1+: native pooling (
"pool"inOPTIONS,CONN_MAX_AGE = 0). It reuses connections and avoids pinning them during non-database work. The strongest option, and now built in. - Async (ASGI): pooling, not persistent connections. The docs say so directly.
- Many app servers against a small connection budget (e.g. serverless, or a database with a tight limit): consider an external pooler like PgBouncer in front of everything, because even native pools multiply across processes and can overwhelm a small connection limit.
Why Understanding This Makes You Better at Django
Database connection management is one of those areas that stays completely invisible until it suddenly becomes the thing standing between your app and scaling. Then, if you don't have the model, you're copy-pasting settings from blog posts and hoping.
The developer who understands it reads their traffic and their deployment (WSGI vs ASGI, how many workers, how tight the connection budget) and picks the right mode deliberately:
- Per-request for simplicity
- Persistent for a quick synchronous win
- Pooling for real concurrency
The developer who doesn't either leaves free performance on the table (paying connection setup on every request forever) or pastes a pool config next to a nonzero CONN_MAX_AGE and can't figure out why the app won't boot.
The model itself is simple once the fog clears. A connection is expensive to open. Django opens one lazily on first query. And the only real question is what happens at request's end: throw it away (per-request), keep it for reuse (persistent, CONN_MAX_AGE), or return it to a shared warm pool (pooling, pool in OPTIONS). The hard rules: persistence and pooling are mutually exclusive, and ASGI wants pooling.
Hold that, and connection settings stop being mysterious incantations and become a deliberate choice you make based on how your app actually runs.
The 30-Second Takeaway
- A DB connection is expensive to open (handshake, auth, a server-side process on Postgres). Django opens one lazily on the first query of a request. The key question is what happens to it at the request's end.
-
Mode 1, per-request (default,
CONN_MAX_AGE = 0): open, use, close every request. Simple and safe, but pays setup cost on every request. Wasteful under load. -
Mode 2, persistent (
CONN_MAX_AGE= positive seconds): keep the connection open and reuse it across requests. AddCONN_HEALTH_CHECKS = Trueto avoid stale connections. Caveat: each thread holds its own connection, so your DB's connection limit must cover your total worker threads. -
Mode 3, native pooling (Django 5.1+,
"OPTIONS": {"pool": {...}}): a shared warm pool. Requests borrow a connection only while needed, so they aren't pinned during non-DB work (e.g. slow external API calls). Built in for Postgres/psycopg 3, with real latency wins. -
The crash-on-startup trap: pooling and persistent connections are mutually exclusive. Set
pooland leaveCONN_MAX_AGEnonzero and Django raisesImproperlyConfigured: Pooling doesn't support persistent connections. Pooling requiresCONN_MAX_AGE = 0. - ASGI (async): disable persistent connections. The docs say use pooling instead.
- Rule of thumb:
| Your situation | Use |
|---|---|
| Low traffic / development | Default (CONN_MAX_AGE = 0) |
| Moderate traffic, WSGI | Persistent + health checks |
| High concurrency / best latency | Native pooling |
| Async (ASGI) | Native pooling |
| Tiny connection budget across many servers | PgBouncer in front |
Your Turn
Open your settings.py and look at your DATABASES block. If there's no CONN_MAX_AGE and no pool, you're opening a fresh connection on every single request. Depending on your traffic, one of the two upgrades above might be the easiest performance win you'll find all week.
Which mode is your app running in right now? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.




Top comments (0)