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.

Timeouts from inside outQuerystatement_timeout 10sDjango viewhandles QueryCanceledGunicorn--timeout 30sNginxproxy_read_timeout 35sThe database limit should fire first, so the query stops and the worker is freed cleanly.
  1. Set statement_timeout for web traffic, for example 10 to 30 seconds, below Gunicorn's --timeout.
  2. Configure it in DATABASES["default"]["OPTIONS"] with "options": "-c statement_timeout=15000", or on the application's database role.
  3. Give migrations and long background jobs a separate role, alias, or setting with a higher or no limit.
  4. Handle django.db.utils.OperationalError caused by cancellation (QueryCanceled) and return a clear error.
  5. With PgBouncer in transaction mode, set timeouts on the role instead of through startup options.
  6. Fix the queries that hit the timeout; the timeout is a guardrail, not a cure.

Step-by-step solution

1. Choose values

Trafficstatement_timeoutWhy
Web requests10–30 sbelow Gunicorn and proxy timeouts
Celery tasks60 s to several minutesbatch work, still bounded
Migrationsnone, or highschema changes on large tables take time; use lock_timeout instead
Reports on a replicaminutesseparate 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}",
        },
    }
}
a timeout on every web connectionDB_STATEMENT_TIMEOUT_MS = int(os.environ.get("DB_STATEMENT_TIMEOUT_MS", "15000"))"OPTIONS": {"options": f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS}",},121Milliseconds: 15 s, below Gunicorn’s timeout.2Sent as a startup parameter by libpq.Celery can read a larger value from its own environmentdjango-deployment.com
PgBouncer in transaction mode may reject this; set it on the role instead.

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
the query is cancelled, the worker survivesSlow query startsmissing index15 s passstatement_timeoutPostgreSQL cancelsQueryCanceledDjango raisesOperationalError, loggedWorker free againno Gunicorn killAn error with a traceback beats a killed worker with nonedjango-deployment.com
The transaction is aborted too; the request cannot continue using it.

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_timeout on the role or database (step 3); the server applies it to PgBouncer's server connections.
  • Use SET LOCAL inside transactions for per-query overrides, which works in transaction mode.
where to set it behind PgBouncerConnection optionssent as a startup parameter✕transaction pooling may reject it✕or ignore_startup_parameters drops itALTER ROLE … SET✓applies on every server connection✓works with any pool mode✓separate roles for web and workersCheck SHOW statement_timeout through the pooler, not directlydjango-deployment.com
Role settings apply to new sessions only.

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';
three timeouts, three problemsStopsstatement_timeoutone query running too longlock_timeout✓waiting too long for a lockidle_in_transaction_session_timeoutsessions holding a transaction openEach covers a gap the others leavedjango-deployment.com
Migrations want a generous statement_timeout but a short lock_timeout.

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

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".

Keep going