Agent Skills: Django Ninja

Fast Django REST framework with Pydantic validation, type hints, and automatic OpenAPI documentation for building type-safe APIs

UncategorizedID: CodeAtCode/oss-ai-skills/django-ninja

Install this agent skill to your local

pnpm dlx add-skill https://github.com/CodeAtCode/oss-ai-skills/tree/HEAD/frameworks/django-ninja

Skill Files

Browse the full folder contents for django-ninja.

Download Skill

Loading file tree…

frameworks/django-ninja/SKILL.md

Skill Metadata

Name
django-ninja
Description
"Fast Django REST framework with Pydantic validation, type hints, and automatic OpenAPI documentation for building type-safe APIs"

Django Ninja

Complete reference for building fast, type-safe REST APIs with Django and Pydantic.

Overview

Django Ninja is a web framework for building APIs with Django and Python 3.6+ type hints. It provides automatic request validation, response serialization, and generates OpenAPI documentation.

Key Features:

  • Fast: Built on Pydantic for high performance
  • Type-safe: Full IDE autocomplete and type checking
  • Auto docs: Automatic OpenAPI/Swagger documentation
  • Easy: Django integration with minimal boilerplate
  • Async support: Native async/await support

Django Ninja vs Django REST Framework

| Feature | Django Ninja | DRF | |---------|-------------|-----| | Validation | Pydantic | Serializers | | Performance | Very Fast | Fast | | Type Safety | Full | Partial | | OpenAPI | Auto | Manual (drf-spectacular) | | Learning Curve | Easy | Moderate | | Async | Native | Limited |

Use Django Ninja when:

  • Building new REST APIs
  • Need type safety and IDE support
  • Want automatic OpenAPI docs
  • Prefer Pydantic validation

Use DRF when:

  • Have existing DRF codebase
  • Need browsable API (HTML responses)
  • Require specific DRF features

Installation

pip install django-ninja

Django Integration

# settings.py
INSTALLED_APPS = [
    # ...
    'ninja',
]

Basic Setup

# api.py
from ninja import NinjaAPI

api = NinjaAPI()

@api.get("/hello")
def hello(request):
    return {"message": "Hello World"}

# urls.py
from django.urls import path
from .api import api

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

Project Structure

myproject/
├── api/
│   ├── __init__.py
│   ├── urls.py
│   ├── schemas.py
│   ├── views/
│   │   ├── __init__.py
│   │   ├── users.py
│   │   └── posts.py
│   └── auth.py
├── models.py
└── settings.py

Schema Definitions

Basic Schema

from ninja import Schema
from datetime import datetime
from typing import Optional, List

class UserIn(Schema):
    username: str
    email: str
    password: str
    first_name: Optional[str] = None
    last_name: Optional[str] = None

class UserOut(Schema):
    id: int
    username: str
    email: str
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    created_at: datetime

class UserUpdate(Schema):
    username: Optional[str] = None
    email: Optional[str] = None
    first_name: Optional[str] = None
    last_name: Optional[str] = None

ModelSchema (from Django Models)

from ninja import ModelSchema
from .models import User, Post

class UserSchema(ModelSchema):
    class Config:
        model = User
        model_fields = ['id', 'username', 'email', 'first_name', 'last_name']

class PostSchema(ModelSchema):
    author: UserSchema  # Nested schema
    
    class Config:
        model = Post
        model_fields = ['id', 'title', 'slug', 'body', 'publish', 'status']

class PostCreateSchema(ModelSchema):
    class Config:
        model = Post
        model_fields = ['title', 'body', 'status']
        model_fields_optional = ['status']  # Optional fields

class PostUpdateSchema(ModelSchema):
    class Config:
        model = Post
        model_fields = ['title', 'body', 'status']
        model_fields_optional = '__all__'  # All fields optional

Nested Schemas

from typing import List

class CommentSchema(Schema):
    id: int
    content: str
    author: str
    created_at: datetime

class PostDetailSchema(Schema):
    id: int
    title: str
    slug: str
    body: str
    author: UserSchema
    categories: List[str]
    comments: List[CommentSchema]
    created_at: datetime
    updated_at: datetime

Validators

from ninja import Schema
from pydantic import validator, root_validator
import re

class UserCreate(Schema):
    username: str
    email: str
    password: str
    
    @validator('username')
    def validate_username(cls, v):
        if len(v) < 3:
            raise ValueError('Username must be at least 3 characters')
        if not re.match(r'^[a-zA-Z0-9_]+$', v):
            raise ValueError('Username can only contain letters, numbers, and underscores')
        return v
    
    @validator('email')
    def validate_email(cls, v):
        if '@' not in v:
            raise ValueError('Invalid email format')
        return v.lower()
    
    @validator('password')
    def validate_password(cls, v):
        if len(v) < 8:
            raise ValueError('Password must be at least 8 characters')
        return v
    
    @root_validator
    def validate_all(cls, values):
        # Cross-field validation
        if values.get('username') == values.get('password'):
            raise ValueError('Password cannot be the same as username')
        return values

