django-ninja-aio-crud: Automatic async CRUD APIs from Django models

Hi Django community! :waving_hand:

I’ve been working on a framework to automate REST API boilerplate in Django projects and would love your feedback.

The Problem

Building REST APIs in Django typically requires writing the same patterns repeatedly:

  • Serializer classes for input/output
  • ViewSets or view functions
  • Filtering and pagination logic
  • Authentication setup
  • M2M relationship endpoints

For each model, this means 150-200 lines of mostly boilerplate code.

The Solution: django-ninja-aio-crud

I built a framework that generates complete async CRUD endpoints from Django models with minimal configuration.

Quick Example

# models.py
from ninja_aio.models import ModelSerializer

class Book(ModelSerializer):
    title = models.CharField(max_length=120)
    published = models.BooleanField(default=True)

    class ReadSerializer:
        fields = ["id", "title", "published"]

    class CreateSerializer:
        fields = ["title", "published"]

    class UpdateSerializer:
        optionals = [("title", str), ("published", bool)]

# views.py
from ninja_aio import NinjaAIO
from ninja_aio.views import APIViewSet

api = NinjaAIO()

@api.viewset(Book)
class BookViewSet(APIViewSet):
    pass

Result: 5 CRUD endpoints ready:

  • GET /book/ - List with pagination
  • POST /book/ - Create
  • GET /book/{pk} - Retrieve
  • PATCH /book/{pk} - Update
  • DELETE /book/{pk} - Delete

Visit /api/docs and you’ll see all endpoints with OpenAPI documentation.

Key Features

Core:

  • :white_check_mark: Full async/await support (built on Django Ninja)
  • :white_check_mark: Automatic Pydantic schema generation
  • :white_check_mark: Per-method authentication (auth, get_auth, post_auth, etc.)
  • :white_check_mark: Built-in filtering and pagination
  • :white_check_mark: M2M relationship endpoints with filtering

Advanced:

  • :white_check_mark: Pydantic validators directly on serializer classes
  • :white_check_mark: Lifecycle hooks (before_save, after_save, on_delete)
  • :white_check_mark: Reverse relation serialization
  • :white_check_mark: Custom endpoints via decorators
  • :white_check_mark: Support for existing Django models (Meta-driven Serializer)

Two Patterns for Different Use Cases

Pattern 1: ModelSerializer (new projects)

For new projects, inherit from ModelSerializer:

class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(User, on_delete=models.CASCADE)

    class ReadSerializer:
        fields = ["id", "title", "author"]

    class CreateSerializer:
        fields = ["title", "author"]

Pattern 2: Meta-driven Serializer (existing projects)

For existing Django models, use the Meta-driven approach without changing model base classes:

from ninja_aio.models import serializers

class ArticleSerializer(serializers.Serializer):
    class Meta:
        model = models.Article
        schema_in = serializers.SchemaModelConfig(fields=["title", "author"])
        schema_out = serializers.SchemaModelConfig(fields=["id", "title", "author"])
        schema_update = serializers.SchemaModelConfig(
            optionals=[("title", str), ("author", int)]
        )

# Then use in ViewSet
@api.viewset(models.Article)
class ArticleViewSet(APIViewSet):
    serializer_class = ArticleSerializer

This means you can add REST APIs to existing Django projects without refactoring your models!

Example: Complete Blog API

Here’s a more realistic example with relationships:

from ninja_aio.models import ModelSerializer
from ninja_aio.views import APIViewSet
from ninja_aio.schemas import M2MRelationSchema
from ninja_aio import NinjaAIO

class Author(ModelSerializer):
    name = models.CharField(max_length=200)
    email = models.EmailField(unique=True)

    class ReadSerializer:
        fields = ["id", "name", "email", "articles"]  # articles = reverse relation

    class CreateSerializer:
        fields = ["name", "email"]

class Tag(ModelSerializer):
    name = models.CharField(max_length=50, unique=True)

    class ReadSerializer:
        fields = ["id", "name"]

class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="articles")
    tags = models.ManyToManyField(Tag, related_name="articles")
    is_published = models.BooleanField(default=False)

    class ReadSerializer:
        fields = ["id", "title", "content", "author", "tags", "is_published"]

    class CreateSerializer:
        fields = ["title", "content", "author"]

    class UpdateSerializer:
        optionals = [("title", str), ("content", str), ("is_published", bool)]

api = NinjaAIO()

@api.viewset(Author)
class AuthorViewSet(APIViewSet):
    pass

