Quick Start Part 2: Models & Schemas
11 minute read
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
nameis the name in its URL, likelobbyin/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. topicis optional.blank=Truelets forms and the admin leave it empty, anddefault=""gives new rooms an empty topic.authoris optional too, because anyone can chat without logging in:AuthMiddlewareStackin yourasgi.pyputs 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 writeuser.chat_messages. Plainmessageswould read as if it had to do with Django’s messages framework, which is also installed.Meta.orderingkeeps 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 thelobbyroom, and everyone after that joins the same row. The consumer keeps the room inself.roomfor as long as the WebSocket is open.self.scope["user"]is the user thatAuthMiddlewareStackfound, the same asrequest.userin 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 theroom_id: intargument, converted and validated like the query parameters on the previous page.- Django’s
get_object_or_404works as usual: a missing room is a404JSON response. - Only the fields in
RoomOutare 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: RoomInmeans “parse the body asRoomIn”. 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, socreate_roomreturns201anddelete_roomreturns204with an empty body.update_roommixes 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.CASCADEonMessage.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.)
For PATCH, where the client sends only the fields that change, use PatchDict[RoomIn]. See Request Body.
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"))
searchandtopicmap toicontainslookups on the room’s name and topic.recentneeds custom logic, so afilter_recentmethod returns aQobject: rooms created in the last week, or, withrecent=false, the ones before that.- Filters left out of the request are skipped, and everything is validated.
?recent=maybegets a422, 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.