Two projects in one year. One API was rewritten in FastAPI and production got twice as fast. The other was back on Django in a week. Both choices were deliberate.
There is no best framework. There is a job, a team size, and the price you pay for an admin panel or for honest async.
Django 5.2 LTS shipped on 2 April 2025, supported until April 2028. FastAPI sat on 0.124.4 by December. Twenty years of banks and government portals on one side. Seven years and await without sync_to_async on the other.
I spent eight months moving code both ways. What follows is what survived the rake, not the marketing.
What each one can do
Django 5.2 has async views, half an ORM (aget(), acreate(), asave()), an admin that can stand up fifty tables in an evening, and four thousand packages. It does not have async transactions. Async admin is not planned.
Turn on async views: half the middleware stays sync, you pay a penalty. Write a transaction: wrap it in sync_to_async. Customize the admin: after three hours it is cheaper to write your own.
It saves you when the MVP is due yesterday, a junior broke a migration, and at 3 a.m. the ORM logs show where it fell over.
FastAPI 0.124 has async out of the box, OpenAPI, Pydantic V2, dependency injection, native WebSocket and SSE. No admin. No migrations. Alembic by hand.
Need an admin: two days on FastAPI-Admin, it is crooked, another day of crutches. Forget an Alembic revision: you rebuild production schema from logs. A junior asks how to do this: you show five ways.
It saves you when three hot endpoints eat hardware, partners need docs, and a WebSocket chat should fit in fifty lines.
Performance
Friday, 11:45 p.m. Traffic up ten times. Django started serving 503. Eight instances held. After the rewrite to FastAPI, three were enough.
Same hardware (4 vCPU, 8 GB RAM, uvicorn/gunicorn), plain JSON:
# FastAPI (async endpoint)
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id, "name": "Item"}
# Result: ~25000 req/s (wrk benchmark)# Django 5 (async view)
from django.http import JsonResponse
async def read_item(request, item_id):
return JsonResponse({"item_id": item_id, "name": "Item"})
# Result: ~18000 req/s (wrk benchmark)FastAPI is 30-40% faster. In money that is two or three servers in every ten.
Django async shows 18k req/s only if there is no sync middleware (goodbye sessions), no admin, and transactions are wrapped in sync_to_async. A normal production setup lands around 12k. The gap with FastAPI is already 2x.
I wrote a pretty async view. Production gave me 12k instead of the promised 18k.
async def my_view(request):
users = [u async for u in User.objects.all()]
return JsonResponse({"users": users})SessionMiddleware sat in MIDDLEWARE. Django silently went sync on every request. I removed every sync middleware. No sessions. Admin dead.
On simple SELECT the gap is noise: SQLAlchemy async about 15 ms for a hundred rows, Django async ORM about 18 ms.
# FastAPI + SQLAlchemy 2.0 async
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
async def get_users(db: AsyncSession):
result = await db.execute(select(User).limit(100))
return result.scalars().all()# Django 5 async ORM
from django.contrib.auth.models import User
async def get_users():
users = [user async for user in User.objects.all()[:100]]
return usersTransactions in Django async do not work.
# This will not work
async with transaction.atomic():
await User.objects.acreate(...)
await Profile.objects.acreate(...)
# RuntimeError: atomic() doesn't support async
# You end up here
@sync_to_async
def create_user_with_profile(data):
with transaction.atomic():
user = User.objects.create(...)
Profile.objects.create(user=user, ...)
# Sync again. Where is async?SQLAlchemy runs async transactions without that dance.
The API contract
Monday, 10:00. Fifteen fields, validation in twenty places.
With Django: two hours in serializers.py, clean_* methods, docs by hand. Miss a field: a production bug.
With FastAPI: change the Pydantic model. OpenAPI updates itself. The frontend looks at /docs.
from pydantic import BaseModel, Field, validator
from typing import Annotated
class UserCreate(BaseModel):
email: str = Field(..., pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
age: Annotated[int, Field(ge=18, le=120)]
@validator("email")
def email_must_be_lowercase(cls, v):
return v.lower()
@app.post("/users/")
async def create_user(user: UserCreate):
return userfrom django import forms
from rest_framework import serializers
class UserCreateForm(forms.Form):
email = forms.EmailField()
age = forms.IntegerField(min_value=18, max_value=120)
def clean_email(self):
return self.cleaned_data['email'].lower()
class UserSerializer(serializers.Serializer):
email = serializers.EmailField()
age = serializers.IntegerField(min_value=18, max_value=120)Time to change a contract: DRF 30-60 minutes, FastAPI 5-10. Docs live in the code.
@app.post("/items/",
summary="Create item",
response_description="Created item details",
tags=["items"])
async def create_item(
item: ItemCreate,
x_token: Annotated[str, Header(description="API token")]
):
"""
Create item with metadata:
- **name**: item name (required)
- **price**: item price in USD
"""
return itemSwagger at /docs, ReDoc at /redoc, schema at /openapi.json.
In DRF the same thing is drf-spectacular and a decorator on every endpoint:
from rest_framework.decorators import api_view
from drf_spectacular.utils import extend_schema
@extend_schema(
summary="Create item",
tags=["items"],
request=ItemSerializer,
responses={201: ItemSerializer}
)
@api_view(['POST'])
def create_item(request):
serializer = ItemSerializer(data=request.data)
if serializer.is_valid():
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)What is in the box
| Feature | Django 5 | FastAPI |
|---|---|---|
| Admin panel | Built-in, powerful | No (third-party: FastAPI-Admin, SQLAdmin) |
| ORM | Django ORM (async) | SQLAlchemy, Tortoise ORM, SQLModel |
| Migrations | django-admin migrate | Alembic (manual) |
| Authentication | django.contrib.auth | OAuth2/JWT (manual or libraries) |
| CORS/CSRF | Middleware + decorators | CORSMiddleware (Starlette) |
| Background tasks | Celery, Django-Q | ARQ, Celery, BackgroundTasks |
| WebSockets | Django Channels | Built-in support |
| GraphQL | Graphene-Django | Strawberry, Ariadne |
| Testing | Django TestCase | pytest + httpx |
Django: batteries included, five thousand pages of docs, one style. FastAPI: less magic, Pydantic, SQLAlchemy and Alembic learned separately.
Three cases
EdTech, an investor wants a demo in 72 hours: content moderation and a mobile API. Django admin, four models in half an hour. DRF, ten endpoints in two hours. git push to Heroku. The demo was ready in 48 hours. FastAPI would have eaten two days on the admin.
Fintech, 100k requests a minute, Django on forty c5.2xlarge, bill $6k a month. Fifteen hot endpoints moved to FastAPI, async SQLAlchemy, connection pooling. Sixteen instances left, $2.4k a month. P99: 250 ms to 95 ms. Native async without the sync middleware tax did the work.
SaaS, three people, the CTO rewrote everything in FastAPI to look modern. A month later FastAPI-Admin crawled, the junior broke Alembic, every service had its own auth. They rolled back to Django and DRF. A team of three does not carry a zoo of microservices.
Hybrid
Often both are enough.
Frontend (Next.js)
↓
FastAPI (public API)
↓
Django (admin)
↓
Shared PostgreSQL
The public edge holds the load. Editors live in an admin they already know. One database.
A temporary bridge while you move endpoints:
from asgiref.sync import sync_to_async
from django.contrib.auth.models import User
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await sync_to_async(User.objects.get)(id=user_id)
return {"id": user.id, "email": user.email}The other way, FastAPI to Django, happens too: Pydantic models in DRF, business logic as-is, async wrapped in sync views. Rarer.
Production numbers
JSON API, about 10k req/s, 4 vCPU, 8 GB, Postgres, Redis:
| Metric | Django 5 + Gunicorn | FastAPI + Uvicorn |
|---|---|---|
| P50 latency | 45ms | 32ms |
| P99 latency | 180ms | 95ms |
| Max RPS | 8500 | 12000 |
| Memory (idle) | 250MB | 180MB |
CRUD with a transaction, about 1k req/s:
| Metric | Django 5 async | FastAPI + SQLAlchemy |
|---|---|---|
| Avg latency | 85ms | 78ms |
| DB pool usage | 60% | 55% |
| Error rate | 0.01% | 0.01% |
At moderate load the gap is pennies. Under high load FastAPI wins.
Django async without the brochure
Admin is sync forever.
class MyAdmin(admin.ModelAdmin):
async def get_queryset(self, request):
return await MyModel.objects.all()
# 500, SynchronousOnlyOperation, ticket: won't fixThe client required an admin. I wrote an async API. Django kept sync middleware. I got async complexity and sync speed. After that it is usually two services: FastAPI outside, Django only for admin.
Transactions:
from django.db import transaction
async def create_user(data):
async with transaction.atomic():
user = await User.objects.acreate(**data)
await Profile.objects.acreate(user=user)That fails. Workaround:
from asgiref.sync import sync_to_async
@sync_to_async
def create_user_with_transaction(data):
with transaction.atomic():
user = User.objects.create(**data)
Profile.objects.create(user=user)
await create_user_with_transaction(data)One sync middleware and async is almost free to lose: each sync to async to sync hop is about 1 ms, then it is a thread per request.
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
]The ORM is marked async-unsafe. Global state is not coroutine-aware:
async def concurrent_updates():
tasks = [
User.objects.filter(id=1).aupdate(score=F('score') + 1)
for _ in range(100)
]
await asyncio.gather(*tasks)The result can be anything.
Works: async views, aget / acreate / asave / adelete / aupdate, async for, async middleware if every middleware is async, WebSocket via Channels. Does not: admin, atomic(), some third-party packages, template rendering in sync middleware.
Django async makes sense when a view fans out to many HTTP calls, you can drop sync middleware, transactions are rare, and admin is not the centre of the world.
import httpx
async def aggregate_data(request):
async with httpx.AsyncClient() as client:
responses = await asyncio.gather(
client.get("https://api1.example.com/data"),
client.get("https://api2.example.com/data"),
client.get("https://api3.example.com/data"),
)
return JsonResponse({"data": [r.json() for r in responses]})An order with a transaction is better in FastAPI:
async def create_order(data):
async with async_session() as session:
async with session.begin():
order = Order(**data)
session.add(order)
await session.flush()
for item in data['items']:
order_item = OrderItem(order_id=order.id, **item)
session.add(order_item)
await session.commit()
return orderWhat is new in 2025
Django 5.2 LTS: composite primary keys, models import themselves in python manage.py shell, MySQL defaults to utf8mb4, async methods on User and permissions, Python 3.10-3.14. Still no async admin.
FastAPI 0.124.4: Pydantic v1 and v2 side by side, 401 instead of 403 when credentials are missing, fastapi run --entrypoint module:app, hierarchical security scopes fixed, dependency cache without scopes.
How I choose
Admin today, a team of one to three, the client does not know the word API: Django. Paying $500-1000 extra a month for hardware is often cheaper than a week on a homemade admin.
Traffic above 10k req/min, a tight server budget, the team lives in async, no admin needed: FastAPI. Price: one or two weeks on Alembic, auth, and everything Django keeps in contrib.
Need both: FastAPI outside, Django inside, one PostgreSQL.
After eight months I no longer pick by hype. Django outlived a hundred JS frameworks. FastAPI will stop being new in three years too. I look at the admin, the load, and the size of the team.
Useful:

