Django Ninja

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django Ninja 1.7.1 (released 2026-09-19) on Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Pydantic 2.13.5, Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03. Django Ninja 1.7.1 lists Django 5.2 LTS, 6.0 and 6.1 among its supported versions and needs Pydantic 2 (pydantic>=2.0,<3.0.0); nothing on this page differs on Django 5.2 LTS. Every listing was run in a scratch project on Python 3.13, Django 6.1.1 and SQLite: manage.py check (also under -W error) and a suite of five tests pass, and every curl call was made against Uvicorn, with its real output pasted below. Django Ninja is a third-party package that turns plain functions with type hints into JSON API endpoints: the hints declare where each input comes from and what shape it must have, Pydantic validates it, and the same hints produce an OpenAPI schema and interactive docs. It was written in 2020 to bring the FastAPI style of programming into Django, while keeping Django’s ORM, URLconf, authentication and middleware. None of the four books this section was cross-checked against covers Django Ninja, so every statement here comes from the official Django Ninja documentation and from running the code. In the Bookshelf application that grows across this section, the running example is a small books API: list and search books, read one, and let an authenticated editor create, update and delete them, plus a partner endpoint protected by an API key.

In a hurry: what goes where

Need Rule of thumb Section

Start an API

One NinjaAPI() instance, mounted with path("api/v1/", api.urls).

Installing and mounting a first API

Group endpoints

A Router(tags=[…​]) per app, mounted with api.add_router("/books/", router).

Routers: one per app

Read inputs

{name} in the path is a path parameter; any other simple argument is a query parameter; a Schema argument is the JSON body.

Path, query and body parameters

Shape input and output

Schema for hand-written shapes, ModelSchema with an explicit fields list to reflect a model.

Schemas: Schema and ModelSchema

Choose the status code

response={201: BookOut, 409: Message} and return Status(201, book); raise HttpError(…​) to abort.

Responses, status codes and errors

Protect an endpoint

auth= on the operation, router or API: APIKeyHeader, HttpBearer, django_auth, or a list of them.

Authentication: APIKey, HttpBearer and django_auth

Avoid blocking

async def endpoint plus the async ORM (aget, acreate, aget_object_or_404) under an ASGI server.

Async endpoints

Page through lists

@paginate(PageNumberPagination) below the route decorator, with response=list[Item].

Pagination

Publish docs

Swagger UI at docs/, schema at openapi.json, export_openapi_schema for a file.

OpenAPI schema and interactive docs

Test it

ninja.testing.TestClient(router) (or TestAsyncClient) inside a Django TestCase.

Testing with TestClient

Where Ninja sits in the request cycle

A NinjaAPI instance is an ordinary Django URLconf entry: api.urls returns a list of URL patterns, so a request travels through Django’s middleware stack and URL resolver exactly as it would for any view. Once the resolver picks an operation, Ninja takes over: it runs the authentication callables, then parses and validates the path, query, header, cookie and body inputs into typed arguments, calls the function, and finally validates the return value against the declared response schema and serialises it to JSON. A validation failure on the way in becomes a 422 response; a failed authentication becomes a 401; the function never runs in either case.

sequenceDiagram participant C as Client participant D as Django (middleware + URL resolver) participant N as NinjaAPI operation participant F as Your function C->>D: HTTP request D->>N: resolved to api.urls pattern N->>N: auth callables (401 if all fail) N->>N: parse + validate inputs with Pydantic (422 on error) N->>F: typed arguments F-->>N: model instance, queryset, dict or Status(...) N->>N: validate against response schema, render JSON N-->>D: HttpResponse D-->>C: response (back through middleware)

Because the endpoint function receives Django’s HttpRequest as its first argument, everything that works in a view works here too: request.user set by AuthenticationMiddleware, sessions, messages, get_object_or_404() (whose Http404 Ninja turns into a JSON 404) and the cache framework.

Installing and mounting a first API

Django Ninja is one package; it pulls in Pydantic 2. Adding ninja to INSTALLED_APPS is optional: without it the Swagger UI assets load from a CDN, with it Django’s staticfiles serves them from your own server, and the export_openapi_schema management command becomes available.

$ python -m pip install "django-ninja==1.7.1"
$ python -c "from importlib.metadata import version; print(version('django-ninja'))"
1.7.1
# config/settings.py (excerpt)
import os


SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # one stable key for every worker: the signed API tokens depend on it

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "ninja",  # optional: self-hosted docs assets, export_openapi_schema
    "catalog",
]

The API object lives in its own module and is mounted like any other URLconf. urls_namespace names the URL namespace used by reverse(); title and version end up in the OpenAPI document. Two NinjaAPI instances with different prefixes and namespaces are how Ninja runs two API versions side by side.

# config/api.py
from django.contrib.auth import aauthenticate
from ninja import NinjaAPI, Schema
from ninja.errors import HttpError

from catalog.api import partner_router, router as books_router
from catalog.auth import issue_token

api = NinjaAPI(title="Bookshelf API", version="1.0.0", urls_namespace="bookshelf-api")
api.add_router("/books/", books_router)
api.add_router("/partners/", partner_router)


class Credentials(Schema):
    username: str
    password: str


class TokenOut(Schema):
    token: str


@api.post("/token", response=TokenOut, auth=None)
async def token(request, credentials: Credentials):
    user = await aauthenticate(request, username=credentials.username, password=credentials.password)
    if user is None:
        raise HttpError(401, "Invalid credentials")
    return {"token": issue_token(user)}
# config/urls.py
from django.contrib import admin
from django.urls import path

from .api import api

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/v1/", api.urls),
]

The models behind the example are deliberately small:

# catalog/models.py
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=200)
    isbn = models.CharField(max_length=13, unique=True)
    published = models.DateField()
    price = models.DecimalField(max_digits=6, decimal_places=2)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title

Routers: one per app

Declaring every operation on the api object works for a demo; a real project gives each app a Router and mounts it with api.add_router(prefix, router). A router accepts the same auth= and tags= arguments as the API, and tags become the groups in the generated docs. Routers nest (router.add_router(…​)), add_router() also accepts a dotted import string such as "catalog.api.router", and since Django Ninja 1.6 the same router may be mounted on more than one NinjaAPI instance, which makes "the same endpoints under /api/v1/ and `/api/v2/`" straightforward.

# catalog/api.py (top of the module)
from datetime import date
from typing import Annotated

from django.db import IntegrityError
from django.shortcuts import aget_object_or_404, get_object_or_404
from ninja import Path, Query, Router, Schema, Status
from ninja.pagination import PageNumberPagination, paginate
from ninja.security import django_auth

from .auth import PartnerKey, SignedToken
from .models import Author, Book
from .schemas import BookIn, BookOut, BookPatch, Message

router = Router(tags=["books"])

The operation decorators are get, post, put, patch and delete (plus api_operation(["GET", "POST"], …​) for several methods on one function). Each takes the path relative to the router prefix and keyword arguments such as response=, auth=, summary=, description=, tags=, operation_id=, deprecated= and url_name=. Every operation is a named URL inside the API’s namespace: by default the name is the function name, so reverse("bookshelf-api:get_book", kwargs={"book_id": 3}) returns /api/v1/books/3, and url_name= overrides it.

Path, query and body parameters

Ninja decides where each function argument comes from by looking at the path string and the type hint:

  • a name that appears in braces in the path ("/{book_id}") is a path parameter;

  • any other argument with a simple type (int, str, date, bool, …​) is a query parameter, required unless it has a default;

  • an argument annotated with a Schema subclass is the JSON body;

  • the explicit markers Path, Query, Body, Header, Cookie, Form and File (all importable from ninja) override that guess and carry validation constraints, written either as Query[FilterSchema] or as Annotated[str, Path(pattern=…​)].

The listing endpoint groups its optional filters in a Schema marked as Query[…​], so they read as one object in the function and as separate query parameters in the docs:

# catalog/api.py (continued)
class BookFilters(Schema):
    q: str | None = None
    author_id: int | None = None
    published_after: date | None = None


@router.get("/", response=list[BookOut])
@paginate(PageNumberPagination, page_size=2)
def list_books(request, filters: Query[BookFilters]):
    books = Book.objects.select_related("author")
    if filters.q:
        books = books.filter(title__icontains=filters.q)
    if filters.author_id:
        books = books.filter(author_id=filters.author_id)
    if filters.published_after:
        books = books.filter(published__gt=filters.published_after)
    return books


@router.get("/isbn/{isbn}", response=BookOut)
def get_by_isbn(request, isbn: Annotated[str, Path(pattern=r"^\d{13}$")]):
    return get_object_or_404(Book.objects.select_related("author"), isbn=isbn)


@router.get("/{book_id}", response=BookOut)
def get_book(request, book_id: int):
    return get_object_or_404(Book.objects.select_related("author"), pk=book_id)

The values arrive already converted: book_id is an int, published_after a datetime.date. Input that cannot be converted never reaches the function; Ninja answers 422 with Pydantic’s error list, and loc says which part of the request was wrong:

$ curl -s "http://127.0.0.1:8000/api/v1/books/?q=kin&page_size=1"
$ curl -s "http://127.0.0.1:8000/api/v1/books/abc"
$ curl -s "http://127.0.0.1:8000/api/v1/books/?published_after=yesterday"
{"items": [{"author": {"id": 2, "name": "Octavia E. Butler"}, "id": 3, "title": "Kindred", "isbn": "9780807083697", "published": "1979-06-01", "price": "11.00"}], "count": 1}

{"detail": [{"type": "int_parsing", "loc": ["path", "book_id"], "msg": "Input should be a valid integer, unable to parse string as an integer"}]}

{"detail": [{"type": "date_from_datetime_parsing", "loc": ["query", "published_after"], "msg": "Input should be a valid date or datetime, input is too short", "ctx": {"error": "input is too short"}}]}

Order matters exactly as in a URLconf: /isbn/{isbn} is declared before /{book_id}, and a path such as /books/abc reaches get_book and fails validation rather than falling through to a 404. Django path converters also work ("/{int:book_id}"); with a converter a non-matching value does not match the URL at all, so the client gets a 404 instead of a 422.

Schemas: Schema and ModelSchema

ninja.Schema is a subclass of Pydantic’s BaseModel that is configured to read attributes from arbitrary objects, so a function may return a model instance, a queryset or a dict and the response schema will pull the fields it needs. Everything Pydantic 2 offers works unchanged: field types and constraints through Field(…​), Annotated, @field_validator and @model_validator, nested schemas, model_dump(). The FastAPI section explains those building blocks in depth: Pydantic v2 Essentials for models, constraints and validators, and Python Types and Annotated for the type-hint syntax both frameworks rely on. The two Ninja-specific additions are dotted aliases and resolvers for output, and ModelSchema, which derives fields from a Django model.

# catalog/schemas.py
from datetime import date
from decimal import Decimal

from ninja import Field, ModelSchema, Schema
from pydantic import field_validator

from .models import Book


class AuthorOut(Schema):
    id: int
    name: str


class BookOut(ModelSchema):
    author: AuthorOut

    class Meta:
        model = Book
        fields = ["id", "title", "isbn", "published", "price"]


class BookIn(Schema):
    title: str = Field(min_length=1, max_length=200)
    isbn: str = Field(pattern=r"^\d{13}$")
    published: date
    price: Decimal = Field(gt=0, max_digits=6, decimal_places=2)
    author_id: int

    @field_validator("title")
    @classmethod
    def strip_title(cls, value: str) -> str:
        return value.strip()


class BookPatch(ModelSchema):
    class Meta:
        model = Book
        fields = ["title", "price"]
        fields_optional = "__all__"


class Message(Schema):
    detail: str

What each piece does:

  • ModelSchema reads Meta.model and builds one Pydantic field per name in Meta.fields, mapping each Django field to a Python type (CharField to str, DateField to date, DecimalField to Decimal, a foreign key to the related primary key). Meta.exclude lists fields to leave out instead. fields = "all" exists, but the official docs warn against it because it silently exposes every new column (a password hash, an internal flag); list the fields.

  • A field declared on the class body (author: AuthorOut above) overrides or adds to the generated ones, which is how a foreign key becomes a nested object instead of a bare id.

  • Meta.fields_optional makes the listed fields (or "all") optional, which is what a PATCH body needs. Combine it with payload.model_dump(exclude_unset=True) so that fields the client did not send are not overwritten with None (the official examples still write payload.dict(…​), which Ninja’s Schema keeps as a Pydantic 1 compatibility alias for model_dump()).

  • Input and output schemas are separate on purpose: the client sends author_id, never id, and receives the nested author. One schema for both directions is the most common way to let clients write fields they should only read.

  • For output, Field(alias="author.name") reads a dotted path from the object, and a resolve_<field> static method (or a normal method, which can also see already-validated fields through self) computes a value.

Model-level validation is not run by Ninja: a Schema validates types and constraints, but Model.full_clean(), unique=True and database constraints are still enforced by Django or the database, which is why the create endpoint below catches IntegrityError for a duplicate ISBN.

Responses, status codes and errors

response= declares what the operation returns. A single schema means "200 with this body"; a dict maps status codes to schemas, and the function then picks the code by returning Status(code, body) (added in Django Ninja 1.6; the older return 201, body tuple still works but is deprecated). response={204: None} documents an empty response. Groups such as codes_4xx from ninja.responses map a whole range to one schema. Whatever the function returns is validated against the schema for that code, so a field missing from the object is a server error rather than a silently incomplete payload.

# catalog/api.py (continued)
@router.post(
    "/",
    response={201: BookOut, 409: Message},
    auth=[SignedToken(), django_auth],
)
def create_book(request, payload: BookIn):
    author = get_object_or_404(Author, pk=payload.author_id)
    try:
        book = Book.objects.create(author=author, **payload.model_dump(exclude={"author_id"}))
    except IntegrityError:
        return Status(409, {"detail": "A book with this ISBN already exists."})
    return Status(201, book)