Custom Types

from ninja import Schema
from typing import Annotated, List
from pydantic import Field, HttpUrl

# Using Annotated for reusable validators
class PostCreate(Schema):
    title: Annotated[str, Field(min_length=5, max_length=200)]
    slug: Annotated[str, Field(pattern=r'^[a-z0-9-]+$')]
    body: Annotated[str, Field(min_length=10)]
    tags: Annotated[List[str], Field(max_items=10)] = []
    
# Custom type with validator
class URLSchema(Schema):
    url: HttpUrl  # Validates URL format

# Using constrint
from pydantic import constr

ShortStr = constr(max_length=50)
EmailStr = constr(regex=r'^[^@]+@[^@]+\.[^@]+$')

class ContactSchema(Schema):
    name: ShortStr
    email: EmailStr

Router & API

HTTP Methods

from ninja import NinjaAPI
from .schemas import UserIn, UserOut, PostSchema

api = NinjaAPI()

# GET - Retrieve resources
@api.get("/users", response=List[UserOut])
def list_users(request):
    return User.objects.all()

@api.get("/users/{user_id}", response=UserOut)
def get_user(request, user_id: int):
    user = get_object_or_404(User, id=user_id)
    return user

# POST - Create resources
@api.post("/users", response=UserOut)
def create_user(request, payload: UserIn):
    user = User.objects.create_user(**payload.dict())
    return user

# PUT - Full update
@api.put("/users/{user_id}", response=UserOut)
def update_user(request, user_id: int, payload: UserUpdate):
    user = get_object_or_404(User, id=user_id)
    for attr, value in payload.dict(exclude_unset=True).items():
        setattr(user, attr, value)
    user.save()
    return user

# PATCH - Partial update
@api.patch("/users/{user_id}", response=UserOut)
def partial_update_user(request, user_id: int, payload: UserUpdate):
    user = get_object_or_404(User, id=user_id)
    for attr, value in payload.dict(exclude_unset=True).items():
        setattr(user, attr, value)
    user.save()
    return user

# DELETE
@api.delete("/users/{user_id}")
def delete_user(request, user_id: int):
    user = get_object_or_404(User, id=user_id)
    user.delete()
    return {"success": True}

Path Parameters

from ninja import Path

@api.get("/posts/{post_id}/comments/{comment_id}")
def get_comment(
    request,
    post_id: int,
    comment_id: int
):
    """Path parameters with type hints are automatically validated."""
    comment = get_object_or_404(Comment, id=comment_id, post_id=post_id)
    return {"comment": comment.content}

# String path parameters
@api.get("/categories/{slug}/posts")
def category_posts(request, slug: str):
    category = get_object_or_404(Category, slug=slug)
    return category.posts.all()

# UUID path parameters
import uuid

@api.get("/orders/{order_id}")
def get_order(request, order_id: uuid.UUID):
    order = get_object_or_404(Order, id=order_id)
    return order

Query Parameters

from ninja import Query, Schema
from typing import Optional, List
from datetime import date

class FilterParams(Schema):
    search: Optional[str] = None
    status: Optional[str] = None
    category: Optional[int] = None
    tags: Optional[List[str]] = None
    created_after: Optional[date] = None
    created_before: Optional[date] = None
    ordering: Optional[str] = "-created_at"
    page: int = 1
    page_size: int = 20

@api.get("/posts", response=List[PostSchema])
def list_posts(request, filters: FilterParams = Query(...)):
    """Query parameters are automatically validated and parsed."""
    posts = Post.objects.all()
    
    if filters.search:
        posts = posts.filter(
            Q(title__icontains=filters.search) |
            Q(body__icontains=filters.search)
        )
    
    if filters.status:
        posts = posts.filter(status=filters.status)
    
    if filters.category:
        posts = posts.filter(categories__id=filters.category)
    
    if filters.tags:
        posts = posts.filter(tags__name__in=filters.tags)
    
    if filters.created_after:
        posts = posts.filter(created_at__gte=filters.created_after)
    
    if filters.created_before:
        posts = posts.filter(created_at__lte=filters.created_before)
    
    posts = posts.order_by(filters.ordering)
    
    return posts[(filters.page - 1) * filters.page_size:filters.page * filters.page_size]

Request Body

from ninja import Body, Schema
from typing import List

class PostCreate(Schema):
    title: str
    body: str
    category_ids: List[int]
    tags: List[str] = []

@api.post("/posts", response=PostSchema)
def create_post(request, payload: PostCreate):
    """Request body is automatically validated against schema."""
    # payload is a Pydantic model instance
    post = Post.objects.create(
        title=payload.title,
        body=payload.body,
        author=request.user
    )
    
    if payload.category_ids:
        post.categories.set(payload.category_ids)
    
    if payload.tags:
        post.tags.set(payload.tags)
    
    return post

# Multiple body parameters
@api.post("/posts/{post_id}/comments")
def add_comment(
    request,
    post_id: int,
    content: str = Body(...),
    author_name: str = Body(...),
    author_email: str = Body(...)
):
    post = get_object_or_404(Post, id=post_id)
    comment = Comment.objects.create(
        post=post,
        content=content,
        author_name=author_name,
        author_email=author_email
    )
    return {"id": comment.id}

