FastAPI + Django in Production: Lessons From a Hybrid Stack
Learn how to combine Django and FastAPI, including async ORM pitfalls, thread and database connection issues, testing challenges, and practical solutions.
Join the DZone community and get the full member experience.
Join For FreePicture the scene: One of the services in your backend is a mature Django app that no one has the resources, time, or, frankly, the will to rewrite. The ORM, the admin panel, and the broader ecosystem all earn their keep.
But you’re looking for the best way to describe your API, and FastAPI catches your eye. It looks like a great fit: native typing, pydantic-based validation, OpenAPI out of the box, and of course the support for async endpoints.
That's the situation our team found itself in - we decided to use both frameworks and take from each what suited us best. Not everything went smoothly — this post is what we built, what broke afterward, and what we learned.
The First Win
So we wired it up, and it works. FastAPI runs as the ASGI application, and the existing Django app plugs into it.
# asgi.py
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
from django.core.asgi import get_asgi_application
from fastapi import FastAPI
app = FastAPI()
django_app = get_asgi_application()
app.mount("/legacy", django_app)
Great! Now:
- Both Django and FastAPI endpoints live side by side, with no pressure to refactor everything in a single day — that was important for us.
- In the new parts of the app, Django steps back into a single role: communicating with the database through its models.
- Endpoints can be either sync or async.
That was the win. But there was the other side also.
Pitfall 1: Async Endpoints Started Running One at a Time
When you reach out to external services, chances are you also want to enrich the request with something from your database, or save the result back to it (we did). Here's a tiny example: A single async handler that fetches data about Order from the database (we use Postgres) and forwards it to an external payment provider.
from asgiref.sync import sync_to_async
from fastapi import FastAPI
app = FastAPI()
@app.post("/orders/{order_id}/dispatch")
async def dispatch_order(order_id: int) -> OrderDTO:
order = await sync_to_async(get_order)(order_id) # fetch from DB
await client.send_order(order) # call external service
return order
# code that uses a Django model
def get_order(order_id: int) -> OrderDTO:
order = Order.objects.get(id=order_id)
return OrderDTO(id=order.id, amount=order.amount)
Inside an async function, you can’t call the Django ORM synchronously. The documented approach is sync_to_async, which moves the synchronous call to a separate thread so it doesn’t block the event loop.
Now let's see what happens under concurrent load. Drop a three-second sleep into get_order:
from django.db import connection
def get_order(order_id: int) -> OrderDTO:
order = Order.objects.get(id=order_id)
with connection.cursor() as cursor:
cursor.execute("SELECT pg_sleep(3);")
return OrderDTO(id=order.id, amount=order.amount)
And fire three requests in parallel:
URL="http://localhost:8000/orders/1/dispatch"
curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL" &
curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL" &
curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL"
>> 3.012s
>> 6.024s
>> 9.037s
We expected ~3 seconds and got nine. The handlers ran one after another, not concurrently. And if you log the thread and database connection IDs from inside get_order, all three requests print the same values.
Why? By default sync_to_async(get_order) runs with thread_sensitive=True, which means the function runs in the same thread as all other thread_sensitive functions. A standalone Django ASGI app does extra work here: it opens a fresh context per request, so requests run in parallel. The benchmark suggests that in our setup FastAPI doesn't: all three sync_to_async calls land on the same thread and line up one behind another.

The event loop itself stays free, by the way: a purely async route keeps responding while the three /dispatch requests wait in that queue. But three async handlers with ORM calls queue up on the same thread, sharing the same connection.
For a moment we hoped Order.objects.aget(...) or other Django async ORM helpers would save us here. They won't: for now under the hood they call the same sync_to_async.
Can we just flip to sync_to_async(..., thread_sensitive=False)? Probably not - it is not a safe default. Django carries a lot of per-request state in thread-locals: the current DB connection, transaction.atomic(), etc. The Django docs say: "a lot of existing Django code assumes it all runs in the same thread."
What to Do About It
No silver bullet, but two approaches hold up:
- Split handlers by what they touch. Reserve
async deffor endpoints that genuinely don't touch the ORM — async-native HTTP calls, cache reads, etc. For ORM-bound endpoints, declare them as plain sync routes. FastAPI runs sync routes on its thread pool, so they actually run in parallel, and each thread gets its own Django connection. As long as these endpoints don't make many slow external calls, this can work. - Move the work out of the handler entirely. If your project already runs with a message broker, the possible answer to "external API + DB write inside a handler" is to stop doing it inside a handler at all. Drop an event on the bus, let consumers handle the side effects, return immediately. The catch: this only makes sense when an event-driven flow already fits your system — because it is, of course, no small refactor.
Pitfall 2: Tests That Can't See Their Own Data
Now let's write a test for get_order — a sync endpoint that reads an order from the DB. The test runs with pytest-django: we create an order in the database and call the handler.
# app.py
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/orders/{order_id}")
def get_order(order_id: int) -> OrderDTO:
try:
order = Order.objects.get(id=order_id)
except Order.DoesNotExist:
raise HTTPException(status_code=404)
return OrderDTO(id=order.id, amount=order.amount)
@pytest.mark.django_db
def test_get_order():
Order.objects.create(id=1)
response = TestClient(app).get("/orders/1")
assert response.status_code == 200 # and we'll have 404
You get 404 Not Found. The handler ran, looked at the database, and the order was nowhere to be found.
Four facts conspire here:
- Pytest runs your test's data setup in one thread; when the FastAPI test client calls the endpoint, the handler runs in another.
pytest-djangowraps every test in an open transaction and rolls it back at the end. That's how the suite stays fast and isolated. The transaction lives on a single database connection.- Django opens a database connection per thread.
- Postgres defaults to
READ COMMITTEDisolation: one connection cannot see another connection's uncommitted writes.
So: the test body runs in the pytest thread. Its Order.objects.create(...) uses connection 1, inside pytest-django's open transaction. When TestClient hits the endpoint, FastAPI dispatches the handler to a worker thread from its thread pool, on another thread with its own connection 2.
Connection 2 looks at the database and sees no order, because connection 1 hasn't committed, so connection 1's write is effectively invisible to everyone else.

Again — What to Do?
Test in layers. Unit-test the endpoint contract with the ORM mocked - those tests don't cross thread or connection boundaries, so the visibility problem simply can't appear. Test business logic and data access in their own tests, without going through TestClient.
For cases when the full end-to-end test is still needed - the commonly suggested fix is @pytest.mark.django_db(transaction=True). This switches the test to a mode where writes actually commit, so other connections can see them. But it has its cost: pytest-django now does a database flush after every test, and the suite gets noticeably slower. On a large suite, for us "noticeably" meant minutes - too much on every run, so we use it only for exceptional cases.
The Recap
FastAPI brings obvious wins — OpenAPI docs, clean endpoint code, typing all the way through; Django gives you a greatly tested ORM and admin. Putting them in the same process gives us both — and a thread-and-connection model that doesn't behave the way we'd expect. Budget for the architecture work before you budget for the migration.
Was it worth it? Yes — we got the clean, typed API we were after, and we kept Django's ORM instead of porting the whole data layer to another framework. Would we do it again? Not sure. The trade-offs of this integration may outweigh its benefits for us, so other combinations might be a better fit.
If you’ve run into the same solution and found an approach with better trade-offs, please share; the comments are open.
Reproduce it yourself. An example with a benchmark and failing tests is in https://github.com/evchibisova/fastapi-over-django-test.
Opinions expressed by DZone contributors are their own.
Comments