Preventing Django connection leaks during Celery tasks

This guide is part of Django Database Connection Management. Celery’s long-running worker processes bypass Django’s standard request-response lifecycle. ORM connections persist across tasks instead of closing after execution. Without explicit teardown, idle and idle-in-transaction states accumulate. The database pool limit is eventually reached. This triggers OperationalError: too many connections and cascading worker failures.

This guide delivers exact remediation steps, signal-based hooks, and validation commands. It enforces strict connection lifecycle management for production workloads.

Key operational realities:

  • Celery workers reuse processes, bypassing Django’s per-request connection closure.
  • Unclosed transactions and idle connections accumulate until DB pool limits are hit.
  • Remediation requires explicit lifecycle hooks, connection closing signals, and pool validation.

Diagnosing Connection Exhaustion in Celery Workers

Identify symptoms and isolate leaked connections using database metrics and worker logs. Monitor pg_stat_activity for persistent idle-in-transaction states. Check Celery worker logs for DatabaseError or connection pool timeouts. Correlate connection spikes directly with task execution frequency.

Understanding how persistent processes bypass standard request teardown is critical. Review the Framework Integration & Connection Lifecycle documentation for architectural context on process reuse and connection retention.

Use the following thresholds to trigger incident response:

Metric Warning Threshold Critical Threshold Action
idle connections > 60% of max_connections > 85% of max_connections Scale workers or force-close
idle-in-transaction age > 30s > 120s Terminate backend PID
Celery connection errors > 5/min > 20/min Drain queue, restart workers

Query the database directly to isolate offending connections. Filter by application_name matching your Celery worker prefix. Cross-reference PIDs with active task logs.

Why Celery workers accumulate connections Django closes connections at the request_finished signal, which never fires in a Celery worker, so a connection opened by the first task stays open for the life of the worker process regardless of CONN_MAX_AGE. Web request — Django closes the connection for you query request_finished close_old_connections() bounded — one request Celery task — no such signal exists task 1 task 2 task 3 one connection, open for the worker's entire lifetime CONN_MAX_AGE does not help here Django checks connection age in `close_old_connections()`, which is only called by the request signals — so in a worker it is never checked The fix is to call it yourself at task boundaries `task_prerun` and `task_postrun` are the Celery signals that correspond to Django's request signals
Django's connection cleanup is wired to request signals that a Celery worker never emits, which is why a worker's connection count only ever goes up.

Implementing Explicit Connection Teardown Hooks

Force Django to close connections after each Celery task completes. The task_postrun signal provides deterministic cleanup. Django’s connections.close_all() iterates over all configured database aliases and closes each connection. Handle transaction rollback explicitly on task failure before closing.

Align your implementation with established Django Database Connection Management best practices. This ensures ORM lifecycle control remains consistent across synchronous and asynchronous execution paths.

Deploy the following signal handler in your Celery configuration module:

from celery.signals import task_postrun
from django.db import connections

@task_postrun.connect
def close_db_connections(**kwargs):
    """
    Deterministically close all thread-local Django DB connections
    after Celery task execution to prevent pool exhaustion.
    """
    connections.close_all()

connections.close_all() calls close() on every alias defined in settings.DATABASES. It is equivalent to iterating connections.all() and calling conn.close() on each — do not do both. Place this handler in a module that Celery imports at startup (e.g., your celery.py or an apps.py ready() hook).

If you prefer a more conservative approach that only closes unhealthy connections, use close_old_connections() instead:

from celery.signals import task_postrun
from django.db import close_old_connections

@task_postrun.connect
def recycle_db_connections(**kwargs):
    """
    Close connections that are unusable or have exceeded CONN_MAX_AGE.
    More conservative than close_all(); suitable when CONN_MAX_AGE > 0.
    """
    close_old_connections()

Use close_all() when workers are short-lived or when you want a clean slate after every task. Use close_old_connections() when CONN_MAX_AGE is set and you want to preserve warm connections that are still valid.

Counting the Worker Fleet’s Real Demand

Before changing any setting, do the arithmetic — it usually explains the incident without further investigation, and it determines whether the fix is teardown hooks or a proxy.

Celery’s connection demand is the product of four numbers that are configured in different places by different people. The number of worker containers comes from the deployment. --concurrency sets processes per container under the default prefork pool. Each of those processes holds one Django connection per database alias. And any task that fans out to a second database, or a service that runs several queues in separate deployments, multiplies again.

A representative example: 6 worker containers × --concurrency=8 × 2 database aliases = 96 connections held continuously, from a settings module whose author was thinking about web requests. Add the web tier’s own 48 and the total is 144 against a database that also serves migrations, monitoring, and a psql session during the incident.

Two structural choices reduce this more effectively than any setting. Running fewer, larger workers with --concurrency matched to actual parallelism — rather than defaulting to CPU count on a machine that is doing I/O-bound work — cuts the multiplier directly. And separating queues into their own deployments with their own settings lets a low-volume queue run at concurrency 2 rather than inheriting 8.