Form Data

from ninja import Form, Schema, File
from django.core.files.uploadedfile import UploadedFile

class ContactForm(Schema):
    name: str
    email: str
    message: str

@api.post("/contact")
def contact_form(request, data: ContactForm = Form(...)):
    """Handle form submission."""
    send_contact_email(data.name, data.email, data.message)
    return {"status": "sent"}

# File uploads with form data
@api.post("/upload")
def upload_with_data(
    request,
    file: UploadedFile = File(...),
    description: str = Form(...),
    tags: List[str] = Form(default=[])
):
    """Upload file with metadata."""
    # Handle file and form data together
    pass

CRUD Operations

Complete CRUD Example

from ninja import NinjaAPI, ModelSchema, Schema
from django.shortcuts import get_object_or_404
from typing import List, Optional

api = NinjaAPI()

# Schemas
class CategoryOut(ModelSchema):
    class Config:
        model = Category
        model_fields = ['id', 'name', 'slug']

class PostOut(ModelSchema):
    author: UserSchema
    categories: List[CategoryOut]
    
    class Config:
        model = Post
        model_fields = ['id', 'title', 'slug', 'body', 'status', 'publish']

class PostIn(Schema):
    title: str
    slug: str
    body: str
    status: str = 'draft'
    category_ids: List[int] = []

class PostUpdate(Schema):
    title: Optional[str] = None
    slug: Optional[str] = None
    body: Optional[str] = None
    status: Optional[str] = None
    category_ids: Optional[List[int]] = None

# List
@api.get("/posts", response=List[PostOut])
def list_posts(request):
    return Post.objects.select_related('author').prefetch_related('categories').all()

# Retrieve
@api.get("/posts/{post_id}", response=PostOut)
def get_post(request, post_id: int):
    post = get_object_or_404(
        Post.objects.select_related('author').prefetch_related('categories'),
        id=post_id
    )
    return post

# Create
@api.post("/posts", response=PostOut)
def create_post(request, payload: PostIn):
    post = Post(
        title=payload.title,
        slug=payload.slug,
        body=payload.body,
        status=payload.status,
        author=request.user
    )
    post.save()
    
    if payload.category_ids:
        post.categories.set(payload.category_ids)
    
    return post

# Full Update
@api.put("/posts/{post_id}", response=PostOut)
def update_post(request, post_id: int, payload: PostIn):
    post = get_object_or_404(Post, id=post_id)
    
    post.title = payload.title
    post.slug = payload.slug
    post.body = payload.body
    post.status = payload.status
    post.save()
    
    post.categories.set(payload.category_ids)
    
    return post

# Partial Update
@api.patch("/posts/{post_id}", response=PostOut)
def partial_update_post(request, post_id: int, payload: PostUpdate):
    post = get_object_or_404(Post, id=post_id)
    
    update_data = payload.dict(exclude_unset=True)
    
    if 'category_ids' in update_data:
        category_ids = update_data.pop('category_ids')
        post.categories.set(category_ids)
    
    for attr, value in update_data.items():
        setattr(post, attr, value)
    
    post.save()
    return post

# Delete
@api.delete("/posts/{post_id}")
def delete_post(request, post_id: int):
    post = get_object_or_404(Post, id=post_id)
    post.delete()
    return {"success": True, "id": post_id}

Bulk Operations

from ninja import Schema
from typing import List

class BulkPostCreate(Schema):
    posts: List[PostIn]

@api.post("/posts/bulk", response=List[PostOut])
def bulk_create_posts(request, payload: BulkPostCreate):
    """Create multiple posts at once."""
    created_posts = []
    
    for post_data in payload.posts:
        post = Post.objects.create(
            **post_data.dict(exclude={'category_ids'}),
            author=request.user
        )
        if post_data.category_ids:
            post.categories.set(post_data.category_ids)
        created_posts.append(post)
    
    return created_posts

@api.post("/posts/bulk-delete")
def bulk_delete_posts(request, ids: List[int]):
    """Delete multiple posts."""
    deleted, _ = Post.objects.filter(id__in=ids).delete()
    return {"deleted": deleted}

Authentication

Global Authentication

from ninja import NinjaAPI
from ninja.security import APIKeyQuery, APIKeyHeader, HttpBearer

# API Key in Header
class ApiKey(APIKeyHeader):
    param_name = "X-API-Key"
    
    def authenticate(self, request, key):
        try:
            return User.objects.get(api_key=key)
        except User.DoesNotExist:
            pass

# API Key in Query
class QueryApiKey(APIKeyQuery):
    param_name = "api_key"
    
    def authenticate(self, request, key):
        try:
            return User.objects.get(api_key=key)
        except User.DoesNotExist:
            pass

# Bearer Token (JWT)
class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        try:
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
            user = User.objects.get(id=payload['user_id'])
            if user.is_active:
                return user
        except (jwt.DecodeError, User.DoesNotExist):
            pass

