Skip to content

Getting Started

A worked tutorial: from a Django model to a validated, serialized, documented boundary. Every transcript below is executed by the test suite.

Install

uv add django-modern-schemas
pip install django-modern-schemas
Requirement Version
Python 3.10 or newer
Django 3.2 or newer
Pydantic 2.13 or newer

Install the Pydantic extras that match the Django fields you use:

pip install "pydantic[email]"     # EmailField -> EmailStr
pip install "pydantic[timezone]"  # timezone-aware datetimes

You do not add anything to INSTALLED_APPS. Schemas are ordinary Python classes.

The models used in this tutorial

models.py
class Category(models.Model):
    name = models.CharField(max_length=100)

    class Meta:
        app_label = 'examples'



class Event(models.Model):
    title = models.CharField(max_length=100)
    category = models.OneToOneField(Category, null=True, on_delete=models.SET_NULL)

    class Meta:
        app_label = 'examples'

    def display_title(self) -> str:
        return f'Event: {self.title}'

Step 1 — Declare a schema

A ModelSchema names its Django model in a nested Config class. Here we expose everything except the relation, which the Relations guide covers separately.

>>> class EventSchema(ModelSchema):
...     class Config:
...         model = models.Event
...         exclude = ['category']

The generated Pydantic fields:

>>> list(EventSchema.model_fields)
['id', 'title']

title is required, and id is not:

>>> EventSchema.model_fields['title'].is_required()
True
>>> EventSchema.model_fields['id'].is_required()
False

Why the primary key is optional here

A schema that you use for both input and output cannot demand a pk that does not exist yet, so the pk is made optional automatically — unless you name it in fields yourself, which reads as a deliberate request for it:

>>> class ExplicitPkSchema(ModelSchema):
...     class Config:
...         model = models.Event
...         fields = ['id', 'title']
>>> ExplicitPkSchema.model_fields['id'].is_required()
True

The full rule is in ModelSchema → The primary key rule.

Step 2 — Validate incoming data

>>> schema = EventSchema.model_validate({'title': 'DjangoCon'})
>>> schema.title
'DjangoCon'
>>> schema.id is None
True

Constraints come from the Django field, so a payload that would not fit the column is caught before any SQL runs. ValidationError.errors() is already in the shape of a JSON error response:

>>> from pydantic import ValidationError
>>> try:
...     EventSchema.model_validate({'title': 'x' * 200})
... except ValidationError as error:
...     print(error.errors()[0]['type'], error.errors()[0]['ctx'])
string_too_long {'max_length': 100}

The rule that fired is max_length=100, which the schema never restated.

Step 3 — Serialize a model instance

The same schema reads a Django instance, because from_attributes is enabled:

>>> event = models.Event(id=7, title='DjangoCon')
>>> EventSchema.model_validate(event).model_dump()
{'id': 7, 'title': 'DjangoCon'}

For a JSON response, use Pydantic's JSON mode:

>>> EventSchema.model_validate(event).model_dump_json()
'{"id":7,"title":"DjangoCon"}'

Step 4 — Persist validated data

create() writes the flat fields through the model's default manager:

>>> created = EventSchema.model_validate({'title': 'PyCon'}).create()
>>> created.pk is not None
True
>>> created.title
'PyCon'

update() writes onto an existing instance and saves it:

>>> _ = EventSchema.model_validate({'title': 'PyCon US'}).update(created)
>>> models.Event.objects.get(pk=created.pk).title
'PyCon US'

See Persistence for partial updates, save(), and the rules around nested schemas.

Step 5 — Publish the contract

model_json_schema() returns a JSON Schema you can hand to OpenAPI tooling:

>>> print(json.dumps(EventSchema.model_json_schema(), indent=2))
{
  "properties": {
    "id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "",
      "title": "Id"
    },
    "title": {
      "description": "",
      "maxLength": 100,
      "title": "Title",
      "type": "string"
    }
  },
  "required": [
    "title"
  ],
  "title": "EventSchema",
  "type": "object"
}

Using it in a Django view

Schemas are plain Pydantic models, so they drop into any view layer:

views.py
import json

from django.http import HttpRequest, JsonResponse
from pydantic import ValidationError

from .models import Event
from .schemas import EventSchema


def create_event(request: HttpRequest) -> JsonResponse:
    try:
        schema = EventSchema.model_validate(json.loads(request.body))
    except ValidationError as error:
        return JsonResponse({'errors': error.errors()}, status=400)

    event = schema.create()
    return JsonResponse(EventSchema.model_validate(event).model_dump(), status=201)


def list_events(request: HttpRequest) -> JsonResponse:
    events = [EventSchema.model_validate(event).model_dump() for event in Event.objects.all()]
    return JsonResponse({'results': events})

Next steps