Moving from Django DRF to Ninja API / Pydantic

As our project grows, we're always looking for ways to streamline development, improve performance, and enhance the developer experience. Recently, we've been exploring a shift from our traditional Django REST Framework (DRF) API patterns to a combination of Django Ninja API and Pydantic. This blog post will delve into our motivations for this change, the benefits we've observed, and some considerations for others contemplating a similar transition.

Why Consider a Change from Django DRF?

Django REST Framework has been a robust and widely adopted solution for building APIs with Django. It provides a comprehensive set of tools, including serializers, viewsets, and excellent browser-based API interfaces. However, as our needs evolved, we identified areas where a different approach could offer advantages:

  • Boilerplate Code: While DRF offers powerful abstractions, creating serializers, views, and viewsets can sometimes lead to a significant amount of boilerplate code, especially for simpler APIs.

  • Performance: For certain use cases, the overhead of DRF's serializer validation and rendering can impact performance, particularly in high-throughput scenarios.

  • Modern Python Features: We were keen to leverage modern Python features like type hints and data validation more extensively, which are core to Pydantic.

  • Developer Experience: A more concise and explicit way to define API endpoints and data structures could improve developer productivity and reduce potential errors.

Introducing Django Ninja API and Pydantic

Django Ninja API

Django Ninja is a web framework for building APIs with Django and Python 3.6+ type hints. It's heavily inspired by FastAPI and offers a number of compelling features:

  • Type Hinting for API Endpoints: You define your request and response models using Pydantic, and Ninja automatically validates and serializes the data based on these type hints.

  • Automatic OpenAPI (Swagger) Documentation: Just like FastAPI, Ninja generates interactive API documentation out of the box, making it easy to explore and test your API.

  • Fast Performance: Ninja is designed for speed, with minimal overhead and efficient request/response handling.

  • Simplified View Definitions: API endpoints are defined as simple Python functions, reducing the complexity often associated with DRF viewsets.

Pydantic

Pydantic is a data validation and settings management library using Python type hints. It's incredibly powerful for:

  • Data Validation: Automatically validates data against defined schemas, ensuring data integrity and catching errors early.

  • Serialization/Deserialization: Easily converts Python objects to and from JSON (or other formats) based on the defined models.

  • Runtime Type Checking: While Python's type hints are typically for static analysis, Pydantic brings runtime type checking to your data.


The Combo: Ninja API and Pydantic in Action

The synergy between Django Ninja API and Pydantic is where the real magic happens. Pydantic models define the structure and validation rules for both incoming request data and outgoing response data. Django Ninja then uses these Pydantic models to automatically:

  1. Validate Incoming Request Body: Any data sent to your API endpoint is automatically validated against the specified Pydantic model. If the data doesn't conform, a clear validation error is returned.

  2. Serialize Outgoing Response Data: When you return data from your API endpoint, Ninja uses the response Pydantic model to serialize it into the appropriate format (e.g., JSON).

  3. Generate OpenAPI Documentation: The Pydantic models directly contribute to the rich and accurate OpenAPI documentation, describing the expected request body and the structure of the responses.

The Core Shift: From Serializers to Schemas

This is the most significant change when moving from DRF to Ninja.

In DRF, a Serializer handles both input validation and output serialization.

# DRF Serializer
from rest_framework import serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'price']

# DRF ViewSet
from rest_framework import viewsets

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

In Django Ninja, you use Pydantic Schemas for validation and a separate ModelSchema for serializing Django models.

Migrating from Django DRF to Django Ninja: A Developer's Guide

For years, Django REST Framework (DRF) has been the go-to for building robust APIs in Django. It's a powerful, feature-rich library with a massive community and a well-established pattern of Serializers, ViewSets, and Routers. But a new contender has emerged, inspired by the speed and simplicity of FastAPI: Django Ninja. If you're considering a switch, you're not alone. This guide will walk you through the key differences and how to make the move, leveraging the power of Pydantic.

Why Make the Switch?

DRF is a fantastic tool, but it can be verbose. The typical workflow often involves creating a Serializer class for data validation and serialization, a ViewSet for handling CRUD logic, and then a Router to generate the URLs. This can lead to a lot of boilerplate code, even for simple endpoints.

Django Ninja, on the other hand, is built on Pydantic and Python type hints. This modern approach offers several compelling benefits:

  • Less Boilerplate: You define your API endpoints as simple functions, using type hints for request and response data. Pydantic handles the heavy lifting of validation and serialization, drastically reducing the amount of code you need to write.

  • Automatic Documentation: Just like FastAPI, Django Ninja automatically generates interactive OpenAPI documentation (Swagger UI and ReDoc) from your type-hinted code. This means no more manual documentation or separate packages.

  • Intuitive & Explicit: The code is highly readable and explicit. Instead of relying on ViewSet magic, you define each endpoint with a simple decorator (@api.get, @api.post, etc.).

The Core Shift: From Serializers to Schemas

This is the most significant change when moving from DRF to Ninja.

In DRF, a Serializer handles both input validation and output serialization.

from rest_framework import serializers
from .models import Product

# DRF Serializer
class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'price']


# DRF ViewSet
from rest_framework import viewsets

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