Factor Where It Is Set Typical Value Effect On Connections
Worker containers Deployment replicas 4–12 Linear multiplier
--concurrency Celery command line Defaults to CPU count Linear multiplier
Database aliases DATABASES in settings 1–3 Linear multiplier
CONN_MAX_AGE Settings, shared with web 0 or 600 Decides whether they are held between tasks
Queue deployments Separate deployments 1–5 Each has its own worker fleet

The --concurrency row is where the easiest win usually is. Celery’s default is the CPU count of the machine, which is the right default for CPU-bound work and much too high for tasks that spend most of their time waiting on a database or an API. Setting it deliberately, based on measured task parallelism, frequently halves the fleet’s connection demand with no throughput cost.

Celery connection demand multiplies four ways Worker containers multiplied by concurrency multiplied by database aliases gives the connection count, and each factor is configured in a different place, so no single file shows the total. containers 6 set in the deployment × --concurrency 8 set on the command line × DB aliases 2 set in settings.py held continuously 96 before the web tier is counted No single file contains this number Three of the four factors live in different repositories or manifests, which is why the total is almost never in the capacity plan Write it down as a comment next to CONN_MAX_AGE, with the date and the numbers it was derived from
The four multipliers are configured in different systems, so the product exists nowhere. Deriving it explicitly is usually enough to explain an overnight exhaustion incident.

Validating Pool Health and Connection Reuse

Verify remediation steps and ensure stable pool utilization under load. Execute synthetic task bursts to simulate production traffic. Track connection delta before, during, and after execution.

Run the following validation query against your database:

SELECT count(*), state 
FROM pg_stat_activity 
WHERE datname = 'your_db_name' 
GROUP BY state;

This query quickly verifies if idle connections drop back to baseline after Celery task bursts. Monitor django.db.connections thread-local state post-task using Django’s debug toolbar or custom middleware.

Execute a controlled load test:

  1. Record baseline pg_stat_activity counts.
  2. Dispatch 500 concurrent tasks via Celery.
  3. Wait for task completion queue to drain.
  4. Re-run the validation query.
  5. Confirm idle count returns to baseline ±10%.

Deviations indicate lingering references or unhandled transaction blocks. Audit task code for raw SQL cursors or third-party libraries bypassing the ORM.

Where to attach connection cleanup in Celery The task_prerun signal discards any connection left stale by an idle period, and task_postrun closes it after the task, giving a worker the same bounded lifetime that a web request has. task_prerun close_old_connections() discard anything reaped while idle task body runs connection opened lazily at the first query task_postrun close_old_connections() release before the worker goes idle again why both, not just postrun postrun bounds the hold time; prerun protects the first query from a socket reaped during a quiet period the case these hooks still miss a task killed by a hard time limit skips postrun — set max-tasks-per-child so workers recycle anyway
Two signals reproduce what Django's request cycle does automatically. The `prerun` half matters as much as the `postrun` half, because a worker's connection can be reaped while it waits for work.

Common Mistakes

Issue Root Cause Operational Impact
Relying solely on CONN_MAX_AGE Limits persistent connection lifetime only Does not force closure between tasks. Idle connections remain until timeout expires.
Calling close_old_connections() when CONN_MAX_AGE=None close_old_connections() only closes connections that exceed CONN_MAX_AGE; with None, no connection is considered old Leaves all idle connections open. Use close_all() instead.
Ignoring transaction rollback on failure Failed tasks leave transactions open DB holds row locks. Connection counts as active. Causes cascading deadlocks.

FAQ

Why do Django connections leak in Celery but not in Gunicorn?
Gunicorn spawns fresh processes per request or uses WSGI request lifecycle hooks. These hooks automatically close connections. Celery workers are long-running processes that reuse threads. They bypass Django’s automatic request teardown.
Does CONN_MAX_AGE=0 fix Celery connection leaks?
No. Setting it to 0 disables persistent connections entirely. It forces a new connection per query. This increases latency and DB handshake overhead. It does not address the underlying leak pattern caused by tasks never calling connection teardown.
How do I verify connections are actually closing?
Run SELECT count(*) FROM pg_stat_activity WHERE state = 'idle' before and after a Celery burst. A stable or decreasing count confirms successful teardown. Monitor worker memory for concurrent validation.
Does --pool=gevent or --pool=eventlet change the connection arithmetic?
Substantially. Those pools run many greenlets inside one process, so the multiplier becomes greenlet concurrency rather than process count, and Django’s thread-local connection storage no longer maps cleanly onto execution contexts. Either use a real pool that understands the concurrency model, or keep --pool=prefork where the one-connection-per-process model holds.
Does Celery Beat need the same treatment?
Yes, and it is frequently forgotten because it runs one process and looks harmless. The scheduler holds a connection for its entire lifetime when it stores its schedule in the database, and that connection is subject to the same idle reapers as any other.
Should CONN_MAX_AGE simply be 0 for workers?
It is the safe default, and for short tasks the handshake cost is small relative to the task. A non-zero value is worth it only for high-frequency short tasks, and only with task_prerun cleanup in place so a stale socket is discarded rather than used.
What does --max-tasks-per-child do for connections?
It recycles the worker process after N tasks, which closes every connection it held as a side effect. That makes it a blunt but effective backstop against both connection leaks and memory growth, at the cost of paying process start-up more often.