@api.viewset(Tag)
class TagViewSet(APIViewSet):
    pass

@api.viewset(Article)
class ArticleViewSet(APIViewSet):
    # Add query filtering
    query_params = {
        "author": (int, None),
        "is_published": (bool, None),
        "title": (str, None)
    }

    # Add M2M relationship endpoints
    m2m_relations = [
        M2MRelationSchema(
            model=Tag,
            related_name="tags",
            filters={"name": (str, "")}
        )
    ]

    async def query_params_handler(self, queryset, filters):
        if filters.get("author"):
            queryset = queryset.filter(author_id=filters["author"])
        if filters.get("is_published") is not None:
            queryset = queryset.filter(is_published=filters["is_published"])
        if filters.get("title"):
            queryset = queryset.filter(title__icontains=filters["title"])
        return queryset

    async def tags_query_params_handler(self, queryset, filters):
        if filters.get("name"):
            queryset = queryset.filter(name__icontains=filters["name"])
        return queryset

This creates:

  • Full CRUD for Author, Tag, and Article
  • Nested serialization (articles include author and tags objects)
  • Filtering: GET /article/?author=1&is_published=true&title=django
  • M2M endpoints: GET /article/{pk}/tag?name=python and POST /article/{pk}/tag/ (with {"add": [1, 2], "remove": [3]})

Schema Validators (Pydantic)

You can add Pydantic validators directly on serializer classes:

from pydantic import field_validator, model_validator

class Book(ModelSerializer):
    title = models.CharField(max_length=120)
    description = models.TextField(blank=True)

    class CreateSerializer:
        fields = ["title", "description"]

        @field_validator("title")
        @classmethod
        def validate_title_min_length(cls, v):
            if len(v) < 3:
                raise ValueError("Title must be at least 3 characters")
            return v

    class UpdateSerializer:
        optionals = [("title", str), ("description", str)]

        @field_validator("title")
        @classmethod
        def validate_title_not_empty(cls, v):
            if v is not None and len(v.strip()) == 0:
                raise ValueError("Title cannot be blank")
            return v

    class ReadSerializer:
        fields = ["id", "title", "description"]

        @model_validator(mode="after")
        def enrich_output(self):
            # Transform output data if needed
            return self

The framework automatically collects these validators and applies them to the generated Pydantic schemas.

Authentication Example

Built-in async JWT authentication:

from ninja_aio.auth import AsyncJwtBearer
from joserfc import jwk

class JWTAuth(AsyncJwtBearer):
    jwt_public = jwk.RSAKey.import_key("-----BEGIN PUBLIC KEY----- ...")
    jwt_alg = "RS256"
    claims = {"sub": {"essential": True}}

    async def auth_handler(self, request):
        user_id = self.dcd.claims.get("sub")
        return await User.objects.aget(id=user_id)

@api.viewset(Book)
class SecureBookViewSet(APIViewSet):
    auth = [JWTAuth()]  # All operations require auth
    get_auth = None     # Except list/retrieve (public)
    post_auth = [JWTAuth()]  # Create requires auth

Granular per-method auth control without repeating decorators.

Lifecycle Hooks

Available hooks for custom logic:

class Article(ModelSerializer):
    # ... fields ...

    async def before_save(self, instance, payload, request):
        # Called before any save
        instance.slug = slugify(instance.title)

    async def on_create_after_save(self, instance, payload, request):
        # Called after create
        await send_notification(f"New article: {instance.title}")

    async def custom_actions(self, payload):
        # Handle custom fields from CreateSerializer
        if payload.get("send_email"):
            await send_email_to_subscribers(self)

    async def on_delete(self, request):
        # Called before deletion
        await log_deletion(self.id, request.user)

Quality & Performance

Testing & Quality:

  • 98%+ test coverage via pytest
  • SonarCloud quality gate passing
  • Zero critical security issues
  • Type hints throughout

Compatibility:

  • Python 3.10, 3.11, 3.12, 3.13, 3.14
  • Django Ninja 1.3, 1.4, 1.5
  • Django 4.2+ (async ORM support required)

Performance:

  • Full async/await (no blocking calls)
  • orjson for fast JSON serialization
  • Automated performance benchmarks in CI

Current benchmark results (median):

  • Single object serialization: ~0.5-1ms
  • Bulk serialization (100 objects): ~5-15ms
  • Full CRUD endpoint cycle: ~2-8ms

Installation & Quick Start

pip install django-ninja-aio-crud
# settings.py
INSTALLED_APPS = [
    # ...
    'ninja_aio',
]