# Use authentication
api = NinjaAPI(auth=ApiKey())

Per-Endpoint Authentication

from ninja import NinjaAPI
from ninja.security import django_auth

public_api = NinjaAPI(auth=None)  # No auth by default
private_api = NinjaAPI(auth=AuthBearer())

# Public endpoints
@public_api.get("/public/data")
def public_data(request):
    return {"message": "This is public"}

# Private endpoints
@private_api.get("/private/data", auth=AuthBearer())
def private_data(request):
    # request.auth contains authenticated user
    return {"user": request.auth.username}

# Mix auth on same API
api = NinjaAPI()

@api.get("/public")
def public_endpoint(request):
    return {"public": True}

@api.get("/private", auth=AuthBearer())
def private_endpoint(request):
    return {"user": request.auth.username}

# Multiple auth methods
@api.get("/multi-auth", auth=[ApiKey(), AuthBearer()])
def multi_auth_endpoint(request):
    """Try multiple auth methods."""
    return {"user": request.auth.username}

JWT Authentication

from ninja import Schema, NinjaAPI
from ninja.security import HttpBearer
import jwt
from datetime import datetime, timedelta
from django.conf import settings

class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        try:
            payload = jwt.decode(
                token,
                settings.SECRET_KEY,
                algorithms=['HS256']
            )
            user = User.objects.get(id=payload['user_id'])
            if user.is_active:
                return user
        except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
            return None

class LoginSchema(Schema):
    username: str
    password: str

class TokenSchema(Schema):
    access: str
    refresh: str
    expires_in: int

api = NinjaAPI()

@api.post("/login", response=TokenSchema)
def login(request, credentials: LoginSchema):
    from django.contrib.auth import authenticate
    
    user = authenticate(
        username=credentials.username,
        password=credentials.password
    )
    
    if not user:
        return {"error": "Invalid credentials"}, 401
    
    # Generate tokens
    access_payload = {
        'user_id': user.id,
        'exp': datetime.utcnow() + timedelta(hours=1),
        'type': 'access'
    }
    
    refresh_payload = {
        'user_id': user.id,
        'exp': datetime.utcnow() + timedelta(days=7),
        'type': 'refresh'
    }
    
    return {
        'access': jwt.encode(access_payload, settings.SECRET_KEY, algorithm='HS256'),
        'refresh': jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm='HS256'),
        'expires_in': 3600
    }

@api.post("/refresh", response=TokenSchema)
def refresh_token(request, refresh: str = Body(...)):
    try:
        payload = jwt.decode(refresh, settings.SECRET_KEY, algorithms=['HS256'])
        
        if payload.get('type') != 'refresh':
            return {"error": "Invalid token type"}, 401
        
        user = User.objects.get(id=payload['user_id'])
        
        access_payload = {
            'user_id': user.id,
            'exp': datetime.utcnow() + timedelta(hours=1),
            'type': 'access'
        }
        
        return {
            'access': jwt.encode(access_payload, settings.SECRET_KEY, algorithm='HS256'),
            'refresh': refresh,
            'expires_in': 3600
        }
    except jwt.InvalidTokenError:
        return {"error": "Invalid token"}, 401

# Protected endpoints
@api.get("/profile", auth=AuthBearer())
def profile(request):
    return {"username": request.auth.username, "email": request.auth.email}

Session Authentication

from ninja.security import django_auth

api = NinjaAPI(auth=django_auth)

@api.get("/me")
def current_user(request):
    # request.user is authenticated via Django session
    return {
        "id": request.user.id,
        "username": request.user.username,
        "email": request.user.email
    }

Basic Authentication

from ninja.security import HttpBasicAuth

class BasicAuth(HttpBasicAuth):
    def authenticate(self, request, username, password):
        from django.contrib.auth import authenticate
        user = authenticate(username=username, password=password)
        if user and user.is_active:
            return user
        return None

api = NinjaAPI(auth=BasicAuth())

@api.get("/protected")
def protected(request):
    return {"user": request.auth.username}

Custom Authentication

from ninja.security import APIKeyHeader
from functools import wraps

class CustomAuth(APIKeyHeader):
    param_name = "Authorization"
    
    def authenticate(self, request, token):
        # Custom token validation
        if not token.startswith("Bearer "):
            return None
        
        token = token[7:]  # Remove "Bearer "
        
        try:
            # Custom token validation logic
            user = validate_custom_token(token)
            return user
        except Exception:
            return None

# Function-based auth
def custom_authenticator(request):
    token = request.headers.get('X-Custom-Token')
    if not token:
        return None
    
    try:
        return validate_token(token)
    except Exception:
        return None

@api.get("/custom-auth", auth=custom_authenticator)
def custom_auth_endpoint(request):
    return {"authenticated": True}

Pagination

LimitOffsetPagination

from ninja import NinjaAPI, Schema
from ninja.pagination import paginate, LimitOffsetPagination

api = NinjaAPI()