@router.patch("/{book_id}", response=BookOut, auth=[SignedToken(), django_auth])
def update_book(request, book_id: int, payload: BookPatch):
    book = get_object_or_404(Book.objects.select_related("author"), pk=book_id)
    for field, value in payload.model_dump(exclude_unset=True).items():
        setattr(book, field, value)
    book.save()
    return book


@router.delete("/{book_id}", response={204: None}, auth=[SignedToken(), django_auth])
def delete_book(request, book_id: int):
    get_object_or_404(Book, pk=book_id).delete()
    return Status(204, None)

With a bearer token in $TOKEN (see Authentication: APIKey, HttpBearer and django_auth), creating the same book twice, sending an invalid body, patching the price and deleting the book look like this:

$ curl -s -X POST http://127.0.0.1:8000/api/v1/books/ \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"title": "  Parable of the Sower ", "isbn": "9781538732182", "published": "1993-10-01", "price": "14.99", "author_id": 2}'
$ # the same request again
$ curl -s -X POST http://127.0.0.1:8000/api/v1/books/ \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"title": "", "isbn": "978", "published": "1993-10-01", "price": "-1", "author_id": 2}'
$ curl -s -X PATCH http://127.0.0.1:8000/api/v1/books/5 \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"price": "13.99"}'
$ curl -s -i -X DELETE http://127.0.0.1:8000/api/v1/books/5 -H "Authorization: Bearer $TOKEN" | head -1
HTTP/1.1 204 No Content
{"author": {"id": 2, "name": "Octavia E. Butler"}, "id": 5, "title": "Parable of the Sower", "isbn": "9781538732182", "published": "1993-10-01", "price": "14.99"}

{"detail": "A book with this ISBN already exists."}

{"detail": [{"type": "string_too_short", "loc": ["body", "payload", "title"], "msg": "String should have at least 1 character", "ctx": {"min_length": 1}}, {"type": "string_pattern_mismatch", "loc": ["body", "payload", "isbn"], "msg": "String should match pattern '^\\d{13}$'", "ctx": {"pattern": "^\\d{13}$"}}, {"type": "greater_than", "loc": ["body", "payload", "price"], "msg": "Input should be greater than 0", "ctx": {"gt": "0"}}]}

{"author": {"id": 2, "name": "Octavia E. Butler"}, "id": 5, "title": "Parable of the Sower", "isbn": "9781538732182", "published": "1993-10-01", "price": "13.99"}

The first response shows the @field_validator at work (the surrounding spaces are gone) and the DecimalField serialised as a string, which keeps its precision. Three ways to end a request with an error:

  • raise HttpError(status, "message") from ninja.errors returns {"detail": "message"} with that status, as the token endpoint does for bad credentials.

  • get_object_or_404() raises Django’s Http404, which Ninja turns into a JSON 404 ({"detail": "Not Found: No Book matches the given query."}).

  • @api.exception_handler(SomeError) registers a function that turns your own exception into a response through api.create_response(request, data, status=…​); the same mechanism lets you replace the default 422 format by handling ninja.errors.ValidationError.

Authentication: APIKey, HttpBearer and django_auth

Authentication is declared, not coded into each function. auth= takes a callable (or a list of them) on an operation, on a Router, on add_router() or on the whole NinjaAPI; the most specific one wins and auth=None switches it off for one operation, as the token endpoint above does. Ninja calls each authenticator with the request; the first one that returns a truthy value wins, that value is stored in request.auth, and the function runs. If none does, the response is a 401 {"detail": "Unauthorized"}. Every authenticator also appears as a security scheme in the OpenAPI document, so the Swagger UI grows an Authorize button.

The built-in classes live in ninja.security:

Class Reads You implement

APIKeyHeader, APIKeyQuery, APIKeyCookie

A key from the header, query parameter or cookie named by param_name (default "key").

authenticate(self, request, key)

HttpBearer

The token from Authorization: Bearer <token>.

authenticate(self, request, token)

HttpBasicAuth

The user name and password from Authorization: Basic …​.

authenticate(self, request, username, password)

django_auth (an instance of SessionAuth), SessionAuthIsStaff, SessionAuthSuperUser, django_auth_superuser

Django’s session cookie: the logged-in request.user.

Nothing; request.auth is the user.

The Bookshelf API uses three of them. Partners send a shared key in a header, read from the environment and compared in constant time. Editors exchange their user name and password for a token signed with Django’s signing module (which uses SECRET_KEY, so rotating the key invalidates every token unless the old key stays in SECRET_KEY_FALLBACKS), or simply use their browser session.

# catalog/auth.py
import os
import secrets