# models.py
from ninja_aio.models import ModelSerializer

class Book(ModelSerializer):
    title = models.CharField(max_length=120)

    class ReadSerializer:
        fields = ["id", "title"]

    class CreateSerializer:
        fields = ["title"]

# urls.py
from ninja_aio import NinjaAIO
from ninja_aio.views import APIViewSet
from .models import Book

api = NinjaAIO()

@api.viewset(Book)
class BookViewSet(APIViewSet):
    pass

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

Visit http://localhost:8000/api/docs - your CRUD endpoints are ready!

Resources

Looking for Feedback

I’d genuinely appreciate feedback from the Django community:

Use cases:

  • What scenarios would this be most/least useful for?
  • Are there Django project patterns where this wouldn’t fit?

Features:

  • What’s missing that would make this production-ready for you?
  • Are there common CRUD patterns not covered?

API design:

  • Are the serializer patterns intuitive?
  • Would you prefer different configuration approaches?

Integration:

  • How would this fit into your current Django workflow?
  • Any concerns about mixing this with existing DRF or custom views?

Documentation:

  • What examples or guides would be most helpful?
  • What use cases should be documented better?

Why I Built This

I’ve been building Django APIs for several years, and I found myself writing the same CRUD patterns over and over. Each new model meant:

  1. Creating 3-4 serializer classes
  2. Writing ViewSet or view functions
  3. Configuring the same filters
  4. Setting up the same auth patterns
  5. Adding pagination boilerplate

Django REST Framework is comprehensive but verbose. Django Ninja improved the developer experience significantly, but CRUD still required substantial boilerplate.

I wanted something that:

  • Eliminates repetitive patterns while staying flexible
  • Stays fully async (critical for modern Django)
  • Doesn’t hide or replace Django’s ORM
  • Integrates cleanly with existing projects
  • Provides good performance out of the box

This project is the result. It’s opinionated about CRUD patterns (automatic endpoints, standard schemas) but flexible about business logic (hooks, custom endpoints, query handlers).

Design Philosophy

What this framework does:

  • :white_check_mark: Automates repetitive CRUD boilerplate
  • :white_check_mark: Provides sensible defaults
  • :white_check_mark: Makes common patterns easy

What this framework doesn’t do:

  • :cross_mark: Replace Django’s ORM
  • :cross_mark: Hide complexity behind magic
  • :cross_mark: Force you into specific architectures

You still write Django code. The framework just reduces repetition.

Comparison to Alternatives

vs Django REST Framework:

  • Less verbose (10 lines vs 150+ for basic CRUD)
  • Fully async (DRF has partial async support)
  • Pydantic schemas (instead of DRF serializers)
  • Less customizable than DRF (trade-off for simplicity)

vs Django Ninja (base):

  • Automatic CRUD generation (Ninja requires manual endpoints)
  • Built-in filtering and pagination
  • Schema-on-model pattern
  • Same underlying framework (built on Ninja)

vs FastAPI + SQLAlchemy:

  • Native Django integration
  • Uses Django ORM (async)
  • Django-native patterns
  • Better for Django projects specifically

Limitations & Trade-offs

When to use this:

  • Standard CRUD-heavy APIs
  • New Django projects
  • Microservices with simple data models
  • Rapid prototyping

When not to use this:

  • Highly custom endpoint logic (though hooks may suffice)
  • Complex GraphQL-style nested mutations
  • Projects requiring DRF ecosystem plugins

Trade-offs:

  • Less flexible than writing views manually
  • Adds a layer of abstraction
  • Learning curve for the framework patterns

I believe the trade-offs are worth it for most CRUD use cases, but I’d love to hear if you disagree!

Contributing

The project is MIT-licensed and open to contributions:

  • :bug: Bug reports and feature requests welcome on GitHub
  • :shuffle_tracks_button: Pull requests appreciated (CONTRIBUTING.md in repo)
  • :open_book: Documentation improvements needed
  • :speech_balloon: Real-world usage feedback valuable

If you try it and run into issues or missing features, please open an issue!

Thanks

Thanks for reading this far! I really value this community’s input.

If you try django-ninja-aio-crud, I’d love to hear:

  • What worked well
  • What didn’t work
  • What you’d change
  • Whether it fits your workflow

Happy to answer any questions or discuss the approach in detail.


If you find this useful, consider:

  • :star: Starring the GitHub repo
  • :package: Trying it in a project
  • :speech_balloon: Sharing feedback
  • :shuffle_tracks_button: Contributing improvements