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.
- Check PostgreSQL logs at the error time for crashes, restarts, or
terminating connectionmessages. - If nothing is there, an idle timeout in a firewall, NAT gateway, load balancer, or pooler dropped the connection.
- Enable
CONN_HEALTH_CHECKS = True(Django 4.1+) so persistent connections are tested before reuse. - Keep
CONN_MAX_AGEbelow the shortest idle timeout on the path, or use TCP keepalives. - 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 9ordatabase system is shut downmeans a real crash, often the OOM killer, or a restart.terminating connection due to administrator commandmeans someone, or a tool, ranpg_terminate_backend.terminating connection due to idle-session timeoutmeansidle_session_timeoutis 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.
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.
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_memor connections rather than tuning the client. - Statement timeouts. A query cancelled by
statement_timeoutgives a different error,canceling statement due to statement timeout, and does not close the connection.
Next steps
- Choose connection settings with Use Persistent Database Connections in Django.
- Plan for failovers with PostgreSQL High Availability and Failover for Django.
- For other connection failures, see Fix Django Database Connection Errors in Production.
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.