Quick Start Part 2: Models & Schemas

Save chat rooms and messages to the database, then build a CRUD API for rooms with pagination and filtering.

On this page you’ll give your chat rooms and messages a home in the database, then turn the Room model into a complete CRUD API, and add pagination and filtering. Along the way you’ll see how schemas are generated from your models.

The models

Nothing Ninja-specific here, just regular Django models. The original Quick Start has a single Event model. Your chat needs two: a Room, and the Messages posted in it.

In Part 1 of the Channels tutorial, you deleted chat/models.py because the chat didn’t store anything. Create it again, with this code:

# chat/models.py
from django.conf import settings
from django.db import models


class Room(models.Model):
    name = models.CharField(max_length=50, unique=True)
    topic = models.CharField(max_length=200, blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Message(models.Model):
    room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        related_name="chat_messages",
        on_delete=models.SET_NULL,
    )
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at", "id"]

    def __str__(self):
        return f"{self.author or 'anonymous'} in {self.room}: {self.content[:50]}"
  • A room’s name is the name in its URL, like lobby in /chat/lobby/, so two rooms can’t share one: unique=True. It’s also part of the room’s Channels group name (chat_lobby), and group names must be shorter than 100 characters, so 50 leaves plenty of room.
  • topic is optional. blank=True lets forms and the admin leave it empty, and default="" gives new rooms an empty topic.
  • author is optional too, because anyone can chat without logging in: AuthMiddlewareStack in your asgi.py puts an anonymous user in the scope for them. If a user is deleted, their messages stay, with no author (SET_NULL).
  • related_name="chat_messages" lets you write user.chat_messages. Plain messages would read as if it had to do with Django’s messages framework, which is also installed.
  • Meta.ordering keeps every list of messages oldest first, the way a chat log reads.

You also deleted the chat/migrations/ folder in Part 1 of the Channels tutorial. Naming the app tells makemigrations to create it again:

uv run python manage.py makemigrations chat
uv run python manage.py migrate

You should see:

Migrations for 'chat':
  chat/migrations/0001_initial.py
    + Create model Room
    + Create model Message
Operations to perform:
  Apply all migrations: admin, auth, catalog, chat, contenttypes, sessions
Running migrations:
  Applying chat.0001_initial... OK

Your Channels tutorial also deleted chat/admin.py. Create it again, so you can look at rooms and messages in the admin site:

# chat/admin.py
from django.contrib import admin

from .models import Message, Room

admin.site.register(Room)
admin.site.register(Message)

Save messages from the consumer

The ChatConsumer you wrote in Part 3 of the Channels tutorial is asynchronous. As that page’s note said, an asynchronous consumer can still use Django models, through the model methods that start with a, like aget_or_create() and acreate(), as long as it calls channels.db.aclose_old_connections() before it uses the database. Put the following code in chat/consumers.py. The highlighted lines are new:

# chat/consumers.py
import json

from channels.db import aclose_old_connections
from channels.generic.websocket import AsyncWebsocketConsumer

from .models import Message, Room


class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = f"chat_{self.room_name}"

        # Find the room in the database, or create it the first time anyone joins
        await aclose_old_connections()
        self.room, _ = await Room.objects.aget_or_create(name=self.room_name)

        # Join room group
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]

        # Save the message before anyone sees it
        await aclose_old_connections()
        user = self.scope["user"]
        await Message.objects.acreate(
            room=self.room,
            author=user if user.is_authenticated else None,
            content=message,
        )

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name, {"type": "chat.message", "message": message}
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event["message"]

        # Send message to WebSocket
        await self.send(text_data=json.dumps({"message": message}))
  • aget_or_create() returns a (room, created) tuple. The first person to open /chat/lobby/ creates the lobby room, and everyone after that joins the same row. The consumer keeps the room in self.room for as long as the WebSocket is open.
  • self.scope["user"] is the user that AuthMiddlewareStack found, the same as request.user in a view. If you’re logged in, for example at /accounts/login/ from Prep 06, your messages get you as their author. Otherwise they’re saved with no author.
  • The message is saved before it’s sent to the group. If saving fails, nobody sees a message that won’t be there when they reload the page.

Show a room’s history

Saved messages are only useful if you can see them. Update the room view in chat/views.py to load the room’s messages:

# chat/views.py
from django.shortcuts import render

from .models import Room


def index(request):
    return render(request, "chat/index.html")


def room(request, room_name):
    room = Room.objects.filter(name=room_name).first()
    chat_messages = room.messages.all() if room else []
    return render(
        request,
        "chat/room.html",
        {"room_name": room_name, "chat_messages": chat_messages},
    )

