Migrating from Django REST framework¶
Treat the migration as an HTTP contract change, even when both implementations use the same Django model. Serializers, authentication, pagination and exception handlers can change what clients observe. Port one resource behind a separate URL prefix, compare its behavior, and switch traffic only after the differences are intentional.
DRF's ViewSets group resource actions; its serializers combine representation, validation and persistence hooks. ninja-devx uses separate Ninja schemas and model/service hooks. Existing Django models and database migrations can remain.
1. Record the existing contract¶
Capture examples for list/detail/create/PUT/PATCH/delete and custom actions. Include:
- Exact URL, trailing slash, route name and HTTP method.
- Anonymous, authenticated, wrong-owner, wrong-tenant and staff requests.
- Empty and malformed bodies, unknown fields, omitted fields, explicit null and duplicates.
- Pagination envelope, default ordering, maximum page size and filter syntax.
- Decimal/date/time/UUID representation, related IDs and nested objects.
- Status, error body, relevant headers, cookies and content type.
- Side effects: signals, audit records, task dispatch, remote calls and transactions.
Keep these as application tests or fixtures. An OpenAPI diff is useful but does not capture all authorization or side-effect behavior. Never use a mutating production request as a shadow request against both implementations unless side effects are isolated.
2. Separate input and output¶
For a model with owner, title, body and done, an owner-scoped DRF view often looks like:
from rest_framework import permissions, serializers, viewsets
from notes.models import Note
class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = ("id", "title", "body", "done")
read_only_fields = ("id", "done")
class NoteViewSet(viewsets.ModelViewSet):
serializer_class = NoteSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Note.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
The equivalent resource configuration in ninja-devx is explicit about schemas:
from typing import Annotated
from ninja import Schema
from ninja_devx.crud import CRUDController
from pydantic import Field
from notes.models import Note
class NoteIn(Schema):
title: Annotated[str, Field(min_length=1, max_length=200)]
body: str = ""
class NoteOut(Schema):
id: int
title: str
body: str
done: bool
class NoteController(CRUDController[Note, NoteOut, NoteIn]):
owner_field = "owner"
scope_queryset_to_owner = True
owner is not an input field. The controller sets it from the authenticated request.
The Quickstart is a complete tested project with this
pattern, a custom action and pagination. Do not expose all model fields merely to make a
serializer conversion shorter.
Translate validate_<field> into a Pydantic field validator and cross-field input rules
into a model validator. Rules that require the current row or transaction belong in a
service or perform_* hook. Default model persistence calls full_clean; this may reject
values the old endpoint accepted. Database uniqueness remains a constraint even when a
pre-save validator passes.
PATCH uses only submitted fields. Explicit null is still a submitted value and is not
the same as omission. PUT applies the input schema, including defaults. Test nested write
semantics separately; a nested output schema does not implement nested object creation.
3. Mount alongside the old router¶
from django.urls import include, path
from ninja import NinjaAPI
from ninja.security import django_auth
from notes.api import NoteController
from notes.legacy_urls import router as legacy_router
api = NinjaAPI(title="Notes migration", urls_namespace="notes-v2", auth=django_auth)
api.add_router("/notes", NoteController.as_router())
urlpatterns = [
path("api/v1/", include(legacy_router.urls)),
path("api/v2/", api.urls),
]
django_auth is an example for a session-based application. It is not an adapter for an
existing DRF token/JWT class. Implement a Ninja auth callable for that credential format,
or use a maintained integration, and verify CSRF, cookie and invalid-token behavior.
DRF authentication classes may return a (user, auth) pair; do not paste that method into
a Ninja auth class and assume the request user will be set correctly. Consult the
DRF authentication contract
and Ninja authentication.
4. Move hooks by responsibility¶
| DRF surface | Destination | Migration check |
|---|---|---|
get_queryset() |
get_queryset(request) |
Preserve tenant/owner filters, annotations and relation loading |
serializer.save(owner=...) |
owner_field or context_data(request) |
Context-controlled fields stay out of the input schema |
perform_create(serializer) |
perform_create(request, payload) |
Return a saved model; preserve selected DB and transaction |
perform_update(serializer) |
perform_update(request, instance, data) |
data contains intended changes; preserve partial-update semantics |
perform_destroy(instance) |
perform_destroy(request, instance) |
Decide hard vs soft deletion and external cleanup timing |
@action(detail=True) |
Explicit route plus Instance/Locked binding | Preserve method/path/permissions; call policy checks in custom lookups |
self.request / self.action |
Request argument / get_operation(request) |
Avoid storing request state on singleton controllers |
| Exception handler | ErrorMap or explicit Ninja handler | Match status/body if clients rely on old DRF errors |
| Serializer method field | Ninja resolver | Provide queryset hints for relations accessed by Python code |
| Filter backends | FilterSchema or controller filter/search/order settings | Parameter names and invalid-order behavior can differ |
| Authentication/permission classes | Ninja auth and new permission objects | They are separate interfaces, not drop-in imports |
For a custom completion action:
from django.http import HttpRequest
from ninja_devx import post
from ninja_devx.crud import Instance
# Add to NoteController. Note and NoteOut are the model/schema above.
@post("/{pk}/complete", response=NoteOut)
def complete(self, request: HttpRequest, note: Instance[Note]) -> Note:
note.done = True
note.save(update_fields=["done"])
return note
This custom method does not automatically acquire the transaction/ETag policy of a built-in
update. Use a transactional service or the documented Locked/atomic patterns when that
operation needs concurrency protection.
5. Rebuild authorization deliberately¶
DRF's object-permission guidance
distinguishes detail checks from list filtering. Keep that distinction after migration.
IsOwner checks an object; scope_queryset_to_owner=True restricts query results. Set a
tenant field only with a resolver that verifies tenant membership. Use Parent for nested
resources rather than trusting a parent ID from the URL.
Built-in writes verify the persisted result against scope and object permissions before commit. A custom hook that returns an out-of-scope row raises 404 and rolls back on the selected alias. Applications using object grants must establish required permissions for the newly created object inside that transaction. See persistence boundaries.
Permission lists on a route replace inherited configuration where documented; use
Also(...) when extending it. Test negation/composition with both request and object
checks. Do not translate permission class names mechanically.
6. Preserve or version transport differences¶
| Existing client assumption | Default/new behavior to inspect |
|---|---|
| Invalid input is 400 | Ninja schema/model validation commonly returns 422 |
Page response has results/next/previous/count |
Ninja PageNumber responses use items/count; choose/adapt a paginator |
| Unknown ordering is ignored | Generated ordering fields reject unsupported values |
| Omitted and null fields are interchangeable | PatchData distinguishes submission from omission |
| Session auth always returns the same 401/403 response | Chosen auth and CSRF flow determine rejection behavior |
| HTML browsable API or alternate renderer is available | Ninja docs UI and configured renderer are different interfaces |
| Serializer output implicitly loads relations | Queryset loading must satisfy schemas and arbitrary resolvers |
| API key can create another credential | Built-in key management rejects API-key-authenticated requests |
For pagination customization consult DRF pagination and the CRUD pagination guide. If existing clients cannot change, implement and test a compatibility envelope or retain the old endpoint through a deprecation window. Do not silently relabel a 422 body as a DRF serializer error.
7. Cut over with a rollback path¶
Run the same accepted-request fixtures against both prefixes and document intentional response differences. Add cross-tenant and wrong-owner tests; compare database state after failed writes and confirm tasks publish only after commit. Measure query count for nested lists and exports. Test on the production database engine, not only SQLite.
Switch one consumer or resource at a time. Keep database changes compatible with both implementations until rollback is no longer needed. Regenerate clients only after operation IDs and response schemas stabilize. Remove the old route and dependencies after its callers have migrated; retaining an obsolete permission class as an unused import is not a completed migration.