Opened 6 weeks ago
Last modified 2 weeks 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 (16)
comment:1 by , 6 weeks ago
comment:2 by , 6 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 , 6 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 , 6 weeks ago
| Owner: | changed from to |
|---|
comment:6 by , 6 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 , 6 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 , 6 weeks ago
| Owner: | changed from to |
|---|
comment:9 by , 5 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 , 4 weeks 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 , 4 weeks 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. 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 , 4 weeks 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 , 4 weeks 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.
comment:14 by , 3 weeks ago
| Has patch: | unset |
|---|
comment:15 by , 2 weeks ago
Hi, I would like to work on this issue. Could you please confirm if it's available and guide me if there are any specific expectations?
comment:16 by , 2 weeks ago
Thanks. If helpful, I'll note the way contributions work around here, we depend on you to study the issue and help us develop the acceptance requirements together. There are very few tickets where we know exactly what we want but stay quiet about it, leaving the details "as an exercise for the contributor". Those are marked mostly with the Easy Pickings flag.
comment:17 by , 2 weeks ago
| Has patch: | set |
|---|
Hi, I’ve submitted a PR for this issue:
https://github.com/django/django/pull/21018
Feedback is welcome.
I’d like to work on a documentation patch for this issue.