The context variable is called chat_messages, not messages. Django’s messages framework already puts a messages variable in every template’s context, and yours would hide it.

The view doesn’t create the room: opening the page isn’t enough to make a room, but connecting the WebSocket is. If the room doesn’t exist yet, there’s no history to show.

In chat/templates/chat/room.html, change only the textarea line, so that it starts with one line per saved message:

<!-- chat/templates/chat/room.html -->
<textarea id="chat-log" cols="100" rows="20">{% for chat_message in chat_messages %}{{ chat_message.content }}
{% endfor %}</textarea><br>

That’s the same format the JavaScript in the page uses when a new message arrives: the message, then a newline. The line break inside the for loop is that newline, so keep it exactly where it is, and don’t add spaces or line breaks anywhere else between the textarea tags. In particular, the {% endfor %} line must start at the very beginning of the line, even though the rest of the file is indented: any spaces before it would end up at the start of every message but the first.

Try it

Make sure Redis is running (see Part 2 of the Channels tutorial), then start the server:

uv run python manage.py runserver

Open http://127.0.0.1:8000/chat/lobby/ 🔗 in two tabs, and send “hello” and “anyone here?” from one of them. Both show up in both tabs, as before. Now reload a tab: the messages are still there.

Go to http://127.0.0.1:8000/admin/chat/message/ 🔗 and log in with your superuser. Your messages are listed there, and the lobby room is under Rooms. Messages you sent while logged in show your username as their author.

Finally, run the Channels tutorial’s tests again, to make sure the chat still works end to end:

uv run python manage.py test chat.tests

The rest of this page is about Django Ninja again. Join two or three more rooms, such as books and random, so the API has a few rooms to work with.

Schemas

A schema describes the shape of data going in or out of your API. Django Ninja schemas are Pydantic models: a class with type-annotated fields.

# locallibrary_config/api.py
from datetime import datetime

from ninja import Schema


class RoomIn(Schema):
    name: str
    topic: str = ""


class RoomOut(Schema):
    id: int
    name: str
    topic: str
    created_at: datetime

RoomIn is what clients send, and RoomOut is what they get back. They’re separate so a client can’t set id or created_at (or, later, fields you’d rather keep private). topic has a default, so it’s optional in RoomIn.

A schema doesn’t have to match a model. You’ll use plain Schema classes for anything that isn’t a table: login payloads, computed results, aggregates.

The same, generated from the model

When a schema mirrors a model, as these do, it only repeats what the model already says. ModelSchema reads the fields from the model instead:

# locallibrary_config/api.py
from ninja import ModelSchema

from chat.models import Room


class RoomIn(ModelSchema):
    class Meta:
        model = Room
        fields = ["name", "topic"]


class RoomOut(ModelSchema):
    class Meta:
        model = Room
        fields = ["id", "name", "topic", "created_at"]

These two classes behave the same as the hand-written ones, with the types and the topic default taken from the model. You also get the model’s constraints: name is limited to 50 characters because of max_length=50. When you add or change a model field, the schema follows.

The rest of this Quick Start uses the ModelSchema versions. You can mix both kinds freely, and a ModelSchema can also declare extra fields of its own (see ModelSchema).

Read: list and detail

Use response= to tell Django Ninja what an endpoint returns:

# locallibrary_config/api.py
from django.shortcuts import get_object_or_404


@api.get("/rooms", response=list[RoomOut])
def list_rooms(request):
    return Room.objects.all()


@api.get("/rooms/{room_id}", response=RoomOut)
def get_room(request, room_id: int):
    return get_object_or_404(Room, id=room_id)
  • You return querysets and model instances directly. Django Ninja reads the fields listed in the schema and serializes them to JSON. There’s no serializer to call.
  • {room_id} in the path becomes the room_id: int argument, converted and validated like the query parameters on the previous page.
  • Django’s get_object_or_404 works as usual: a missing room is a 404 JSON response.
  • Only the fields in RoomOut are sent back, even if the object has more. The schema is also the contract shown in the docs.

GET /api/rooms/1:

{
    "id": 1,
    "name": "lobby",
    "topic": "",
    "created_at": "2026-10-19T14:02:11.482Z"
}

Create, update, delete

A schema-typed argument is read from the JSON request body. The highlighted lines are the ones explained below:

# locallibrary_config/api.py
@api.post("/rooms", response={201: RoomOut})
def create_room(request, payload: RoomIn):
    return Room.objects.create(**payload.dict())


@api.put("/rooms/{room_id}", response=RoomOut)
def update_room(request, room_id: int, payload: RoomIn):
    room = get_object_or_404(Room, id=room_id)
    for attr, value in payload.dict().items():
        setattr(room, attr, value)
    room.save()
    return room


