Django Ninja API with Schema models and pagination on sqlite: working versions (tested 2026-10-15)
A typed Django Ninja CRUD API with Schema input and output models, page-number pagination and sqlite, tested offline with the ninja TestClient and pytest-django.
passed5 passed · 0 failed2026-10-15 13:20 UTC- Status
- passed
- Last passed
- 2026-10-15 13:20 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("django-ninja-schema-crud") (install), or see them on GitHub.
django==6.1.2 django-ninja==1.7.1 pytest-django==4.14.0 pytest==9.1.1
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
SECRET_KEY = "test-only-not-a-secret"
DEBUG = False
ALLOWED_HOSTS = ["testserver"]
INSTALLED_APPS = ["catalog"]
ROOT_URLCONF = "catalog.urls"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
USE_TZ = True
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=200)
year = models.PositiveIntegerField(null=True, blank=True)
class Meta:
# Pagination needs a stable order, otherwise pages can overlap or skip rows.
ordering = ["id"]
from ninja import NinjaAPI, Schema
from ninja.errors import HttpError
from ninja.pagination import PageNumberPagination, paginate
from .models import Book
api = NinjaAPI()
class BookIn(Schema):
title: str
author: str
year: int | None = None
class BookPatch(Schema):
title: str | None = None
author: str | None = None
year: int | None = None
class BookOut(Schema):
id: int
title: str
author: str
year: int | None
def _get_book(book_id: int) -> Book:
try:
return Book.objects.get(pk=book_id)
except Book.DoesNotExist:
raise HttpError(404, "Book not found")
@api.post("/books", response={201: BookOut})
def create_book(request, payload: BookIn):
return 201, Book.objects.create(**payload.model_dump())
@api.get("/books", response=list[BookOut])
@paginate(PageNumberPagination, page_size=2)
def list_books(request):
return Book.objects.all()
@api.get("/books/{book_id}", response=BookOut)
def get_book(request, book_id: int):
return _get_book(book_id)
@api.patch("/books/{book_id}", response=BookOut)
def update_book(request, book_id: int, payload: BookPatch):
book = _get_book(book_id)
# exclude_unset keeps fields the client did not send, instead of overwriting them with None.
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(book, field, value)
book.save()
return book
from django.urls import path
from .api import api
urlpatterns = [
path("api/", api.urls),
]
import pytest
from ninja.testing import TestClient
from catalog.api import api
from catalog.models import Book
client = TestClient(api)
@pytest.mark.django_db
def test_create_returns_201_and_persists():
r = client.post("/books", json={"title": "Dune", "author": "Herbert", "year": 1965})
assert r.status_code == 201
assert r.json()["title"] == "Dune"
assert Book.objects.filter(pk=r.json()["id"], year=1965).exists()
@pytest.mark.django_db
def test_pagination_returns_second_page_in_stable_order():
for i in range(3):
Book.objects.create(title=f"T{i}", author="A")
r = client.get("/books?page=2")
body = r.json()
assert body["count"] == 3
# page_size is 2, so page 2 holds only the third book; ordering by id makes this deterministic.
assert [b["title"] for b in body["items"]] == ["T2"]
@pytest.mark.django_db
def test_missing_book_returns_404_with_detail():
r = client.get("/books/999")
assert r.status_code == 404
assert r.json() == {"detail": "Book not found"}
@pytest.mark.django_db
def test_invalid_payload_returns_422():
r = client.post("/books", json={"author": "no title"})
assert r.status_code == 422
@pytest.mark.django_db
def test_partial_patch_keeps_unsent_fields():
b = Book.objects.create(title="Keep", author="A", year=1990)
r = client.patch(f"/books/{b.id}", json={"year": 2001})
assert r.status_code == 200
assert r.json()["title"] == "Keep"
assert r.json()["year"] == 2001
[pytest] pythonpath = . DJANGO_SETTINGS_MODULE = settings
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Django 6 + Django REST Framework model API with pytest-django
A DRF ModelViewSet with a serializer, router and token-free permissions, tested with pytest-django and APIClient on in-memory SQLite.
Django models with factory_boy and pytest-django db fixtures
Django models built with factory_boy DjangoModelFactory (SubFactory, get_or_create, traits) and tested offline on in-memory SQLite with pytest-django database access rules and on_commit capture.
Django post_save signal enqueuing a Celery task, tested with eager mode and on_commit
A post_save signal queues a Celery task only after the row commits via transaction.on_commit, then tests run offline on in-memory SQLite with Celery eager mode and pytest-django's on_commit capture.
Django REST Framework JWT auth with simplejwt, tested offline
Obtain and refresh JWT pairs with simplejwt, add custom claims, protect a DRF view, and test expired-token rejection on sqlite in-memory with pytest-django.
All Django recipesAll django-ninja recipesAll pytest-django recipes