Set PostgreSQL Statement Timeouts for Django
Stop runaway queries from exhausting Django workers: set statement_timeout per connection or role, give migrations and Celery different limits, handle QueryCanceled errors, and work with PgBouncer.
Problem statement
A single bad query (an unindexed filter on a large table, a report with a huge date range, a search that scans everything) can run for minutes. Meanwhile it holds a Gunicorn worker, consumes database CPU and I/O, and may hold locks. When several users trigger it, workers run out, and the whole site returns 502 or 504, even for pages that have nothing to do with the query.
Gunicorn's --timeout eventually kills the worker, but the query keeps running in PostgreSQL. A server-side statement_timeout cancels the query itself. This guide sets sensible limits for web requests, different ones for migrations and background jobs, and handles the resulting errors cleanly.
- Set
statement_timeoutfor web traffic, for example 10 to 30 seconds, below Gunicorn's--timeout. - Configure it in
DATABASES["default"]["OPTIONS"]with"options": "-c statement_timeout=15000", or on the application's database role. - Give migrations and long background jobs a separate role, alias, or setting with a higher or no limit.
- Handle
django.db.utils.OperationalErrorcaused by cancellation (QueryCanceled) and return a clear error. - With PgBouncer in transaction mode, set timeouts on the role instead of through startup options.
- Fix the queries that hit the timeout; the timeout is a guardrail, not a cure.
Step-by-step solution
1. Choose values
| Traffic | statement_timeout | Why |
|---|---|---|
| Web requests | 10–30 s | below Gunicorn and proxy timeouts |
| Celery tasks | 60 s to several minutes | batch work, still bounded |
| Migrations | none, or high | schema changes on large tables take time; use lock_timeout instead |
| Reports on a replica | minutes | separate connection and resources |
Set the web value below Gunicorn's --timeout so PostgreSQL cancels the query and Django returns an error before Gunicorn kills the worker.
2. Set it per connection in Django
# settings/production.py
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get("DB_STATEMENT_TIMEOUT_MS", "15000"))
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# NAME, USER, PASSWORD, HOST, PORT ...
"OPTIONS": {
"options": f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS}",
},
}
}
options is a libpq connection parameter that sets server parameters at connection start. Values without units are milliseconds. Each process type can set a different value through the environment: DB_STATEMENT_TIMEOUT_MS=15000 for web, 120000 for Celery, and 0 (no limit) for the migration step.
Check the effective value:
python manage.py shell -c "
from django.db import connection
with connection.cursor() as c:
c.execute('SHOW statement_timeout'); print(c.fetchone())
"
3. Or set it on database roles
ALTER ROLE myapp_web SET statement_timeout = '15s';
ALTER ROLE myapp_worker SET statement_timeout = '2min';
ALTER ROLE myapp_migrate SET statement_timeout = 0;
Role settings apply to new sessions of that role. Separate roles for web, workers, and migrations give each its own limits and privileges, and make pg_stat_activity easier to read. Settings in the connection options override role settings.
4. Override for specific queries
For a known long operation inside a web-context process (an admin export, say), raise the limit for one transaction only:
from django.db import connection, transaction
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout = '120s'")
rows = list(expensive_queryset)
SET LOCAL reverts at the end of the transaction, so the connection's normal limit applies afterwards. Long work like this usually belongs in a background task instead.
5. Handle cancellations
When the timeout fires, PostgreSQL cancels the statement with canceling statement due to statement timeout, and Django raises django.db.utils.OperationalError wrapping psycopg's QueryCanceled. Unhandled, it becomes a 500. Handle it where a friendlier response makes sense:
import psycopg
from django.db import OperationalError
from django.shortcuts import render
class QueryTimeoutMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
if isinstance(exception, OperationalError) and isinstance(exception.__cause__, psycopg.errors.QueryCanceled):
return render(request, "errors/query_timeout.html", status=503)
return None
Still report these errors to your error tracker: each one is a query that needs fixing. The same cancellation can happen from lock_timeout or a manual pg_cancel_backend, so inspect the message when triaging.
6. PgBouncer compatibility
With PgBouncer in transaction pooling mode, clients cannot set session parameters through startup options; PgBouncer rejects unknown startup parameters (unsupported startup parameter: options) unless configured to ignore them, and ignoring them means the timeout is not applied. Options:
- Set
statement_timeouton the role or database (step 3); the server applies it to PgBouncer's server connections. - Use
SET LOCALinside transactions for per-query overrides, which works in transaction mode.
7. Pair with lock and idle timeouts
ALTER ROLE myapp_web SET lock_timeout = '5s';
ALTER DATABASE myapp SET idle_in_transaction_session_timeout = '60s';
statement_timeout includes time spent waiting for locks, but a dedicated lock_timeout fails lock waits faster, and idle_in_transaction_session_timeout catches sessions that are not running a statement at all.
Verification check:
python manage.py shell -c "
from django.db import connection, OperationalError
try:
with connection.cursor() as c:
c.execute('SELECT pg_sleep(60)')
except OperationalError as e:
print('cancelled as expected:', e)
"
With a 15-second limit, the command fails after about 15 seconds with a statement timeout message.
Explanation
When a Gunicorn worker times out, Gunicorn kills the Python process, but PostgreSQL does not know the client is gone until it tries to send results, so the query can keep consuming resources. statement_timeout is enforced by the database itself: the backend cancels the running statement and returns an error to the client. Setting it slightly below the application server's timeout makes the failure clean and fast at every layer: the query stops, Django gets an exception it can handle, and the worker is free for the next request.
Edge cases / notes
Migrations
Never run migrations under the web timeout. Large index builds or table rewrites legitimately take longer. Use a migration-specific role or environment value, plus lock_timeout to avoid blocking traffic.
Replicas
Hot standby servers can cancel long queries that conflict with replication (canceling statement due to conflict with recovery). That is a different mechanism, controlled by max_standby_streaming_delay and hot_standby_feedback.
When to automate this
Put per-role timeouts in database provisioning, per-process DB_STATEMENT_TIMEOUT_MS values in service templates, and alert on the rate of QueryCanceled errors. A spike after a deploy points directly at the new slow query.
Next steps
- Align with worker timeouts in Gunicorn Worker Timeout in Django: Root Causes and Fixes.
- Catch transactions that sit idle with Fix Idle in Transaction Connections from Django.
- Configure pooling with Set Up PgBouncer for Django.
- Find the queries that hit the limit with Find Slow Django Queries with pg_stat_statements.
FAQ
How do I set a query timeout for Django with PostgreSQL?
Add "options": "-c statement_timeout=15000" to DATABASES["default"]["OPTIONS"], or set statement_timeout on the application's database role.
What value should statement_timeout have?
For web requests, lower than Gunicorn's timeout, typically 10 to 30 seconds. Background jobs and migrations need higher limits.
Does statement_timeout work with PgBouncer?
Not through startup options in transaction pooling mode. Set it on the database role, or use SET LOCAL inside transactions.
What error does Django raise when the timeout fires?
django.db.utils.OperationalError, caused by psycopg's QueryCanceled, with the message "canceling statement due to statement timeout".