class PostOut(ModelSchema):
    class Config:
        model = Post
        model_fields = ['id', 'title', 'slug']

@api.get("/posts", response=List[PostOut])
@paginate(LimitOffsetPagination)
def list_posts(request):
    """Returns paginated results with limit and offset."""
    return Post.objects.all()

# Response format:
# {
#   "items": [...],
#   "count": 100
# }
# 
# Query: /posts?limit=10&offset=20

PageNumberPagination

from ninja.pagination import PageNumberPagination

class CustomPagination(PageNumberPagination):
    page_size = 20
    page_query_param = 'page'
    page_size_query_param = 'page_size'
    max_page_size = 100

@api.get("/posts", response=List[PostOut])
@paginate(CustomPagination)
def list_posts(request):
    """Returns paginated results with page number."""
    return Post.objects.all()

# Response format:
# {
#   "items": [...],
#   "count": 100,
#   "page": 1,
#   "page_size": 20,
#   "pages": 5
# }
#
# Query: /posts?page=2&page_size=50

Custom Pagination

from ninja.pagination import PaginationBase
from ninja import Schema
from typing import List, Any

class CustomPaginatedResponse(Schema):
    items: List[Any]
    total: int
    page: int
    per_page: int
    total_pages: int
    has_next: bool
    has_prev: bool

class CursorPagination(PaginationBase):
    """Cursor-based pagination for large datasets."""
    
    class Output(Schema):
        items: List[Any]
        next_cursor: str = None
        prev_cursor: str = None
        has_more: bool
    
    def paginate_queryset(self, queryset, request, **kwargs):
        cursor = request.GET.get('cursor')
        limit = int(request.GET.get('limit', 20))
        
        if cursor:
            queryset = queryset.filter(id__gt=cursor)
        
        items = list(queryset[:limit + 1])
        has_more = len(items) > limit
        
        if has_more:
            items = items[:-1]
        
        return {
            'items': items,
            'next_cursor': str(items[-1].id) if items and has_more else None,
            'prev_cursor': cursor,
            'has_more': has_more
        }

@api.get("/posts", response=CustomPaginatedResponse)
@paginate(CursorPagination)
def list_posts_cursor(request):
    return Post.objects.all().order_by('id')

Error Handling

Validation Errors

from ninja import NinjaAPI
from ninja.errors import ValidationError
from pydantic import ValidationError as PydanticValidationError

api = NinjaAPI()

# Automatic validation
class UserCreate(Schema):
    username: str  # Required
    email: str  # Required
    age: int  # Must be integer

@api.post("/users")
def create_user(request, payload: UserCreate):
    # If validation fails, returns 422 with details
    user = User.objects.create_user(**payload.dict())
    return user

# Validation error response format:
# {
#   "detail": [
#     {
#       "loc": ["body", "username"],
#       "msg": "field required",
#       "type": "value_error.missing"
#     }
#   ]
# }

Custom Exceptions

from ninja import NinjaAPI, HttpError
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError

api = NinjaAPI()

class CustomException(Exception):
    def __init__(self, message, code):
        self.message = message
        self.code = code

@api.exception_handler(CustomException)
def custom_exception_handler(request, exc):
    return api.create_response(
        request,
        {"error": exc.message, "code": exc.code},
        status=400
    )

@api.exception_handler(ObjectDoesNotExist)
def not_found_handler(request, exc):
    return api.create_response(
        request,
        {"error": "Resource not found"},
        status=404
    )

@api.exception_handler(IntegrityError)
def integrity_error_handler(request, exc):
    return api.create_response(
        request,
        {"error": "Database integrity error", "detail": str(exc)},
        status=409
    )

# Using HttpError
@api.get("/users/{user_id}")
def get_user(request, user_id: int):
    try:
        user = User.objects.get(id=user_id)
    except User.DoesNotExist:
        raise HttpError(404, "User not found")
    return user

@api.post("/users")
def create_user(request, payload: UserCreate):
    if User.objects.filter(username=payload.username).exists():
        raise HttpError(409, "Username already exists")
    return User.objects.create_user(**payload.dict())

Error Response Format

from ninja import Schema
from typing import List, Optional

class ErrorDetail(Schema):
    loc: List[str]
    msg: str
    type: str

class ErrorResponse(Schema):
    detail: List[ErrorDetail]
    
# Configure custom error response
api = NinjaAPI(
    default_error_responses={
        400: ErrorResponse,
        422: ErrorResponse
    }
)

# Custom error renderer
def custom_error_renderer(request, errors):
    return {
        "success": False,
        "errors": [
            {
                "field": ".".join(str(loc) for loc in e["loc"]),
                "message": e["msg"]
            }
            for e in errors
        ]
    }

api = NinjaAPI(error_renderer=custom_error_renderer)

File Uploads

Single File Upload

from ninja import File, NinjaAPI
from django.core.files.uploadedfile import UploadedFile

api = NinjaAPI()