In Django Ninja, you use Pydantic Schemas for validation and a separate ModelSchema for serializing Django models.

# Django Ninja Schemas
from ninja import Schema, ModelSchema
from .models import Product

# For request payload validation (input)
class ProductIn(Schema):
    name: str
    price: float

# For response data (output)
class ProductOut(ModelSchema):
    class Config:
        model = Product
        model_fields = ['id', 'name', 'price']

The ModelSchema is particularly powerful as it automatically creates a Pydantic schema based on your Django model, handling the conversion for you. This means you can often return a Django QuerySet or model instance directly, and Ninja will use the response schema you defined to handle the serialization.

A Practical Example: Migrating a CRUD Endpoint

Let's imagine a simple API for a Product model.

DRF Pattern (the old way):

Model (models.py):

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=255)
    price = models.DecimalField(max_digits=10, decimal_places=2)

Serializer (serializers.py):

from rest_framework import serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = '__all__'

ViewSet (views.py):

from rest_framework import viewsets
from .models import Product
from .serializers import ProductSerializer

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

URLs (urls.py):

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet

router = DefaultRouter()
router.register('products', ProductViewSet)

urlpatterns = [
    path('', include(router.urls)),
]

This setup automatically generates all your CRUD endpoints, which is a key advantage of DRF, but also abstracts away a lot of the implementation.


Django Ninja Pattern (the new way):

Model (models.py): Remains the same.

API Logic (api.py): This is where everything happens.

from ninja import NinjaAPI, ModelSchema, Schema
from typing import List
from .models import Product

api = NinjaAPI()

class ProductIn(Schema):
    name: str
    price: float

class ProductOut(ModelSchema):
    class Config:
        model = Product
        model_fields = ['id', 'name', 'price']

@api.post("/products", response=ProductOut)
def create_product(request, payload: ProductIn):
    product = Product.objects.create(**payload.dict())
    return product

@api.get("/products", response=List[ProductOut])
def list_products(request):
    return Product.objects.all()

@api.get("/products/", response=ProductOut)
def get_product(request, product_id: int):
    return Product.objects.get(id=product_id)

@api.put("/products/", response=ProductOut)
def update_product(request, product_id: int, payload: ProductIn):
    product = Product.objects.get(id=product_id)
    for attr, value in payload.dict(exclude_unset=True).items():
        setattr(product, attr, value)
    product.save()
    return product

@api.delete("/products/")
def delete_product(request, product_id: int):
    product = Product.objects.get(id=product_id)
    product.delete()
    return {"success": True}

URLs (urls.py):

from django.urls import path
from .api import api

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

This setup is more manual, but the logic is right there in the function. You can clearly see the input (payload: ProductIn) and the expected output (response=ProductOut), making the code self-documenting.


Benefits We've Experienced

Since adopting this combo, we've observed several significant improvements:

  • Reduced Boilerplate: We can define a complete API endpoint with input validation, output serialization, and automatic documentation in a much more concise way.

  • Simple API implementation: When you look at it at a glance, it almost looks like something that is more of a FastAPI implementation. We want to keep things simple, define your endpoints, your expected query parameter and data, and report back the response. At a glance for one endpoint, it looks very clear and nothing is hiding you from correctly guessing what this endpoint is looking for and what it is responding

  • Less magic / hidden operations: Django DRF is very powerful - however as for every framework, it hides certain things that some developers might not be aware of, often catching them off guard when trying to trace things. Pydantic makes it clearer 

  • Enhanced Type Safety: Leveraging type hints with Pydantic has made our API code more robust and less prone to common data-related errors. It's also made our codebase easier to understand and maintain. It also identifies any type errors on building time, reducing any chances of runtime errors

  • Better Developer Experience: The automatic OpenAPI documentation and the explicit nature of Pydantic models have made it easier for developers to build and consume our APIs.

  • Better code encapsulation: As your code gets bigger, often you will find integrating your model everywhere is not going to be maintainable in the future (Think about modifying your model or putting a cache layer above it since it is a big model). With Ninja / Pydantic implementation, your model does not have to integrate with the API, furthermore you can try to implement service based pattern to separate the concern between API implementation and the actual business logic, which again makes it way cleaner

Considerations for Transition

While the benefits are clear for us, a transition isn't without its considerations:

  • Learning Curve: Developers familiar with DRF's serializer-heavy approach will need to adapt to Pydantic's data modeling.

  • Existing Codebase: Migrating an existing, large DRF codebase to Ninja/Pydantic requires a thoughtful strategy, potentially taking an iterative approach.

  • Ecosystem Maturity: While both Django Ninja and Pydantic are mature and widely used, DRF has a larger and more established ecosystem of plugins and community support.


Conclusion

The move from Django DRF API patterns to the Django Ninja API and Pydantic combo has been a positive step for our team. It has allowed us to build more performant, maintainable, and type-safe APIs with a better developer experience. While DRF remains a powerful tool, for our current and future needs, the elegance and efficiency of Ninja and Pydantic are proving to be a winning combination.

We encourage other Django developers to explore this powerful duo, especially if you're looking to modernize your API development workflow and leverage the full potential of Python type hints.