from django.contrib.auth import get_user_model
from django.core import signing
from ninja.security import APIKeyHeader, HttpBearer


class PartnerKey(APIKeyHeader):
    param_name = "X-API-Key"

    def authenticate(self, request, key):
        expected = os.environ.get("BOOKSHELF_PARTNER_KEY", "")
        if key and expected and secrets.compare_digest(key, expected):
            return "partner"
        return None


class SignedToken(HttpBearer):
    max_age = 3600  # seconds

    def authenticate(self, request, token):
        try:
            user_id = signing.loads(token, salt="bookshelf-api", max_age=self.max_age)
        except signing.BadSignature:  # also covers SignatureExpired
            return None
        return get_user_model().objects.filter(pk=user_id, is_active=True).first()


def issue_token(user):
    return signing.dumps(user.pk, salt="bookshelf-api")
# catalog/api.py (end of the module)
partner_router = Router(tags=["partners"], auth=PartnerKey())


@partner_router.get("/stock", response=list[BookOut])
def stock(request):
    return Book.objects.select_related("author")[:50]

The key and test matters: when the header is missing, key is None, and secrets.compare_digest(None, …​) raises TypeError, which would turn a missing header into a 500 instead of a 401. The write endpoints use auth=[SignedToken(), django_auth], so either a token or a session is enough.

$ curl -s -X POST http://127.0.0.1:8000/api/v1/token \
    -H "Content-Type: application/json" -d '{"username": "editor", "password": "..."}'
$ curl -s -X POST http://127.0.0.1:8000/api/v1/token \
    -H "Content-Type: application/json" -d '{"username": "editor", "password": "wrong"}'
$ curl -s http://127.0.0.1:8000/api/v1/partners/stock
$ curl -s -X DELETE http://127.0.0.1:8000/api/v1/books/1 -H "Authorization: Bearer forged"
{"token": "<signed token>"}

{"detail": "Invalid credentials"}

{"detail": "Unauthorized"}

{"detail": "CSRF check Failed"}

The last answer is a 403, not a 401, and it is the most important behaviour on this page. Ninja leaves CSRF protection off for header-based schemes (a bearer token or API key is never attached to a request automatically by the browser, so a forged cross-site request cannot carry it), but switches it on for any cookie-based authenticator, and django_auth is one. When the forged token fails, Ninja falls back to django_auth, whose CSRF check rejects the request. A browser client that relies on the session must therefore send the CSRF token in the X-CSRFToken header, exactly as Django’s own AJAX instructions describe (a test with Client(enforce_csrf_checks=True) confirmed the 403 without the header and a 201 with it); a token-only endpoint (auth=SignedToken()) answers 401 instead.

Permissions are ordinary code after authentication: request.auth holds whatever the authenticator returned (a User here), so if not request.auth.has_perm("catalog.delete_book"): raise HttpError(403, …​) inside the function, or the same check in a small decorator placed below the route decorator, covers object and model permissions. Authenticators may also be async def callables for async endpoints.

Async endpoints

An operation becomes asynchronous by being declared with async def; sync and async operations can live side by side in the same router and Ninja calls each one correctly. Inside an async operation, use the async ORM (aget(), acreate(), async for, aget_object_or_404()), awaitable HTTP clients and other async libraries; a plain sync ORM call there raises SynchronousOnlyOperation, and anything without an async API goes through asgiref.sync.sync_to_async. The rules are Django’s, explained in Async support: the async ORM.

# catalog/api.py (continued)
@router.get("/{book_id}/async", response=BookOut)
async def get_book_async(request, book_id: int):
    return await aget_object_or_404(Book.objects.select_related("author"), pk=book_id)


@router.get("/by-author/{author_id}", response=list[BookOut])
@paginate(PageNumberPagination, page_size=2)
async def books_by_author(request, author_id: int):
    await aget_object_or_404(Author, pk=author_id)
    return Book.objects.select_related("author").filter(author_id=author_id)
$ uvicorn config.asgi:application --port 8000
$ curl -s http://127.0.0.1:8000/api/v1/books/3/async
$ curl -s http://127.0.0.1:8000/api/v1/books/by-author/9
{"author": {"id": 2, "name": "Octavia E. Butler"}, "id": 3, "title": "Kindred", "isbn": "9780807083697", "published": "1979-06-01", "price": "11.00"}

{"detail": "Not Found: No Author matches the given query."}

The second endpoint returns an unevaluated queryset from an async function. That is safe only because the built-in pagination classes support async operations and evaluate the page asynchronously; returning a bare queryset from an async operation without @paginate and with a list response is evaluated during serialisation and fails, so materialise it first ([book async for book in qs]). The concurrency benefit needs an ASGI server (Uvicorn, Daphne, Granian); under WSGI an async operation still works, but Django runs it in a one-off event loop per request. Async authenticators (async def callables passed to auth=) are supported; Django Ninja 1.5.0 fixed sync authenticators on async operations and 1.7.1 stopped async authenticators from being called twice.

