Should You Run Django on Free-Threaded Python Yet?
A client asked me in September whether we could get more out of the four-core box their Django app runs on without paying for a bigger one. The honest answer was to try the free-threaded build, so I spent an afternoon on it. The build itself was easy. Then uv pip install 'psycopg[binary]' failed, and the afternoon turned into a different kind of lesson: the framework is further along than the driver stack underneath it, and the driver stack is what decides whether you can ship.
Here is what I found, what I’d tell a client today, and the one place free-threading is already worth the trouble.
What Django actually claims right now
Django’s main branch carries the trove classifier Programming Language :: Python :: Free Threading :: 2 - Beta and pins asgiref>=3.12.1. That classifier is a real signal — it went in with a pull request that also enabled a ubuntu-free-threading job in CI — but read the words. “2 - Beta” is not “5 - Production/Stable”, and the PR was explicitly not backported to stable/6.1. If you install Django from PyPI today, you are not running the code that gets tested on a free-threaded interpreter.
The version matrix matters too. Django 6.1 and 6.2 both support Python 3.12, 3.13 and 3.14 — no 3.15, whose first release was scheduled for 2026-10-09. So the only free-threaded target that Django supports at all is 3.14t. And the tracking ticket, Django #36983, is still open at triage stage “Someday/Maybe” with component Core (Other). The same ticket notes that 3.13t was dropped from the test matrix over potential memory leaks from dynamically created classes.
So: supported enough to test, not supported enough to promise. That is a reasonable place for a framework to be, and it is a bad place to put a client’s production traffic.
The driver is the wall
Django’s own docs say psycopg 3.1.12+ is the recommended PostgreSQL driver, and that psycopg2 support “is likely to be deprecated and removed at some point in the future.” Connection pooling via "pool": True is psycopg 3 only. For a Postgres-backed Django app, psycopg is not optional.
psycopg’s free-threading work lives in issue #1095, and it is not finished. The maintainer’s position there is unambiguous: “Work is ongoing but not finished yet”, “Binary packages will only be released when we will declare the support complete”, and until then “support is experimental: use it at your risk.” Asked about shipping a binary that disables the GIL, he answered: “Not at the moment, no. I don’t want to do anything that may destabilize the binary.” A report on that same thread is exactly the failure I hit — psycopg-binary ships cp314 wheels but no cp314t, so the install resolves to nothing on a free-threaded interpreter.
The community compatibility tracker tells the same story with more context: psycopg has an upstream issue and no listed first-supported version, while Pillow landed support in 11.0.0, NumPy in 2.1.0, and cryptography in 46.0.0. The scientific stack got there first. The web stack did not.
This is worse than a missing wheel, because the failure mode is quiet. Importing a C extension that is not marked free-threading-safe can automatically re-enable the GIL, with a warning printed. You can end up running a free-threaded interpreter with the GIL switched back on — paying the single-threaded overhead, getting none of the parallelism, and only finding out if someone reads the logs. If you try this at all, assert it at boot:
# myproject/apps.py
import sys, sysconfig, warnings
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
class CoreConfig(AppConfig):
name = "core"
def ready(self):
if sysconfig.get_config_var("Py_GIL_DISABLED") != 1:
return # ordinary build, nothing to check
if sys._is_gil_enabled():
raise ImproperlyConfigured(
"Free-threaded build detected but the GIL is enabled again. "
"Some C extension re-enabled it; check import warnings."
)
warnings.warn("Running free-threaded with the GIL disabled.", stacklevel=2)
sysconfig.get_config_var("Py_GIL_DISABLED") is the documented way to branch on the build, and sys._is_gil_enabled() reports what the running process actually does. Use both. The build and the runtime state are different questions.
What the overhead actually costs
The deal you are offered is: give up some single-threaded speed, get real parallelism across threads. The published numbers are narrower than the hype. CPython’s own how-to puts the single-threaded overhead at about 1% on macOS aarch64 to 8% on x86-64 Linux, workload-dependent. PEP 779 set a hard target of 15% slower than the with-GIL build and noted the figure was around 10%, with a 20% memory target.
Memory is the part people skip. On the free-threaded build, interned strings are immortal, the allocator is mimalloc rather than pymalloc, and non-GC objects carry a larger header — None is 32 bytes instead of 16 on AMD64. Deferred and per-thread reference counting mean some memory is freed by the collector rather than at zero refcount, so RSS does not fall when you expect it to. On a small VPS that is a real line item, and it lands on the same box you were trying to squeeze.
Where Django itself still contends
Even with a perfect driver, Django’s internals were written under the assumption that one thread runs Python at a time. The Django forum thread on free-threading performance is the best reading on this, because CPython’s Sam Gross names specific hot spots rather than hand-waving:
Field.creation_counter, a shared class-level counter, is contended and invalidates CPython’s attribute lookup cache — and some code paths create temporaryFieldinstances per request, such asAutoField.rel_db_type.functools.lru_cachewith a bound scales badly, because a strict LRU policy updates metadata on every hit.- Lock contention on the set lookup in
HttpHeaders.UNPREFIX_HEADERS, which a frozenset would fix.
Those are not exotic edge cases; they are the request path. The thread also records the disagreement that makes this slow to fix: moving creation_counter assignment into contribute_to_class would change field ordering, and at least one third-party package depends on that ordering. This is normal framework maintenance, and it is why the ticket is open rather than closed.
What I’d actually do
Split the tiers. Keep the web tier on the ordinary GIL build, because that tier is the one holding psycopg. Put free-threading where the dependency graph is already green.
The case that paid off for that client was a nightly ingest job: pull a few thousand rows out of Postgres once, hand the parsing and numeric work to a thread pool, write back once. The DB touch is serial and happens on the normal interpreter; the CPU-bound middle runs under 3.14t with NumPy, which has shipped free-threaded support since 2.1.0 per the tracker. Two processes, one boundary, no gamble on the driver.
Getting the interpreter is the easy part. The installation guide documents uv venv --python 3.14t, brew install python-freethreading, and python3.14t as the executable name; the manylinux images ship python3.14t too, which is what I used in the container:
FROM quay.io/pypa/manylinux_2_28_x86_64 AS crunch
RUN python3.14t -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# numpy has shipped free-threaded wheels since 2.1.0; psycopg has not
RUN pip install --no-cache-dir "numpy>=2.1"
COPY crunch/ /app/crunch/
# fail loudly rather than silently running with the GIL back on
RUN python -c "import sys, numpy; assert not sys._is_gil_enabled(), 'GIL re-enabled'"
CMD ["python", "/app/crunch/run.py"]
Note the assertion in the build, not just at runtime. The whole risk of this build is that it looks fine while quietly doing nothing for you.
The thing to watch is not Django. It is psycopg #1095 and whether binary wheels tagged cp314t appear on PyPI. The day they do, re-run your own benchmark on your own query mix — not a hello-world view — and compare against gunicorn --threads on the GIL build. Until then, a free-threaded Django web tier is a science project, and the honest version of “can we get more out of this box” is still: fix the N+1s, add the index, and move the CPU work out of the request.
Sources
- Python HOWTO: Free-threaded CPython
- PEP 779 – Criteria for supported status for free-threaded Python
- Django forum: Improve free-threading performance
- Django ticket #36983
- Django PR #21623 — asgiref 3.12 and free-threading CI
- Django
pyproject.tomlon main - psycopg issue #1095 — Support free-threaded python
- py-free-threading compatibility tracking
- Installing a free-threaded CPython
- Django FAQ: What Python version can I use with Django?
- Django docs: PostgreSQL notes
- CPython developer guide: status of Python versions