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 |
|
Group endpoints |
A |
|
Read inputs |
|
|
Shape input and output |
|
|
Choose the status code |
|
|
Protect an endpoint |
|
|
Avoid blocking |
|
|
Page through lists |
|
|
Publish docs |
Swagger UI at |
|
Test it |
|
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.
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.
Source: Motivation and Tutorial - First Steps
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
Schemasubclass is the JSON body; -
the explicit markers
Path,Query,Body,Header,Cookie,FormandFile(all importable fromninja) override that guess and carry validation constraints, written either asQuery[FilterSchema]or asAnnotated[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.
Source: Path parameters, Query parameters and Request Body
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:
-
ModelSchemareadsMeta.modeland builds one Pydantic field per name inMeta.fields, mapping each Django field to a Python type (CharFieldtostr,DateFieldtodate,DecimalFieldtoDecimal, a foreign key to the related primary key).Meta.excludelists 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: AuthorOutabove) overrides or adds to the generated ones, which is how a foreign key becomes a nested object instead of a bare id. -
Meta.fields_optionalmakes the listed fields (or"all") optional, which is what a PATCH body needs. Combine it withpayload.model_dump(exclude_unset=True)so that fields the client did not send are not overwritten withNone(the official examples still writepayload.dict(…), which Ninja’sSchemakeeps as a Pydantic 1 compatibility alias formodel_dump()). -
Input and output schemas are separate on purpose: the client sends
author_id, neverid, 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 aresolve_<field>static method (or a normal method, which can also see already-validated fields throughself) 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")fromninja.errorsreturns{"detail": "message"}with that status, as the token endpoint does for bad credentials. -
get_object_or_404()raises Django’sHttp404, 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 throughapi.create_response(request, data, status=…); the same mechanism lets you replace the default 422 format by handlingninja.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 |
|---|---|---|
|
A key from the header, query parameter or cookie named by |
|
|
The token from |
|
|
The user name and password from |
|
|
Django’s session cookie: the logged-in |
Nothing; |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
Opaque base64 cursors over an |
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.
Source: Pagination, Settings and Django Ninja 1.7.0 release notes
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.
Source: API Docs, Operations parameters and Management Commands
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, |
|
Views and URLs |
Decorated functions, |
|
Validation errors |
422 with Pydantic’s error list |
400 with a field-to-messages mapping |
OpenAPI |
Built in: |
Usually the third-party drf-spectacular |
Async |
|
Not described in DRF’s documentation; views are written synchronously |
Authentication, permissions |
|
Authentication and permission classes, including object-level permissions |
Throttling, versioning, filtering |
|
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.
Source: Motivation, Throttling, Filtering and Django REST framework
Pitfalls
-
fields = "all"on aModelSchemaexposes every column, including ones added later. List the fields, and keep input and output schemas separate. -
Unbounded
limit:LimitOffsetPaginationaccepts anylimitunlessNINJA_PAGINATION_MAX_LIMITormax_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 anyAPIKeyCookie) is in theauthlist, unsafe methods need the CSRF token; with a header-based scheme they do not. A failed bearer token followed bydjango_authyields a 403, not a 401. -
A missing credential is
None: anAPIKeyHeader.authenticate()that compares the key without checking forNonefirst turns a missing header into a 500. -
N+1 queries: the response schema walks relations on every object, so a nested
authorneedsselect_related()(orprefetch_related()for many-valued relations) in the queryset; see Query optimisation and fetch modes. -
Sync ORM in
async def: raisesSynchronousOnlyOperation; returning an unevaluated queryset from an async operation without@paginatefails 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.
-
TestClientbypasses 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
| Version | Released | Change |
|---|---|---|
1.0 |
2023-11-16 |
Pydantic 2 required; |
1.4.0 |
2025-03-26 |
|
1.5.0 |
2025-11-13 |
Global and router-level decorators ( |
1.6.0 |
2026-03-12 |
|
1.7.0 |
2026-08-30 |
Django 6.1 confirmed and the Django upper version bound dropped, |
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).