Opened 6 months ago

Last modified 3 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: yes
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 (20)

comment:1 by Mankameshwar Mishra, 6 months ago

I’d like to work on a documentation patch for this issue.

comment:2 by Shubh Rai, 6 months 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 Youssef Tarek Ali, 6 months ago

Has patch: set
Owner: set to Youssef Tarek Ali
Status: newassigned

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.

PR: https://github.com/django/django/pull/20825

comment:4 by Shubh Rai, 6 months ago

Owner: changed from Youssef Tarek Ali to Shubh Rai

comment:6 by Shubh Rai, 6 months ago

Triage Stage: UnreviewedAccepted

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 VIZZARD-X, 6 months ago

Triage Stage: AcceptedUnreviewed

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 Natalia Bidart, 6 months ago

Owner: changed from Shubh Rai to Youssef Tarek Ali

comment:9 by Shubh Rai, 6 months 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.

comment:10 by Tim Graham, 5 months ago

Has patch: unset
Summary: Documentation incorrectly states that persistent connections don't work with runserverClarify how persistent connections interact with runserver
Triage Stage: UnreviewedAccepted

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.

in reply to:  10 comment:11 by Youssef Tarek Ali, 5 months 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 = 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.

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.

New PR: https://github.com/django/django/pull/20905

Last edited 5 months ago by Youssef Tarek Ali (previous) (diff)

comment:12 by Tim Graham, 5 months 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 Adam Sołtysik, 5 months 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 David Smith, 5 months ago

Has patch: unset

comment:15 by Sushmita Yadav, 5 months 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 Jacob Walls, 5 months 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 CHARAN KAKUMANU, 5 months ago

Has patch: set

Hi, I’ve submitted a PR for this issue:
https://github.com/django/django/pull/21018

Feedback is welcome.

comment:18 by blighj, 3 weeks ago

I went to review the recent PR for this ticket and I don't think this needs any change at all.

I rooted through the code and from my reading, runserver never reuses a database connection across client connections. socketserver.ThreadingMixIn starts one thread per accepted socket, and database connections are thread-local, so a connection lives and dies with the socket that created it.

The only bit of the docs that is slightly out of date is the "for each request". That was true when written, there was no support for KeepAlive, every request was a connection.

So yes now, a keep-alive connection will sometimes land on the same thread and it'll look like DB connections are being shared. Which requests share a socket is effectively nondeterministic from the developer's side, it depends on how your browser happens to pool its connections, so you can't rely on it. Open a second tab, return a StreamingHttpResponse (and I'm sure there are more) and you'll get a different client connection, a different thread and no sharing, plus an orphaned DB connection left open until it's GC'd.

The docs are right to advise against using persistent connections with runserver. I don't think they need updating, the only thing that I'd make any case for is to change creates a new thread for each request to creates a new thread for each client connection, but in a section about database connections that overloads "connection" enough that I don't think even that is worth it.

comment:19 by Adam Sołtysik, 3 weeks ago

Personally, I find persistent connections useful with runserver, as they make navigating through my app smoother, so the sentence "Don’t enable them during development" looks misleading. I think that a softer "Depending on how your browser handles keep-alive, they may or may not work for you" would do much better.

comment:20 by blighj, 3 weeks ago

Thanks for the feedback, I take your point. To be honest I've used it myself in my last gig, for the same reasons, we had a shared db server over the cloud used by our cms team (5 or 6 people max) and every so often we'd all get a "sorry, too many clients already" error and all work was halted until someone kicked the db server. The first few times it happened it was painful as it took us a while to realise the db needed restarting. We never figured out what was the cause and it was a rare occurrence, dozen times a year, though often in clusters. We could see that the db server was full of idle connections but couldn't figure out where they were coming from. I now highly suspect it was the persistent connections.
But yeah we had it on more because we had it on in production and thought it would help speed up dev without ever knowing about the docs recommendation for runserver.
The question to decide is whether Django's reference documentation on databases should give a soft recommendation to use something that was not designed to work, may or may not work, and may cause a hard-to-diagnose failure.
A community blog post, with lots of benchmarks and going into details on the inner workings and tradeoffs, I can see that no problem. Something in the Django docs, not so sure...

What if we said.

  • docs/ref/databases.txt

    a b  
    8989to be reused. This will help keep the number of simultaneous connections to
    9090this database small.
    9191
    92 The development server creates a new thread for each request it handles,
    93 negating the effect of persistent connections. Don't enable them during
    94 development.
     92The development server isn't designed for persistent connections. It creates
     93a new thread for each HTTP connection it handles, so database connections
     94aren't reused between them and can be left orphaned. You may see one reused
     95while a client keeps an HTTP connection alive, but that's outside your
     96control. Persistent connections shouldn't be enabled during development.
    9597
    9698When Django establishes a connection to the database, it sets up appropriate
    9799parameters, depending on the backend being used. If you enable persistent

These seem like accurate claims for the docs to me. It acknowledges how persistent connections may appear to work, points out that it isn't designed, and gives a clue to the failure mode that would have helped my old team. It also downgrades the don't to a shouldn't, if people want to take the risk, that's on them. And it doesn't open up Django maintainers to bug reports asking why it doesn't work reliably, or where all these idle connections have come from.

To double check the claims were accurate I ran a little test against Postgres, with 40 requests with and without keep alive:

idle backends
baseline 0
40 requests, 1 keep-alive connection 1
40 requests, 40 separate connections 41
after gc.collect() 0

So you can see from the first pass there is a single connection serving all 40 requests. The second creates one per client connection and leaves them sitting idle in the database until they're garbage collected, (41 because the keep-alive connection from the first pass is still there too).

comment:21 by blighj, 3 weeks ago

Patch needs improvement: set
Note: See TracTickets for help on using tickets.
Back to Top