Pagination

@paginate goes below the route decorator, the operation declares response=list[ItemSchema], and the function returns the whole queryset; the paginator adds its own query parameters, slices the queryset and wraps the page in an object (items plus count by default). Three classes ship in ninja.pagination:

Class Query parameters Notes

LimitOffsetPagination (the default)

limit, offset

limit defaults to NINJA_PAGINATION_PER_PAGE (100). It is unbounded unless you set NINJA_PAGINATION_MAX_LIMIT or pass max_limit= to @paginate(…​) (the decorator argument is new in 1.7.0).

PageNumberPagination

page (from 1), page_size

page_size= on the decorator sets the default; a client-supplied page_size is capped at max_page_size (NINJA_MAX_PER_PAGE_SIZE, default 100).

CursorPagination (new in 1.6.0)

cursor, page_size

Opaque base64 cursors over an ordering= tuple, returning next/previous links; stable when rows are inserted while a client pages through.

NINJA_PAGINATION_CLASS changes the default class project-wide. A custom paginator subclasses PaginationBase (or AsyncPaginationBase to support async operations), defines nested Input and Output schemas and implements paginate_queryset(). @paginate(pass_parameter="pagination_info") hands the parsed input to the function.

The listing endpoint shown earlier uses @paginate(PageNumberPagination, page_size=2):

$ curl -s "http://127.0.0.1:8000/api/v1/books/?page=2"
$ curl -s "http://127.0.0.1:8000/api/v1/books/?page=0"
{"items": [{"author": {"id": 1, "name": "Ursula K. Le Guin"}, "id": 2, "title": "The Dispossessed", "isbn": "9780061054884", "published": "1974-05-01", "price": "12.50"}], "count": 3}

{"detail": [{"type": "greater_than_equal", "loc": ["query", "page"], "msg": "Input should be greater than or equal to 1", "ctx": {"ge": 1}}]}

Because the paginator slices the queryset, the database only returns one page, and count is a separate COUNT(*) query. The model’s Meta.ordering (here by title) keeps pages deterministic; an unordered queryset can show the same row on two pages. For Django’s own Paginator, used by HTML views, see Pagination.

OpenAPI schema and interactive docs

Every NinjaAPI serves two extra URLs under its prefix: the OpenAPI 3.1 document at openapi.json and an interactive viewer at docs (Swagger UI by default). Both are generated from the same type hints that drive validation, so they cannot drift from the code: operation ids come from the module and function names, summaries from the function name (or summary=), descriptions from the docstring (or description=), groups from tags, and one security scheme per authenticator.