@api.post("/upload")
def upload_file(request, file: UploadedFile = File(...)):
    """Upload a single file."""
    # Validate file
    if file.size > 10 * 1024 * 1024:  # 10MB
        raise HttpError(413, "File too large (max 10MB)")
    
    allowed_types = ['image/jpeg', 'image/png', 'application/pdf']
    if file.content_type not in allowed_types:
        raise HttpError(415, f"File type {file.content_type} not allowed")
    
    # Save file
    file_path = handle_uploaded_file(file)
    
    return {
        "filename": file.name,
        "size": file.size,
        "content_type": file.content_type,
        "path": file_path
    }

def handle_uploaded_file(f):
    import os
    from django.conf import settings
    
    upload_dir = os.path.join(settings.MEDIA_ROOT, 'uploads')
    os.makedirs(upload_dir, exist_ok=True)
    
    file_path = os.path.join(upload_dir, f.name)
    
    with open(file_path, 'wb+') as destination:
        for chunk in f.chunks():
            destination.write(chunk)
    
    return file_path

Multiple File Uploads

from typing import List

@api.post("/upload/multiple")
def upload_files(request, files: List[UploadedFile] = File(...)):
    """Upload multiple files."""
    results = []
    
    for file in files:
        if file.size > 10 * 1024 * 1024:
            continue  # Skip large files
        
        file_path = handle_uploaded_file(file)
        results.append({
            "filename": file.name,
            "size": file.size,
            "path": file_path
        })
    
    return {"uploaded": len(results), "files": results}

File Upload with Schema

from ninja import Schema, File, Form
from typing import Optional

class FileMetadata(Schema):
    title: str
    description: Optional[str] = None
    tags: List[str] = []

@api.post("/upload/with-metadata")
def upload_with_metadata(
    request,
    file: UploadedFile = File(...),
    metadata: FileMetadata = Form(...)
):
    """Upload file with metadata."""
    file_obj = UploadedFile.objects.create(
        file=file,
        title=metadata.title,
        description=metadata.description,
        tags=metadata.tags
    )
    return {"id": file_obj.id, "filename": file.name}

Testing

pytest with Django Ninja

# tests/test_api.py
import pytest
from django.test import Client
from ninja.testing import TestClient
from myapp.api import api

@pytest.fixture
def api_client():
    return TestClient(api)

def test_list_posts(api_client):
    response = api_client.get("/posts")
    assert response.status_code == 200
    assert isinstance(response.json(), list)

def test_create_post(api_client):
    data = {
        "title": "Test Post",
        "slug": "test-post",
        "body": "Test content",
        "status": "draft"
    }
    response = api_client.post("/posts", json=data)
    assert response.status_code == 200
    result = response.json()
    assert result["title"] == "Test Post"
    assert result["id"] is not None

