Opened 3 weeks ago
Last modified 7 days ago
#36964 assigned Cleanup/optimization
Clarify how persistent connections interact with runserver
| Reported by: | Adam Sołtysik | Owned by: | Youssef Tarek Ali |
|---|---|---|---|
| Component: | Documentation | Version: | 5.2 |
| Severity: | Normal | Keywords: | |
| Cc: | Triage Stage: | Accepted | |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
From https://docs.djangoproject.com/en/5.2/ref/databases/#caveats:
The development server creates a new thread for each request it handles, negating the effect of persistent connections. Don’t enable them during development.
Something here seems to be incorrect. I'm using 'CONN_MAX_AGE': None with the development server, and it works as expected. My requests are significantly faster than with the default setting. When analyzing with SELECT * FROM pg_stat_activity WHERE datname = '<dbname>', I can see a single connection created and persisting after the first request, and it disappears after autoreload.
Change History (12)
comment:1 by , 3 weeks ago
comment:2 by , 3 weeks ago
Sequential requests reuse the same thread, which results in single DB connection visible . The connection is dropped only on autoreload.
It seems the documentation warning is for concurrent requests, where multiple threads may create separate connections, rather than sequential development testing. I believe that the documentation is not wrong but little bit clarification can be given.
comment:3 by , 3 weeks ago
| Has patch: | set |
|---|---|
| Owner: | set to |
| Status: | new → assigned |
I have assigned this to myself and drafted a documentation update. The new wording clarifies that persistent connections do work for sequential requests in the development server, while noting that they are reset upon auto reload and that concurrent requests still trigger new threads. Verified the build locally with make html.
comment:4 by , 3 weeks ago
| Owner: | changed from to |
|---|
comment:6 by , 3 weeks ago
| Triage Stage: | Unreviewed → Accepted |
|---|
A pull request has been opened for this documentation clarification:
https://github.com/django/django/pull/20827
The PR updates the wording about persistent connections when using the development server to clarify that they may still be observed when CONN_MAX_AGE is enabled, but their behavior may not reflect production environments due to autoreload and the thread-per-request model.
comment:7 by , 3 weeks ago
| Triage Stage: | Accepted → Unreviewed |
|---|
Reverting the triage stage back to 'Unreviewed' for now so it can be officially assessed and accepted by a core reviewer/triager, as per the standard workflow.
comment:8 by , 3 weeks ago
| Owner: | changed from to |
|---|
comment:9 by , 2 weeks ago
,Sorry for taking ownership of this ticket. I'm still new to the Django ticket workflow and didn't realize it was already being worked on. My apologies for the confusion.
follow-up: 11 comment:10 by , 9 days ago
| Has patch: | unset |
|---|---|
| Summary: | Documentation incorrectly states that persistent connections don't work with runserver → Clarify how persistent connections interact with runserver |
| Triage Stage: | Unreviewed → Accepted |
The quoted statement in the documentation was written by one of Django's most esteemed contributors (2ee21d9f0d9eaed0494f3b9cd4b5bc9beffffae5). While something may have changed in the intervening years, we need a more rigorous explanation.
I believe that multiple threads may be used by the built-in runserver, even if requests are not concurrent. I verified this making this modification:
diff --git a/django/db/backends/base/base.py b/django/db/backends/base/base.py
index 23015a57a3..94e7197dde 100644
--- a/django/db/backends/base/base.py
+++ b/django/db/backends/base/base.py
@@ -51,6 +51,9 @@ class BaseDatabaseWrapper:
queries_limit = 9000
def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS):
+ print("DB Wrapper")
+ import threading
+ print(threading.get_ident())
# Connection related attributes.
# The underlying database connection.
self.connection = None
And making several requests:
Starting WSGI development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. DB Wrapper 139299259958976 [14/Mar/2026 00:41:02] "GET /admin/ HTTP/1.1" 200 11255 [14/Mar/2026 00:41:04] "GET /admin/polls/choice/ HTTP/1.1" 200 11343 [14/Mar/2026 00:41:04] "GET /admin/jsi18n/ HTTP/1.1" 200 3342 [14/Mar/2026 00:41:08] "GET /admin/polls/question/ HTTP/1.1" 200 13477 DB Wrapper 139299250517696 [14/Mar/2026 00:41:09] "GET /admin/jsi18n/ HTTP/1.1" 200 3342 [14/Mar/2026 00:41:12] "GET /admin/polls/choice/ HTTP/1.1" 200 1134
I don't believe the AI-generated patch correctly explained the nuances here.
comment:11 by , 9 days ago
| Has patch: | set |
|---|
Replying to Tim Graham:
The quoted statement in the documentation was written by one of Django's most esteemed contributors (2ee21d9f0d9eaed0494f3b9cd4b5bc9beffffae5). While something may have changed in the intervening years, we need a more rigorous explanation.
I believe that multiple threads may be used by the built-in runserver, even if requests are not concurrent. I verified this making this modification:
diff --git a/django/db/backends/base/base.py b/django/db/backends/base/base.py index 23015a57a3..94e7197dde 100644 --- a/django/db/backends/base/base.py +++ b/django/db/backends/base/base.py @@ -51,6 +51,9 @@ class BaseDatabaseWrapper: queries_limit = 9000 def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS): + print("DB Wrapper") + import threading + print(threading.get_ident()) # Connection related attributes. # The underlying database connection. self.connection = NoneAnd making several requests:
Starting WSGI development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. DB Wrapper 139299259958976 [14/Mar/2026 00:41:02] "GET /admin/ HTTP/1.1" 200 11255 [14/Mar/2026 00:41:04] "GET /admin/polls/choice/ HTTP/1.1" 200 11343 [14/Mar/2026 00:41:04] "GET /admin/jsi18n/ HTTP/1.1" 200 3342 [14/Mar/2026 00:41:08] "GET /admin/polls/question/ HTTP/1.1" 200 13477 DB Wrapper 139299250517696 [14/Mar/2026 00:41:09] "GET /admin/jsi18n/ HTTP/1.1" 200 3342 [14/Mar/2026 00:41:12] "GET /admin/polls/choice/ HTTP/1.1" 200 1134I don't believe the AI-generated patch correctly explained the nuances here.
Thanks for the logs and feedback, Natalia. I see now that my previous rationale was off.
I wrote this update myself to make sure the technical details are correct. The new PR explains that database connections are thread-local and that there's no guarantee of thread reuse for sequential requests, even with Keep-Alive. I also added a note about the auto-reloader.
The documentation builds correctly locally. Let me know if I've still missed anything.
comment:12 by , 9 days ago
How is the development server's behavior different from multithreaded WSGI servers used in production? Do they "guarantee that sequential requests will use the same thread"?
comment:13 by , 9 days ago
The quoted statement in the documentation was written by one of Django's most esteemed contributors (2ee21d9f0d9eaed0494f3b9cd4b5bc9beffffae5). While something may have changed in the intervening years, we need a more rigorous explanation.
The statement was probably correct when it was written, and what changed later was the keep-alive support added around https://github.com/django/django/pull/10609.
How is the development server's behavior different from multithreaded WSGI servers used in production? Do they "guarantee that sequential requests will use the same thread"?
It seems that, unlike production servers, runserver generally keeps creating new threads for each client, as mentioned earlier and confirmed in a forum post. But the threads are reused with HTTP keep-alive, which allows persistent DB connections to work.
I’d like to work on a documentation patch for this issue.