$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:8000/api/v1/docs
200 text/html; charset=utf-8
$ curl -s http://127.0.0.1:8000/api/v1/openapi.json | python -m json.tool | head -6
{
    "openapi": "3.1.0",
    "info": {
        "title": "Bookshelf API",
        "version": "1.0.0",
        "description": ""

The security schemes Ninja derived from the three authenticators, from the same document:

{
  "SignedToken": {"type": "http", "scheme": "bearer"},
  "SessionAuth": {"type": "apiKey", "in": "cookie", "name": "sessionid"},
  "PartnerKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}
}

The viewer and schema are configured on the NinjaAPI constructor:

from django.contrib.admin.views.decorators import staff_member_required
from ninja import NinjaAPI, Redoc, Swagger

# Redoc instead of Swagger UI
api = NinjaAPI(docs=Redoc())

# Swagger UI that remembers the token between page loads
api = NinjaAPI(docs=Swagger(settings={"persistAuthorization": True}))

# docs only for staff users
api = NinjaAPI(docs_decorator=staff_member_required)

# keep openapi.json for client generators, hide the viewer
api = NinjaAPI(docs_url=None)

# no schema and no viewer at all
api = NinjaAPI(openapi_url=None)

With ninja in INSTALLED_APPS, the schema can be written to a file in CI (to diff it in code review or feed a client generator) without starting a server:

$ python manage.py export_openapi_schema --api config.api.api --indent 2 --output schema.json
$ head -3 schema.json
{
  "openapi": "3.1.0",
  "info": {

Whether to expose the docs in production is a policy decision; the schema reveals every endpoint and parameter, so a public API usually keeps it and an internal one protects it with docs_decorator. The FastAPI section covers the same ideas for FastAPI’s generator in OpenAPI and Interactive Docs.

Testing with TestClient

ninja.testing.TestClient calls a router or an API object directly, without going through the URL resolver or the middleware stack, and returns a response with .status_code, .json() and .data. It runs inside an ordinary Django TestCase, so each test still gets a transaction that is rolled back and setUpTestData() still works. Request methods accept json= for the body, headers= and COOKIES=, user= to set request.user (which is what django_auth checks), and any other keyword becomes an attribute on the request. TestAsyncClient is the awaitable version for async operations. Django Ninja 1.7.0 reworked the client to build real HttpRequest objects and made override_settings work for the NINJA_* settings.

# catalog/tests.py
from datetime import date

from django.contrib.auth import get_user_model
from django.test import TestCase
from ninja.testing import TestAsyncClient, TestClient

from .api import router
from .auth import issue_token
from .models import Author, Book


class BookApiTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.author = Author.objects.create(name="Ursula K. Le Guin")
        cls.book = Book.objects.create(
            title="The Dispossessed", isbn="9780061054884",
            published=date(1974, 5, 1), price="12.50", author=cls.author,
        )
        cls.user = get_user_model().objects.create_user("editor")

    def setUp(self):
        self.client = TestClient(router)

    def test_list_is_paginated(self):
        response = self.client.get("/?page=1")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["count"], 1)
        self.assertEqual(response.json()["items"][0]["author"]["name"], "Ursula K. Le Guin")

    def test_invalid_path_parameter_is_422(self):
        response = self.client.get("/not-a-number")
        self.assertEqual(response.status_code, 422)
        self.assertEqual(response.json()["detail"][0]["loc"], ["path", "book_id"])

    def test_create_requires_auth(self):
        payload = {"title": "Lavinia", "isbn": "9780151014248",
                   "published": "2008-04-21", "price": "15.00", "author_id": self.author.pk}
        self.assertEqual(self.client.post("/", json=payload).status_code, 401)
        token = issue_token(self.user)
        response = self.client.post("/", json=payload, headers={"Authorization": f"Bearer {token}"})
        self.assertEqual(response.status_code, 201)
        self.assertTrue(Book.objects.filter(isbn="9780151014248").exists())

    def test_session_user(self):
        # user= sets request.user, so django_auth accepts the call.
        response = self.client.patch(f"/{self.book.pk}", json={"price": "9.99"}, user=self.user)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["price"], "9.99")


class AsyncBookApiTests(TestCase):
    async def test_async_detail(self):
        author = await Author.objects.acreate(name="Octavia E. Butler")
        book = await Book.objects.acreate(
            title="Kindred", isbn="9780807083697",
            published=date(1979, 6, 1), price="11.00", author=author,
        )
        client = TestAsyncClient(router)
        response = await client.get(f"/{book.pk}/async")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["title"], "Kindred")
$ python -W error manage.py test catalog -v 2
...
test_async_detail (catalog.tests.AsyncBookApiTests.test_async_detail) ... ok
test_create_requires_auth (catalog.tests.BookApiTests.test_create_requires_auth) ... ok
test_invalid_path_parameter_is_422 (catalog.tests.BookApiTests.test_invalid_path_parameter_is_422) ... ok
test_list_is_paginated (catalog.tests.BookApiTests.test_list_is_paginated) ... ok
test_session_user (catalog.tests.BookApiTests.test_session_user) ... ok
...
Ran 5 tests in 0.005s

OK

Because TestClient skips middleware, it does not exercise CSRF, sessions or anything a middleware adds. Keep one or two end-to-end tests with Django’s own test client against the real URL: Client(enforce_csrf_checks=True) plus force_login() reproduces the 403 for a cookie-authenticated POST without an X-CSRFToken header, and the 201 once the header carries a valid token. Django’s async testing tools are covered in Async support: testing, and the FastAPI section’s Testing page shows pytest patterns that carry over.

Choosing Ninja or Django REST framework

Both build JSON APIs on top of the same Django project, and both can live in one project (different URL prefixes) while an API migrates from one to the other. The difference is the programming model. Django REST framework (3.18.1 at the time of writing, covered in Django REST framework) is class-based: serializers, generic views and viewsets, routers that derive URLs from viewsets, and pluggable classes for authentication, permissions, throttling, filtering and pagination, plus a browsable HTML API. Django Ninja is function-based and type-driven: the signature is the contract, Pydantic does the validation, and OpenAPI comes out of the box.

Concern Django Ninja 1.7 Django REST framework 3.18

Declaring inputs and outputs

Type hints, Schema / ModelSchema (Pydantic 2)

Serializer / ModelSerializer classes

Views and URLs

Decorated functions, Router, explicit paths

APIView, generic views, viewsets with routers that generate URLs

Validation errors

422 with Pydantic’s error list

400 with a field-to-messages mapping

OpenAPI

Built in: openapi.json and Swagger UI or Redoc

Usually the third-party drf-spectacular

Async

async def operations, async authenticators and pagination

Not described in DRF’s documentation; views are written synchronously

Authentication, permissions

auth= callables; permissions are your own code or decorators

Authentication and permission classes, including object-level permissions

Throttling, versioning, filtering

AnonRateThrottle/AuthRateThrottle (modelled on DRF’s), several NinjaAPI instances, FilterSchema

Throttle classes, versioning schemes, django-filter integration

Ecosystem and examples

Smaller, younger (first released 2020)

Large: many third-party packages, tutorials and answered questions

Choose Ninja when the team already knows FastAPI or Pydantic (the concepts in the FastAPI section transfer almost one to one), when the API is mostly explicit endpoints rather than uniform CRUD over many models, when async I/O matters, or when an accurate OpenAPI document for client generation is a hard requirement. Choose DRF when viewsets and routers would generate most of the API, when you need its mature permission and filtering classes or the browsable API, or when the project, its third-party packages or the team are already built around it. The Ninja documentation’s own motivation for existing is that FastAPI’s dependency injection and ORM-agnostic stance fit Django poorly, so Ninja keeps FastAPI’s type-hint style while using Django’s request object, ORM and authentication directly.

Pitfalls

  • fields = "all" on a ModelSchema exposes every column, including ones added later. List the fields, and keep input and output schemas separate.

  • Unbounded limit: LimitOffsetPagination accepts any limit unless NINJA_PAGINATION_MAX_LIMIT or max_limit= caps it, so one request can ask for the whole table.

  • Decorator order: @paginate (and any other operation decorator) goes below @router.get(…​); above it, it never wraps the function Ninja registered.

  • Route order: /{book_id} declared before /isbn/{isbn} or /by-author/…​ swallows those paths and answers 422.

  • Cookie authentication means CSRF: as soon as django_auth (or any APIKeyCookie) is in the auth list, unsafe methods need the CSRF token; with a header-based scheme they do not. A failed bearer token followed by django_auth yields a 403, not a 401.

  • A missing credential is None: an APIKeyHeader.authenticate() that compares the key without checking for None first turns a missing header into a 500.

  • N+1 queries: the response schema walks relations on every object, so a nested author needs select_related() (or prefetch_related() for many-valued relations) in the queryset; see Query optimisation and fetch modes.

  • Sync ORM in async def: raises SynchronousOnlyOperation; returning an unevaluated queryset from an async operation without @paginate fails during serialisation.

  • Response validation is strict: an object missing a field declared in the response schema produces a 500, which is the right outcome but surprises people who expect missing fields to be dropped.

  • TestClient bypasses middleware: CSRF, sessions and custom middleware need an end-to-end test with Django’s client.

Where the books are outdated

None of the four books this section was cross-checked against mentions Django Ninja; their API chapters use Django REST framework only, so there is nothing to correct here. The DRF errors they contain are corrected on the Django REST framework page. Older Ninja tutorials found online are a different matter: they often use Pydantic 1 idioms (class Config, orm_mode), tuple returns such as return 201, obj, and the csrf=True argument to NinjaAPI, all of which are deprecated or removed in the current release (see below).

What changed recently

Table 1. Django Ninja changes by version
Version Released Change

1.0

2023-11-16

Pydantic 2 required; Annotated[…​] parameter markers; CSRF enabled automatically for cookie-based authentication; async authentication; ModelSchema configured with class Meta (the class Config form deprecated); pluggable docs viewers (Swagger, Redoc).

1.4.0

2025-03-26

page_size query parameter for PageNumberPagination, pattern= on parameter markers, customisable validation errors, a staff-or-superuser session check, router auth always overriding API auth.

1.5.0

2025-11-13

Global and router-level decorators (add_decorator), annotation-based FilterSchema, Pydantic 2.12 support, sync authenticators fixed on async operations; the deprecated Config class support and the csrf argument were removed.

1.6.0

2026-03-12

Status(code, body) return (tuple returns deprecated), CursorPagination, the same router mountable on several APIs, streaming responses (JSON Lines and server-sent events), Python 3.14 annotation fixes.

1.7.0

2026-08-30

Django 6.1 confirmed and the Django upper version bound dropped, max_limit= for LimitOffsetPagination in the decorator, TestClient building real HttpRequest objects, override_settings honoured for NINJA_* settings, Retry-After header on 429 throttled responses.

1.7.1

2026-09-19

HTTP Basic credentials keep percent sequences intact; async authenticators no longer called twice; new "Authorization and Permissions" documentation.

The rows come from the GitHub release notes of each version. Django Ninja does not follow Django’s release cadence; the version to pin is whichever release lists your Django version among its classifiers (1.7.1 lists 5.2, 6.0 and 6.1).