def test_get_post(api_client):
    # First create a post
    create_response = api_client.post("/posts", json={
        "title": "Test",
        "slug": "test",
        "body": "Content"
    })
    post_id = create_response.json()["id"]
    
    # Then retrieve it
    response = api_client.get(f"/posts/{post_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Test"

def test_update_post(api_client):
    # Create post
    create = api_client.post("/posts", json={
        "title": "Original",
        "slug": "original",
        "body": "Content"
    })
    post_id = create.json()["id"]
    
    # Update
    response = api_client.patch(f"/posts/{post_id}", json={
        "title": "Updated"
    })
    assert response.status_code == 200
    assert response.json()["title"] == "Updated"

def test_delete_post(api_client):
    # Create post
    create = api_client.post("/posts", json={
        "title": "To Delete",
        "slug": "to-delete",
        "body": "Content"
    })
    post_id = create.json()["id"]
    
    # Delete
    response = api_client.delete(f"/posts/{post_id}")
    assert response.status_code == 200
    
    # Verify deleted
    get_response = api_client.get(f"/posts/{post_id}")
    assert get_response.status_code == 404

def test_validation_error(api_client):
    response = api_client.post("/posts", json={
        "title": "Test"
        # Missing required fields
    })
    assert response.status_code == 422

Testing Authentication

from ninja.testing import TestClient
from myapp.api import api, AuthBearer

@pytest.fixture
def authenticated_client():
    client = TestClient(api)
    # Mock authentication
    user = User.objects.create_user(username="testuser", password="testpass")
    client.force_authenticate(user)
    return client

def test_authenticated_endpoint(authenticated_client):
    response = authenticated_client.get("/profile")
    assert response.status_code == 200
    assert response.json()["username"] == "testuser"

def test_unauthenticated():
    client = TestClient(api)
    response = client.get("/profile")
    assert response.status_code == 401

# Testing with JWT
def test_jwt_authentication():
    client = TestClient(api)
    
    # Login first
    login_response = client.post("/login", json={
        "username": "testuser",
        "password": "testpass"
    })
    token = login_response.json()["access"]
    
    # Use token
    response = client.get(
        "/profile",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert response.status_code == 200

Async Support

Async Views

from ninja import NinjaAPI
from asgiref.sync import sync_to_async

api = NinjaAPI()

@api.get("/async-posts")
async def async_list_posts(request):
    """Async endpoint for listing posts."""
    # Use async ORM
    posts = await sync_to_async(list)(Post.objects.all()[:10])
    return posts

@api.post("/async-posts")
async def async_create_post(request, payload: PostCreate):
    """Async endpoint for creating posts."""
    @sync_to_async
    def create_post_sync():
        return Post.objects.create(**payload.dict())
    
    post = await create_post_sync()
    return post

# Async with external API
import aiohttp

@api.get("/external-data")
async def fetch_external(request):
    """Fetch data from external API asynchronously."""
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.example.com/data') as response:
            data = await response.json()
            return data

Async Authentication

from ninja.security import HttpBearer

class AsyncAuthBearer(HttpBearer):
    async def authenticate(self, request, token):
        """Async token validation."""
        user = await validate_token_async(token)
        return user

async def validate_token_async(token):
    """Validate JWT token asynchronously."""
    import jwt
    from django.conf import settings
    
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
        user = await sync_to_async(User.objects.get)(id=payload['user_id'])
        return user
    except Exception:
        return None

api = NinjaAPI(auth=AsyncAuthBearer())

@api.get("/async-protected")
async def async_protected(request):
    return {"user": request.auth.username}

OpenAPI Documentation

Swagger UI

from ninja import NinjaAPI

api = NinjaAPI(
    title="My API",
    description="API documentation",
    version="1.0.0",
    docs_url="/docs/",  # Swagger UI at /api/docs/
    openapi_url="/openapi.json"  # OpenAPI schema at /api/openapi.json
)

# Access at: /api/docs/
# OpenAPI schema at: /api/openapi.json

Customizing Documentation

from ninja import Schema
from typing import Optional

class PostSchema(Schema):
    id: int
    title: str
    body: str
    
    class Config:
        # OpenAPI schema customization
        schema_extra = {
            "example": {
                "id": 1,
                "title": "My First Post",
                "body": "This is the content of my first post."
            }
        }

@api.get(
    "/posts/{post_id}",
    response=PostSchema,
    summary="Get a post by ID",
    description="Retrieves a specific post by its ID. Returns 404 if not found.",
    tags=["posts"],
    operation_id="get_post_by_id"
)
def get_post(request, post_id: int):
    """
    Retrieve a post by ID.
    
    Args:
        post_id: The unique identifier of the post
        
    Returns:
        PostSchema: The requested post
        
    Raises:
        HttpError: 404 if post not found
    """
    post = get_object_or_404(Post, id=post_id)
    return post

# Tags for grouping endpoints
api = NinjaAPI()

posts_tags = ["posts"]
users_tags = ["users"]

@api.get("/posts", tags=posts_tags)
def list_posts(request):
    pass

@api.get("/users", tags=users_tags)
def list_users(request):
    pass

Response Examples

from ninja import Schema

class PostSchema(Schema):
    id: int
    title: str
    body: str

@api.get(
    "/posts/{post_id}",
    response={
        200: PostSchema,
        404: ErrorSchema
    },
    examples={
        200: {"id": 1, "title": "Post Title", "body": "Post content"},
        404: {"error": "Post not found"}
    }
)
def get_post_with_examples(request, post_id: int):
    pass

Django Integration

Models

# models.py
from django.db import models
from django.contrib.auth.models import User

class Post(models.Model):
    STATUS_CHOICES = [
        ('draft', 'Draft'),
        ('published', 'Published'),
    ]
    
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
    body = models.TextField()
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
    categories = models.ManyToManyField('Category', related_name='posts', blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return self.title

class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    
    def __str__(self):
        return self.name

Middleware Integration

# middleware.py
from django.utils.deprecation import MiddlewareMixin

class APIMiddleware(MiddlewareMixin):
    """Custom middleware for API requests."""
    
    def process_request(self, request):
        # Add custom headers or modify request
        request.api_version = request.headers.get('X-API-Version', '1.0')
    
    def process_response(self, request, response):
        # Add headers to all API responses
        if request.path.startswith('/api/'):
            response['X-API-Version'] = getattr(request, 'api_version', '1.0')
        return response

# settings.py
MIDDLEWARE = [
    # ...
    'myapp.middleware.APIMiddleware',
]

Signals

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post

@receiver(post_save, sender=Post)
def post_created_handler(sender, instance, created, **kwargs):
    """Handle post creation."""
    if created:
        # Send notification, update cache, etc.
        send_notification(instance)

# apps.py
from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = 'myapp'
    
    def ready(self):
        import myapp.signals

Best Practices

1. Code Organization

# api/__init__.py
from ninja import NinjaAPI

api = NinjaAPI(title="My API")

# Import routers
from .users import router as users_router
from .posts import router as posts_router

api.add_router("/users", users_router)
api.add_router("/posts", posts_router)

# api/users.py
from ninja import Router

router = Router()

@router.get("/")
def list_users(request):
    pass

# api/posts.py
from ninja import Router

router = Router()

@router.get("/")
def list_posts(request):
    pass

2. Schema Organization

# schemas/__init__.py
from .users import UserIn, UserOut, UserUpdate
from .posts import PostIn, PostOut, PostUpdate

# schemas/users.py
from ninja import Schema, ModelSchema
from typing import Optional

class UserBase(Schema):
    username: str
    email: str
    first_name: Optional[str] = None
    last_name: Optional[str] = None

class UserIn(UserBase):
    password: str

class UserOut(UserBase):
    id: int
    created_at: str

class UserUpdate(Schema):
    username: Optional[str] = None
    email: Optional[str] = None
    first_name: Optional[str] = None
    last_name: Optional[str] = None

3. Error Handling

# utils/errors.py
from ninja import HttpError

class NotFoundError(HttpError):
    def __init__(self, resource, identifier):
        super().__init__(404, f"{resource} with id {identifier} not found")

class PermissionDeniedError(HttpError):
    def __init__(self, message="Permission denied"):
        super().__init__(403, message)

class ValidationError(HttpError):
    def __init__(self, message):
        super().__init__(422, message)

# Usage
@api.get("/posts/{post_id}")
def get_post(request, post_id: int):
    post = Post.objects.filter(id=post_id).first()
    if not post:
        raise NotFoundError("Post", post_id)
    if post.author != request.user:
        raise PermissionDeniedError("You can only view your own posts")
    return post

4. Query Optimization

# Always use select_related for ForeignKey
@api.get("/posts")
def list_posts(request):
    return Post.objects.select_related('author').all()

# Use prefetch_related for ManyToMany
@api.get("/posts")
def list_posts_with_categories(request):
    return Post.objects.select_related('author').prefetch_related('categories').all()

# Use only() for specific fields
@api.get("/posts")
def list_posts_minimal(request):
    return Post.objects.only('id', 'title', 'slug').all()

# Use defer() for excluding large fields
@api.get("/posts")
def list_posts_without_body(request):
    return Post.objects.defer('body').all()

5. Caching

from django.core.cache import cache
from functools import wraps

def cache_response(timeout=300):
    """Decorator for caching API responses."""
    def decorator(func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            cache_key = f"api:{request.path}:{request.GET.urlencode()}"
            cached = cache.get(cache_key)
            if cached:
                return cached
            
            result = func(request, *args, **kwargs)
            cache.set(cache_key, result, timeout)
            return result
        return wrapper
    return decorator

@api.get("/posts")
@cache_response(timeout=60)
def list_posts(request):
    return list(Post.objects.all())

Common Issues

Issue: Validation Errors Not Showing

Problem: Validation errors return generic 422 without details.

Solution:

# Check error renderer configuration
api = NinjaAPI()

# Default error renderer shows details
# Custom error renderer for more control
def custom_error_renderer(errors):
    return {
        "success": False,
        "errors": [
            {"field": e["loc"][-1], "message": e["msg"]}
            for e in errors
        ]
    }

api = NinjaAPI(error_renderer=custom_error_renderer)

Issue: Authentication Not Working

Problem: request.auth is None in protected endpoints.

Solution:

# Check auth decorator placement
@api.get("/protected", auth=AuthBearer())  # Correct
def protected(request):
    return {"user": request.auth.username}

# vs

@api.get("/protected")  # Wrong - no auth
@auth_required  # This doesn't work
def protected(request):
    pass

Issue: File Upload Size Limit

Problem: Large file uploads fail silently.

Solution:

# settings.py
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024  # 10MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024  # 10MB

# In the endpoint
@api.post("/upload")
def upload_file(request, file: UploadedFile = File(...)):
    if file.size > 10 * 1024 * 1024:
        raise HttpError(413, "File too large")

Issue: CORS Errors

Problem: Frontend can't access API due to CORS.

Solution:

# Install django-cors-headers
pip install django-cors-headers

# settings.py
INSTALLED_APPS = [
    # ...
    'corsheaders',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # Add at the top
    # ...
]

CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "https://myapp.com",
]

# Or for development
CORS_ALLOW_ALL_ORIGINS = True  # Only in development!

Issue: Nested Schema Serialization

Problem: Nested schemas not serializing correctly.

Solution:

# Make sure nested schemas are properly defined
class AuthorSchema(ModelSchema):
    class Config:
        model = User
        model_fields = ['id', 'username']

class PostSchema(ModelSchema):
    author: AuthorSchema  # Must be defined before use
    
    class Config:
        model = Post
        model_fields = ['id', 'title', 'body']

# For many-to-many
class PostWithCategories(ModelSchema):
    categories: List[str]  # Or List[CategorySchema]
    
    class Config:
        model = Post
        model_fields = ['id', 'title']
    
    @staticmethod
    def resolve_categories(obj):
        return [c.name for c in obj.categories.all()]

References

  • Official Documentation: https://django-ninja.dev/
  • GitHub Repository: https://github.com/vitalik/django-ninja
  • Pydantic Documentation: https://docs.pydantic.dev/
  • OpenAPI Specification: https://swagger.io/specification/
  • Django Documentation: https://docs.djangoproject.com/
Django Ninja Skill | Agent Skills