What's New
Webhooks
You can now document the webhooks your API sends. Decorate the payload Schema with @api.webhook or @router.webhook and it appears in the OpenAPI 3.1 webhooks section, so Swagger and Redoc show it next to your operations. This is documentation only: Ninja doesn't send anything. The guide shows one way to deliver them with Django tasks.
from ninja import NinjaAPI, Router, Schema
api = NinjaAPI()
@api.webhook("order.paid")
class OrderPaid(Schema):
id: int
total: Decimal
router = Router(tags=["orders"])
@router.webhook # name defaults to the class name: "OrderShipped"
class OrderShipped(Schema):
id: intNew documentation (and docs for AI assistants)
The docs at django-ninja.dev are reorganized into four sections:
- Quick Start: build an events API in three pages
- Tutorial: build TaskFlow, a team task tracker, in eight parts. It covers relations, validation, JWT auth and permissions, filtering, uploads, testing, OpenAPI polish and versioning
- Guide: one short page per topic, and every example was run against the current code
- About
The docs now include files for LLMs. Point your coding assistant at /llms.txt, which has usage notes and a link to every page, or at /llms-full.txt, which is the whole Guide in one file. Every page is also served as raw Markdown, e.g. /guide/routers.md.
register_field() accepts field classes
A custom Django field that subclasses a built-in one inherits its internal type, so registering it by name ("UUIDField") would change every UUIDField. You can now register the field class itself:
from typing import Annotated
from pydantic import BeforeValidator
from ninja.orm import register_field
class PublicIDField(models.UUIDField):
def from_db_value(self, value, expression, connection):
return PublicID(value)
register_field(PublicIDField, Annotated[str, BeforeValidator(str)])The registration also applies to subclasses, and the most specific class wins. It also covers ForeignKey and OneToOne fields that point at that field. Forward FK and OneToOne fields now take their type from the to_field target instead of always using the related model's pk.
Optional schema parameters
An Optional schema used as a Query, Header or Form param now falls back to its default when none of its fields are sent:
class Filters(Schema):
city: str
year: int = 2026
@api.get("/events")
def events(request, filters: Filters | None = Query(None)):
...
# GET /events -> filters is None (was 422)
# GET /events?city=Kyiv -> Filters(city="Kyiv", year=2026)Body params next to files
When you mix Body params with File or Form, each multipart part is now read the way Swagger and generated clients send it. Primitive types (str, int, date, Enum, EmailStr, ...) are read as plain text, and objects and arrays are read as JSON:
@api.post("/upload")
def upload(
request,
file: File[UploadedFile],
note: str = Body(...),
day: date = Body(...),
meta: dict[str, int] | None = Body(None),
):
...
# note='say "hi"' day=2024-01-02 meta='{"a": 1}' -> 200 (quoted strings and dates used to give 400)ModelSchema improvements
Model fields now always come first, in model order, followed by your custom fields. A field you declare on the schema is used as-is, so a third-party model field that Ninja can't convert no longer breaks fields = "__all__":
class ProductOut(ModelSchema):
price: str # MoneyField: no register_field() needed when declared
tags: list[str] = []
class Meta:
model = Product
fields = "__all__"
list(ProductOut.model_fields) # ['id', 'name', 'price', 'tags']Listing a FK by its attname (user_id) in Meta.fields now gives an error that suggests the fix: use user, or declare user_id: int.
Python 3.15
Django Ninja is now tested on Python 3.15 with Django 5.2, 6.0 and 6.1.
⚠️ Behavior changes
- The
NINJA_PAGINATION_MAX_OFFSETsetting is removed, and a value left in settings is ignored (#1818) ForeignKey(..., to_field="slug")is now typed from the target field (string) instead of the related pk (integer). If a stringto_fieldholds numeric values, responses now return"123"instead of123(#1842)- ModelSchema puts model fields (in
Meta.fieldsorder) before custom fields. This changes the key order in responses and in OpenAPI (#1829) - Declaring a field on a ModelSchema and also listing it in
Meta.fieldsnow emits aDeprecationWarning. It will become aConfigErrorin a later release (#1815) - Nullable FK and OneToOne fields with
depth > 0are nowOptionalin the schema (anyOf: [..., null]) (#1803) - Paginated responses of
Union/Annotateditems get their own schema names (PagedFooOrBarinstead of a sharedPagedUnionorPagedAnnotated). Models in generated clients may be renamed (#1800) - In multipart requests, a
strBodypart sent with literal surrounding quotes now keeps them (#1825)
Details
Features
- Webhooks:
@api.webhook/@router.webhookfor OpenAPI 3.1 webhooks by @vitalik in #1783 - ModelSchema:
register_field()accepts field classes; FK/O2O typed fromto_fieldtarget by @vitalik in #1842 - Add Python 3.15 to CI matrix and classifiers by @vitalik in #1837
ModelSchema & ORM
- ModelSchema: don't convert model fields that are declared on the schema by @vitalik in #1843
- Fix ModelSchema field order: model fields before custom fields by @vitalik in #1829
- Warn when a ModelSchema field is declared as a class attribute and also listed in
Meta.fieldsby @smoltafet in #1815 - ModelSchema: hint about ForeignKey attnames (
user_id) inMeta.fieldsby @vitalik in #1832 - Fix nullable relations not Optional in schemas with
depth > 0by @moneebullah25 in #1803 - Include
base_classin the ModelSchema factory cache key by @moneebullah25 in #1804 - Fix
create_schemacrash on models with aForeignObjectreverse relation by @shivanij1203 in #1746
Request parsing & params
- Read multipart
Bodyparts by their schema: primitives as plain text, objects and arrays as JSON, by @vitalik in #1825 and #1850 - Fix Optional schema params returning 422 when no fields are sent by @vitalik in #1826
- Fix numeric aliases (e.g.
alias="0") nulling out fields on object input by @smoltafet in #1817
Responses & schemas
- Filter the response by the declared schema when a subclass instance is returned (also in 1.7.3) by @vitalik in #1824
- Fix
exclude_unsetfor nested Schema instances, while nested instances are still re-validated by @vitalik in #1830 and #1846
Pagination
- Fix
CursorPaginationreturning 500 for malformed cursors by @moneebullah25 in #1794 - Fix
CursorPagination500 on tie runs of 100+ rows by @vitalik in #1818 - Name paginated union schemas after their members by @ybai08 in #1800
OpenAPI
- Fix OpenAPI parameter examples for list and dict forms (fixes the Swagger UI
i.get is not a functioncrash) by @vitalik in #1844
Routers & auth
Typing
- Parameterize bare
CallableandDictannotations on operations and the test client by @NorthIsUp in #1789
Documentation
- Restructure into Quick Start / Tutorial / Guide / About by @vitalik in #1786
- Build single-file Markdown docs for LLMs (
llms.txt,llms-full.txt) by @Nuung in #1799 - Drop
typing.List/Dict/Optionalin favor of builtin generics by @vitalik in #1782 - Drop
Statusfrom single-response Quick Start examples by @vitalik in #1790 - Use
constant_time_comparein auth examples by @Highdrien in #1781 - Using
request.authwith VIEW-mode decorators by @vitalik in #1831 - Clarify 404 handling for undefined URLs by @vitalik in #1839
- Fix typos in docs, a comment and a test docstring by @selmanfarukyilmaz in #1796
Maintenance
- Upgrade Ruff to 0.17.3 by @vitalik in #1836
- Fix
test_all_fieldson pydantic 2.14 by @vitalik in #1797 - Fix test model name collision breaking CI on master by @vitalik in #1828
- Add
SECURITY.mdwith private vulnerability reporting instructions by @vitalik in 3506fd4
New Contributors
- @Highdrien made their first contribution in #1781
- @selmanfarukyilmaz made their first contribution in #1796
- @NorthIsUp made their first contribution in #1789
- @moneebullah25 made their first contribution in #1794
- @ybai08 made their first contribution in #1800
- @smoltafet made their first contribution in #1817
- @shivanij1203 made their first contribution in #1746
- @Nuung made their first contribution in #1799
Full Changelog: v1.7.1...v1.8.0b1