@api.delete("/rooms/{room_id}", response={204: None})
def delete_room(request, room_id: int):
    get_object_or_404(Room, id=room_id).delete()
  • payload: RoomIn means “parse the body as RoomIn”. By the time your function runs, the data is valid and typed.
  • response={201: RoomOut} maps a status code to a schema. With only one code listed, that’s the code every response gets, so create_room returns 201 and delete_room returns 204 with an empty body.
  • update_room mixes a path parameter and a body in one signature. Django Ninja works out where each argument comes from.
  • Deleting a room also deletes its messages, because of on_delete=models.CASCADE on Message.room.

Send a bad body, such as {"topic": 42}, and the error lists every problem at once:

{
    "detail": [
        {"type": "missing", "loc": ["body", "payload", "name"], "msg": "Field required"},
        {"type": "string_type", "loc": ["body", "payload", "topic"], "msg": "Input should be a valid string"}
    ]
}

Open /api/docs again. All five room endpoints are there, with request and response schemas, and you can create a room from the browser with Try it out. A room you create there is a real room: open /chat/<its name>/ and chat in it.

More than one status code

Try creating a room called lobby with Try it out. You get a 500 error, not a helpful answer: the name column is unique, so the database refuses the second lobby with an IntegrityError.

When an endpoint can answer in different ways, list every status code in response and return Status(code, data) to pick one. Replace create_room with a version that creates a room, or reports a clash with an existing one:

# locallibrary_config/api.py
from ninja import Schema, Status


class Conflict(Schema):
    message: str
    existing_id: int


@api.post("/rooms", response={201: RoomOut, 409: Conflict})
def create_room(request, payload: RoomIn):
    existing = Room.objects.filter(name=payload.name).first()
    if existing:
        return Status(409, {"message": "A room with that name already exists", "existing_id": existing.id})
    return Status(201, Room.objects.create(**payload.dict()))

Each response is validated against the schema for its code, and both appear in the interactive docs. See Responses for more.

(Renaming a room to a name that’s taken with PUT has the same problem. Fixing update_room the same way is a good exercise.)

Pagination

A list endpoint that returns every row won’t last long in production. Add one decorator:

# locallibrary_config/api.py
from ninja.pagination import paginate


@api.get("/rooms", response=list[RoomOut])
@paginate
def list_rooms(request):
    return Room.objects.all()

The view still returns a plain queryset. @paginate adds limit and offset query parameters, slices the queryset in the database, and wraps the result:

{
    "items": [{"id": 1, "name": "lobby", ...}],
    "count": 3
}

Page-number and cursor pagination, or your own class, are one argument away. See Pagination.

Filtering

Filters are also a schema: a FilterSchema, read from the query string. Each field maps to an ORM lookup:

# locallibrary_config/api.py
from datetime import timedelta
from typing import Annotated

from django.db.models import Q
from django.utils import timezone
from ninja import FilterLookup, FilterSchema, Query

...


class RoomFilter(FilterSchema):
    search: Annotated[str | None, FilterLookup("name__icontains")] = None
    topic: Annotated[str | None, FilterLookup("topic__icontains")] = None
    recent: bool | None = None

    def filter_recent(self, value: bool) -> Q:
        week_ago = timezone.now() - timedelta(days=7)
        return Q(created_at__gte=week_ago) if value else Q(created_at__lt=week_ago)


@api.get("/rooms", response=list[RoomOut])
@paginate
def list_rooms(request, filters: Query[RoomFilter]):
    return filters.filter(Room.objects.order_by("name"))
  • search and topic map to icontains lookups on the room’s name and topic.
  • recent needs custom logic, so a filter_recent method returns a Q object: rooms created in the last week, or, with recent=false, the ones before that.
  • Filters left out of the request are skipped, and everything is validated. ?recent=maybe gets a 422, not a server error.

Now these all work, and combine with pagination:

/api/rooms?search=lob
/api/rooms?recent=true&topic=books
/api/rooms?recent=true&limit=10&offset=20

See Filtering for combining filters with OR, filtering on related models, and more.

What you have so far

Saved chat messages, plus about 60 lines of code that give you a paginated, filterable, validated and documented CRUD API:

Method Path
GET /api/rooms list, with search, topic, recent, limit, offset
POST /api/rooms create
GET /api/rooms/{room_id} detail
PUT /api/rooms/{room_id} update
DELETE /api/rooms/{room_id} delete

Right now, though, anyone can create or delete rooms, and the API can’t show or post messages. Next: Auth, Routers & Next Steps.

Last modified October 22, 2026: add button label (28b88d0)