Troubleshooting
PostgreSQL & databases

Fix "Server Closed the Connection Unexpectedly" in Django and PostgreSQL

Fix the Django OperationalError server closed the connection unexpectedly: find whether PostgreSQL crashed, a firewall or load balancer killed idle connections, or a failover happened, then tune CONN_MAX_AGE, CONN_HEALTH_CHECKS, and TCP keepalives.

Problem statement

Sentry shows a steady trickle of errors, often the first request after a quiet period:

django.db.utils.OperationalError: server closed the connection unexpectedly
    This probably means the server terminated abnormally
    before or while processing the request.

The client sent a query on a connection it believed was open, and the other end had gone away. Despite the wording, the server usually did not crash. Something between Django and PostgreSQL, or PostgreSQL itself, closed an idle connection, and Django reused it later without noticing.

How a stale connection failsDjango keepsconnection openIdle periodminutes passSomething closesNAT, LB, serverNext queryclosed unexpectedlyA reused connection fails on its first query after something silently dropped it.
  1. Check PostgreSQL logs at the error time for crashes, restarts, or terminating connection messages.
  2. If nothing is there, an idle timeout in a firewall, NAT gateway, load balancer, or pooler dropped the connection.
  3. Enable CONN_HEALTH_CHECKS = True (Django 4.1+) so persistent connections are tested before reuse.
  4. Keep CONN_MAX_AGE below the shortest idle timeout on the path, or use TCP keepalives.
  5. For Celery and long-running scripts, call close_old_connections() between units of work.

Step-by-step solution

1. Decide whether the server or the network closed it

Look at the database log around the timestamp:

sudo journalctl -u postgresql --since "2026-10-03 09:00" --until "2026-10-03 09:10"
  • server process ... was terminated by signal 9 or database system is shut down means a real crash, often the OOM killer, or a restart.
  • terminating connection due to administrator command means someone, or a tool, ran pg_terminate_backend.
  • terminating connection due to idle-session timeout means idle_session_timeout is set.
  • Nothing at all means the connection was dropped on the network path.

2. Find idle timeouts on the path

Typical silent killers and their defaults:

  • AWS NAT gateways and Network Load Balancers drop idle TCP flows after 350 seconds.
  • Azure load balancers default to 4 minutes.
  • Google Cloud firewall connection tracking expires idle connections after 10 minutes.
  • PgBouncer closes idle server connections after server_idle_timeout (600 s by default).

If Django holds connections longer than any of these, the first query after the gap fails.

a stale connection over a NAT gatewayRequest endst = 0, conn keptNo trafficquiet periodNAT expires flowt = 350 sNext requestreuses connectionQuery failsclosed unexpectedlyNeither side is told when a middlebox forgets the connectiondjango-deployment.com
The error appears on the first request after a quiet period, which is why it is intermittent.

3. Turn on health checks for persistent connections

With CONN_MAX_AGE above zero, Django reuses connections across requests. Since Django 4.1, it can test them first:

DATABASES["default"].update({
    "CONN_MAX_AGE": 60,
    "CONN_HEALTH_CHECKS": True,
})

At the start of each request, Django runs a cheap check on a reused connection and reconnects if it fails. This removes almost all of these errors in web processes.

4. Keep connections alive below the timeout

TCP keepalives make the operating system send probes on idle connections, which keeps NAT and load balancer state fresh:

DATABASES["default"]["OPTIONS"] = {
    "keepalives": 1,
    "keepalives_idle": 60,
    "keepalives_interval": 10,
    "keepalives_count": 5,
}

These are libpq parameters that psycopg passes through. An idle time of 60 seconds is well below every default listed above.

5. Handle workers and scripts

Celery workers and long management commands do not go through Django's request cycle, so health checks never run for them. Close stale connections between tasks or batches:

from django.db import close_old_connections

for batch in batches:
    close_old_connections()
    process(batch)

Celery's Django integration closes connections around tasks, but a task that sleeps or waits on an API for minutes can still find its connection dead. See Fix Django InterfaceError: Connection Already Closed.

make connection reuse safeCONN_HEALTH_CHECKS = TrueDjango 4.1 and laterCONN_MAX_AGE below idle timeoutsNAT, LB, poolerTCP keepalives every 60 slibpq OPTIONSclose_old_connections in loopsworkers and scriptsRetries for idempotent taskscovers failoversWeb requests are covered by health checks; background work needs explicit caredjango-deployment.com
Most projects need only the first two items; keepalives cover strict networks.

Explanation

A TCP connection has no heartbeat by default. If a middlebox removes its tracking entry, both endpoints still think the connection is open. The next packet Django sends is dropped or answered with a reset, and libpq reports that the server closed the connection. The wording dates from a time when the main cause was a crashed backend, which is why it says "the server terminated abnormally". In cloud networks, idle timeouts are far more common.

Edge cases / notes

  • Failover. A managed database failover closes every connection. Health checks reconnect web processes; tasks in flight fail and should be retried.
  • Memory pressure. If PostgreSQL logs show backends killed by signal 9, the database server ran out of memory. Reduce work_mem or connections rather than tuning the client.
  • Statement timeouts. A query cancelled by statement_timeout gives a different error, canceling statement due to statement timeout, and does not close the connection.

Next steps

FAQ

Did my PostgreSQL server crash?

Probably not. Check the server log; if it has no crash or termination messages, an idle timeout on the network path closed the connection.

Should I set CONN_MAX_AGE to 0?

That opens a new connection per request and avoids stale ones, at the cost of connection setup time. Health checks with a modest CONN_MAX_AGE give you both reuse and safety.

Why does it only happen in the morning or after quiet periods?

Idle connections time out when there is no traffic. Busy periods keep connections active, so the error shows up after gaps.

Does PgBouncer fix this?

It moves the problem: Django's connections to PgBouncer can still go stale. Keep health checks on and align PgBouncer's timeouts with your network.

Keep going