msgspec and pydantic¶
Four independent integrations use these libraries. Serialization and validation remain CPU work; backend selection does not make them asynchronous. Performance depends on eligibility, payload size and the selected compatibility mode.
| Feature | Contract |
|---|---|
| Serializer backend (section 1) | An existing DRF serializer stays the definition. The msgspec and pydantic backends compile output and recognize a conservative input subset when the result is known to equal DRF's; the python backend compiles output only, without a dependency; otherwise DRF does the work. |
| Schema-first serializers (section 2) | MsgspecSerializer / PydanticSerializer: a Struct or BaseModel is the definition; the library's rules apply, not DRF's. |
| JSON codec (section 3) | MsgspecJSONRenderer / MsgspecJSONParser, independent of the other two. |
| Conversion (section 5) | manage.py aiodrf_convert writes a schema for a serializer, or a serializer for a schema. |
Install django-aiodrf[msgspec] or django-aiodrf[pydantic]. aiodrf imports neither
unless it is used; selecting a backend that is not installed is reported by
manage.py check (aiodrf.E004).
1. The serializer backend¶
AIODRF = {
"SERIALIZER_BACKEND": "msgspec", # "drf" (default), "msgspec", "pydantic", "python"
"SERIALIZER_BACKEND_PARITY": "strict", # or "fast", for output
"SERIALIZER_BACKEND_FALLBACK": "drf", # or "error"
}
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name"]
serializer_backend = "msgspec" # overrides the setting; "drf" opts out
serializer_backend_fallback = "error" # overrides the setting
The serializer object is DRF's throughout: fields, context, errors,
validated_data, data, save(), many=True and schema generation are
unchanged. The backend applies where aiodrf produces the result: generic
views, aio.data / serializer.adata() and aio.is_valid /
serializer.ais_valid(). Reading serializer.data or calling is_valid()
directly is DRF's code.
A serializer is compiled as a whole, per direction, or not at all. When it
cannot be, DRF does the work; with the fallback set to "error",
ImproperlyConfigured names the reason instead: a field the backend cannot
express, a custom list serializer, more field sets than the cache keeps.
Output¶
Compiled in "strict" parity, where the output is identical to DRF's:
ModelSerializerfields backed by concrete model fields of the matching type: strings, integers (DRF 3.17'sBigIntegerFieldtoo, unless coerced to a string), floats, booleans, UUIDs (hex_verbose), dates and times in ISO 8601, choices whose keys have the model field's type. UUIDs, dates and times are output by DRF's own conversion, so a value that is still the text it was set to (Model(code="..."), before the row is read again) comes out unchanged, as DRF outputs it;- datetimes in ISO 8601 and
DecimalFields output as strings (DRF's default,COERCE_DECIMAL_TO_STRING), with DRF's output for any value: datetimes converted to the active time zone (or left naive withoutUSE_TZ), withZfor a zero offset; decimals quantized to their places in the thread's decimal context with the field's digits and rounding, and normalized when the field says so, with the same errors. The time zone and the decimal context are read once per output rather than once per value, and the backend formats datetimes itself. Rare values (a naive datetime to make aware, an offset in seconds, an overflow, a string) and localized decimals are converted by DRF's own code. ADateTimeFieldwith adefault_timezoneof its own, aDecimalFieldthat outputsDecimalobjects and a subclass that overridesquantize()stay on DRF; PrimaryKeyRelatedFieldon a forward foreign key, read from<fk>_id, when the key is a string or an integer;- the column of a forward foreign key itself (
"<fk>_id"inMeta.fields, read with aReadOnlyField, or a declared field), as a field of the key's target type. DRF outputs the value of aReadOnlyFieldor aPrimaryKeyRelatedFieldunchanged: a UUID or a date stays a Python object in.data, an integer in aFloatFieldstays an integer. Such fields compile only when the value is a string, an integer or a boolean; - the primary keys of a many-to-many field (either side) or a reverse foreign
key (
PrimaryKeyRelatedField(many=True), whatModelSerializerbuilds for a many-to-many field), when the keys are strings or integers; SlugRelatedFieldon a forward foreign key, a many-to-many field or a reverse foreign key, when the slug is a column of the related model (not a path such asauthor__name) holding strings, integers or booleans;ModelField, whichModelSerializerbuilds for aGeneratedField, when the value is a string, a number or a boolean;FileFieldandImageFieldon the model's file fields: the file's URL, made absolute with the request in the serializer's context, or its name withuse_url=False. Building a URL can query the storage, so such a serializer is never represented on the event loop in thread mode;- a field whose dotted source follows foreign keys that cannot be null to a
column of the related model (
CharField(source="author.name")). A missing related row givesNone, as in DRF; - a declared string, number, boolean, UUID, date, time, datetime or decimal
field reading a value of the instance alone, which the model's class does
not define: an annotation (
annotate(book_count=Count("books"))) or a value the view set. The value is represented as the DRF field represents it, whatever its type, and a method stored there is called, as DRF calls it. An instance without the value is DRF's, which skips the field; - with
aiodrf.contrib.mongodbinstalled,ObjectIdcolumns through django-mongodb-extensions'ObjectIdFieldand aiodrf'sObjectIdPrimaryKeyRelatedField, single andmany=True; - nested serializers on forward foreign keys, recursively;
- nested
many=Trueserializers on a many-to-many field (either side) or a reverse foreign key, when the list serializer is DRF's own and the child compiles.
msgspec and pydantic serializers output with their own schema: the compiler
neither compiles nor refuses them, whatever SERIALIZER_BACKEND_FALLBACK says.
Related managers are read like DRF's ManyRelatedField and ListSerializer
read them: manager.all(), the prefetched objects when the queryset
prefetched them (in the prefetch's order), a query otherwise, made where DRF
would make it (the worker thread in thread mode). Foreign keys in a dotted
source are read as DRF reads them, joined by select_related() or queried.
The objects, their order and the number of queries are DRF's:
select_related() and prefetch_related() matter exactly as much as they do
for DRF.
A compiled field outputs None as null even when the model field is not
nullable, as DRF does.
The compiled class represents instances of the serializer's model (a list or
queryset of them for many=True): they have every attribute it reads.
Anything else, such as a dict or the rows of QuerySet.values(), is DRF's:
DRF outputs a field's default, or skips a field that is not required, when
the key or attribute is missing. With
serializer_backend_fallback = "error" such a source raises. In "fast"
parity a plain Serializer reads any object but a mapping.
An instance can still hold what the compiled class cannot read: a forward
relation whose row does not exist (a db_constraint=False key, a row deleted
meanwhile), a deferred field of a deleted row, or the related manager of an
unsaved instance, which DRF outputs as None or []; a value of another type
than the model field's, set by the project, is converted by DRF's field (5 in
a CharField is "5"). In "strict" parity, when the compiled class fails
with its backend's validation error, DRF represents the source, with DRF's
output or DRF's exception, whatever serializer_backend_fallback says: what
one instance holds is not a serializer that cannot be compiled. The backends
take any exception raised while reading an attribute (a database error, a
query a profiler refuses) for a missing attribute; DRF's read raises it as
itself. DRF reads the source again, so strict parity compiles only
reads of Django's code: Django's descriptors of Django's field classes, and
relations read lazily through managers whose get_queryset() and all() are
Django's. A model field class of the project's or a third party's, an attribute
the model defines over a field, or such a manager keeps the serializer on DRF.
The second read repeats at most Django's query of a lazy relation. "fast"
parity does not read again: the backend's error (msgspec.ValidationError,
pydantic.ValidationError, aiodrf.contrib.compiler.UnreadableValue) is raised where DRF outputs None, converts the
value or skips a missing attribute.
Everything else keeps the serializer on DRF: SerializerMethodField, a nested
many=True or PrimaryKeyRelatedField(many=True) on anything but a related
manager (a list property, the list a Prefetch(to_attr=...) sets: DRF skips a
field whose attribute is missing, the compiled class cannot), embedded models
and arrays of django-mongodb-backend, a dotted source through a nullable
foreign key (DRF then skips the key or outputs the field's default), through a
related manager or to a property, source="*", custom fields, an overridden
to_representation or get_attribute, or one assigned to the serializer, the
list serializer or a field instance, a list serializer with its own
to_representation (at the top or nested), a nested serializer whose
Meta.model is not the related objects' model, fields without a model field,
choices with keys of another type than the model field's, and JSON (DRF leaves
values such as Decimal or tuples to its encoder). With msgspec, several
fields may read the same attribute or relation: it is read once and converted
for each of them. "fast" additionally compiles DecimalFields that output
Decimal objects (as str(value), not quantized), fields of a plain
Serializer and JSON fields; its output can differ from DRF's for these.
Which compiled class a serializer uses is found in one of two ways:
- By class, when its fields are a function of its class: the class and
every nested serializer it declares build their fields with DRF's code
alone (no
__init__,get_fields(),get_field_names(),build_*()orbind()of the project's, noMeta.depth, no model field whose building runs the project's code (implementation guide, section 3), only DRF's own field classes declared, children of collections and to-many relations included), and the instance was created with the usual arguments (instance,data,context,partial) and has nothing of the class shadowed: no fields read yet (they may have been edited), no method assigned. Such a serializer is analyzed once per class and later instances build no DRF field at all; one that does not compile is not analyzed again either. Changes made to a static serializer instance are still respected. After validation its fields exist on the instance; a generic view'screateandupdatestill use the class's compiled class when the view built, validated and saved the serializer with framework code alone (noget_serializer*orperform_*of the project's) and the serializer's class defines no methods, since nothing could have edited the fields. - By field set otherwise: fields removed per instance and changed field options are part of the cache key, so a serializer with dynamic fields compiles one variant per field set, at most 32 per class, and DRF serves the rest. This builds the instance's fields.
The backend and parity are part of both keys; compiled classes are dropped
when REST_FRAMEWORK changes, which the analysis reads. A class with its own
to_representation is refused without reading the instance's fields, also
when they exist already, as after validation.
aio.data on a model instance usually represents it in a worker thread
(REPRESENTATION_MODE = "thread"), since reading an attribute may query.
When the class's encoder reads only columns of the model, and the instance
has each of them loaded (none deferred), nothing can query and the output is
produced on the event loop, without the hop; relations and nested
serializers keep it.
The python backend¶
"SERIALIZER_BACKEND": "python" compiles output with the same analysis, the
same strict parity and the same declines as msgspec and pydantic, into plain
Python readers: one per field and a dict per instance, without either library.
It accepts what the msgspec backend accepts for each field type; a value of
another type (a string set on an integer field) or an attribute it cannot
read makes DRF represent that source. Input is validated by DRF, as without a
backend.
On a ModelViewSet of a five-field model, one request of list (20 rows) and
retrieve took 3.55 M and 2.05 M instructions with it, against 5.57 M and
3.12 M with DRF's serializer and 3.40 M and 2.04 M with msgspec, the field
cache on. Its readers are as fast as msgspec's for one object and within
about 10 % on lists; where msgspec is not wanted it gives the compiled
output's gain without the dependency.
Datetime contracts¶
Compiled DRF serializers and explicit vendor schemas have different contracts. The compiler's supported datetime path calls DRF timezone/format conversion; unsupported datetime options are left to DRF. Time zone overrides, DST transitions, naive values, fractional seconds and out-of-range values produce DRF's output with both backends.
Pydantic and msgspec schemas used directly follow their library's rules for
datetime input and output, not Django's time zone conversion: datetime objects,
ISO strings, invalid dates and single or list output behave exactly as with the
library itself. Pydantic's strict validation of Python objects rejects ISO
strings in a dictionary produced by a JSON parser; msgspec's rules differ. If a
schema should accept those strings, set Meta.strict = False on it and test the
resulting coercion of its other fields. The adapter never changes strictness or
falls back to DRF validation on its own.
Input¶
aiodrf/contrib/inputs.py compiles a recognizer, not a second validator.
It accepts only input for which DRF would return exactly the same
validated_data: a JSON dict (or list for many=True) whose values
already have the types DRF converts to, strings already trimmed, numbers
within their limits. Whatever it does not accept, DRF validates as usual, so
every coercion ("12" for an integer), every error
message and code is DRF's. Both backends use this analysis, selected by
SERIALIZER_BACKEND or Meta.serializer_backend. Both backends recognize
canonical UUID strings, ISO dates and naive ISO times. Pydantic uses narrow
text conversions for these fields before strict validation; unrelated coercions
remain disabled. A time is read as Django reads it (time.fromisoformat),
digits beyond microseconds truncated; msgspec's own parser would round them,
up to the next minute or midnight. This does not change schema-first serializers' strictness.
The pydantic input path does not require msgspec to be installed.
Input rejection always falls back to DRF, including when the output
compilation policy is SERIALIZER_BACKEND_FALLBACK = "error". That setting
governs output eligibility; a recognizer declining a value is not a validation
error. Schema-first serializers still use their schema's own validation rules.
A serializer takes part when nothing but these field classes decides the
result, by exact class: BooleanField, IntegerField, FloatField,
CharField, ChoiceField (string or integer keys), UUIDField, DateField
and TimeField (ISO 8601 input only), nested serializers and nested many=True, with
required, allow_null, allow_blank, constant defaults, source
(dotted too), partial=True, and min/max length and value validators with
constant limits (several of one kind combine to the stronger one, as DRF
runs them all; allow_blank keeps the empty string and nothing else exempt
from min_length). It does not when there is a validate() or
validate_<field>(), an async member, a serializer-level validator
(unique-together), a relation, a subclassed field, a callable default or
limit, source="*", or any other validator (UniqueValidator,
RegexValidator). Form input (QueryDict) always goes to DRF.
ListField, DictField and HStoreField are recognized recursively when their
children satisfy the same rules. Child nullability, validation bounds and string
rules remain effective; changing a child or assigning a validation hook cannot
reuse an incompatible cached plan. Untyped containers retain DRF validation.
Times with offsets retain DRF validation because Django treats time strings and
Python time objects differently. See the type coverage matrix
for intentional fallbacks and schema-native alternatives.
Both properties are verified against DRF with generated input and combinations of field options and validators: whatever is recognized, DRF accepts, with the same values and types. Backend model instances and custom container objects are not recognized as JSON input.
Measuring compilation¶
Measure cold compilation, warm output and fallback-heavy inputs separately. A static serializer's plan can be reused without reconstructing its fields; dynamic fields and custom hooks still require inspection. Fallback has a cost, so compiler eligibility is not itself evidence of an endpoint improvement. The performance guide describes suitable measurement tools.
Compilation diagnostics¶
python manage.py aiodrf_inspect_serializers [--backend msgspec|pydantic] [--parity strict|fast] [--format text|json]
lists the serializers of the project's API views with, for each direction,
compiled or DRF: <reason> for the selected installed backend. It
instantiates serializers without a request, so one whose fields depend on
context is reported as it looks without it, and one that cannot be
instantiated is listed as such; its constructor and field-building hooks
must be safe to run without a request. No request values are included.
A schema serializer (MsgspecSerializer, PydanticSerializer) is reported
as schema: msgspec validates and represents it (code schema_serializer):
its schema does the work in both directions, not the backend. A
SchemaViewMixin view is inspected through the serializer its
input_schema/output_schema pair builds, and a bare Struct or model set as
serializer_class through the class it is adapted to.
With --format json, input and output eligibility are reported separately.
Eligibility means that a recognizer can be built, not that it will accept
every value. Every record has serializer (a dotted path, or null),
inspected and usages (the endpoints: path, method, action, and a
note when the view's get_serializer_class() may choose another serializer
at request time). A record with inspected: false has a reason: the
serializer could not be instantiated without a request, its fields or its
analysis failed without one (a get_fields() reading context["request"];
the reason starts with "could not be analyzed"), the view chooses its
serializer in get_serializer_class(), a generic view declares none, or the
view is not a generic view. Inspected records include scope: "instance",
the backend and parity, and per direction eligible, code and reason.
The report is static: it says what could compile, not what requests did
(a serializer instance can still fall back to DRF, for example when a method
is assigned to one of its fields). Codes include
eligible, custom_hook, custom_validator, async_validation,
dynamic_default, unsupported_source, unsupported_relation,
custom_list, model_required, backend_not_installed (the selected
backend is missing: input is not inspected, output only for what keeps it on
DRF) and the conservative unsupported_field category; consumers should
branch on the code, not parse the reason.
compiler.report_details() and inputs.report_input_details() return the
same structured decision for one serializer instance; the older report()
and report_input() return a reason or None.
2. Schema-first serializers¶
Install django-aiodrf[pydantic] or django-aiodrf[msgspec]. Define native
pydantic.BaseModel or msgspec.Struct classes; no separate field declarations
or DRF-to-schema compilation are required. This mode delegates input conversion
and output representation to the selected library. It has no DRF validation
fallback and is distinct from compiling an existing DRF serializer.
from aiodrf.contrib.msgspec import MsgspecSerializer
class BookSerializer(MsgspecSerializer):
class Meta:
schema = BookSchema # a msgspec.Struct
# or: input_schema = BookIn; output_schema = BookOut
model = Book # optional: create()/update() write its fields
strict = True # False: accept "12" for an int, like DRF
PydanticSerializer is configured the same way. A bare Struct or model
class can be a view's serializer_class; aiodrf wraps it (one serializer
class per schema, at most 1024 kept). These are DRF serializers, so generic
views, many=True, OPTIONS metadata and drf-spectacular work, but the
validation rules, coercions and messages are the library's:
validated_datais a dict of the schema's fields. Nested values stay Struct or model instances;create()/update()withMeta.modelassign the fields as given (a foreign key asauthor_id) and set to-many relations (a list of primary keys or instances) after the save, as DRF'sModelSerializerdoes; nested writes are not handled.- msgspec stops at its first error (code
invalid, orrequired); pydantic reports all of them with its own codes. Both arrive as DRFValidationErrortrees, lists keyed asLIST_SERIALIZER_ERRORS_AS_DICTsays. The errors of an array input itself (anarray_likeStruct, a listRootModel) are keyed by index on every DRF version, since a serializer's errors are a dict. .fieldsare read-only DRF fields describing the output schema, forOrderingFilter, metadata and the browsable API. DRF's HTML form skips read-only fields, so the browsable API offers its raw-data form only. Posted form data (QueryDict) is validated with lenient coercion, and collection fields keep repeated values.- What a schema accepts is the backend's, not DRF's fields'. A
floatfield accepts1e400(infinity) under both backends, and pydantic also accepts the strings"nan"and"inf"; astrfield accepts a lone surrogate ("\ud800"). DRF'sFloatFieldandCharFieldrefuse these with a 400; a schema lets them through validation, and DRF's renderer then fails on them (a 500). Refuse them in the schema:allow_inf_nan=Falsein a pydantic model'smodel_config,Annotated[float, msgspec.Meta(ge=..., le=...)]in a Struct.MsgspecJSONParserrefuses non-finite numbers and lone surrogate escapes when it parses the body (section 3). - The two backends accept different input for the same annotation, so the same body can be a 400 under one and a 200 under the other:
| Input | msgspec | pydantic |
|---|---|---|
UUID as {12345678-...} or urn:uuid:12345678-... |
refused | accepted |
bytes as "YQ" |
refused (not valid base64) | accepted, as the bytes of the text |
float as "nan" |
refused | accepted |
Output differs too: a timedelta of one hour is "PT3600S" under msgspec
and "PT1H" under pydantic (DRF's DurationField writes "01:00:00").
In views¶
Three ways, from least to most explicit:
serializer_class = BookSchemaon a generic view: the bare Struct or model validates and represents.aiodrf.contrib.typed.SchemaViewMixinwithinput_schemaandoutput_schema: request bodies are validated with one, responses represented with the other (either alone does both). In a generic view the pair'screate()/update()writequeryset.model: the view'squerysetattribute, given to the class or toas_view(queryset=...). Aget_queryset()override does not change the model written.- A
MsgspecSerializer/PydanticSerializersubclass, forMetaoptions (strict,partial_schema).
from aiodrf.contrib.typed import SchemaViewMixin
class BookViewSet(SchemaViewMixin, viewsets.ModelViewSet):
queryset = Book.objects.all()
input_schema = BookIn
output_schema = BookOut
def perform_create(self, serializer):
book_in = serializer.validated_object # a BookIn
serializer.save(owner=self.request.user)
class NewBook(SchemaViewMixin, APIView):
input_schema = BookIn
output_schema = BookOut
async def post(self, request):
body = await self.aget_validated_body() # a BookIn; DRF's 400 if invalid
book = await Book.objects.acreate(**body.model_dump())
return self.schema_response(book, status=201)
validated_data stays DRF's dict, so save() and perform_create work
unchanged; serializer.validated_object is the input schema's instance (the
partial schema's for PATCH). get_validated_body() is the synchronous form
for synchronous handlers. schema_response(data, many=False, **kwargs)
represents with the output schema and returns a Response; it reads model
instances by attribute on the calling thread, so relations it reads must be
loaded. drf-spectacular documents the request body from input_schema and
the response from output_schema, for generic views and for APIViews.
The static serializer is resolved when the URL is built (as_view): the
bare schema or the pair becomes its serializer class then, and a request
costs one dictionary lookup. Input
and output schemas must come from one library. A serializer that
get_serializer_class() chooses per request is resolved when the view
builds it.
Native validation and serialization hooks¶
Pydantic field/model validators, field/model serializers, aliases, computed
fields and configuration belong on the BaseModel. The serializer passes its
DRF context to Pydantic validation and serialization, including many=True
and partial output. A generic view supplies request, view and format;
direct serializer construction can supply application values:
from pydantic import BaseModel, FieldSerializationInfo, field_serializer
from aiodrf.contrib.typed import adapt
class Price(BaseModel):
amount: int
@field_serializer("amount")
def display(self, value: int, info: FieldSerializationInfo) -> str:
return f"{info.context['currency']} {value}"
serializer = adapt(Price)(data={"amount": 20}, context={"currency": "EUR"})
await serializer.ais_valid(raise_exception=True)
data = await serializer.adata() # {"amount": "EUR 20"}
Context stays on the serializer's adapter; shared schema caches do not retain
requests. Construct one serializer per operation. Pydantic's own output rules
apply: for example, extra="allow" retains extra input values in
validated_data, while Field(exclude=True) excludes an output field. Review
extra-field policies before accepting data passed to create() or update().
Msgspec custom types use the following optional Meta callables:
| Option | Signature | Purpose |
|---|---|---|
dec_hook |
(target_type, value) -> object |
Convert a custom input type; raise ValueError for invalid input |
enc_hook |
(object) -> JSON-compatible value |
Represent a custom output type |
schema_hook |
(target_type) -> dict |
Describe the custom type for JSON Schema/OpenAPI |
All default to None. Hooks are serializer-local configuration, not global
msgspec patches. A hook must reject types it does not handle. The same conversion
hooks participate in nested output projection, which keeps subclass-only fields
out of a declared base schema's response. Such projection can invoke hooks more
than once; keep them deterministic and free of side effects.
class ReferenceSerializer(MsgspecSerializer):
class Meta:
schema = BookReference
dec_hook = decode_reference
enc_hook = encode_reference
schema_hook = reference_schema
The typed schemas example includes
complete hook implementations, Pydantic context, request errors and Swagger.
Unsupported custom types fail explicitly; aiodrf does not silently change to
DRF validation. schema_hook must describe the actual wire value—it does not
implement conversion or validation.
Root values and execution policy¶
PydanticSerializer preserves a RootModel's array or scalar output and a
model_serializer's return shape. Msgspec array_like=True Structs retain array
output. validated_data remains attribute-keyed ({"root": ...} for a
RootModel); use validated_object when application code needs the native object.
For many=True, DRF returns a list of attribute mappings; it does not expose a
list-level validated_object. RootModels require an explicit partial_schema
because field-level PATCH has no general meaning for a root value.
The normal path still parses the request body, validates a native schema,
produces JSON-compatible Python values, and renders a DRF response. It does not
promise a zero-copy model_validate_json/encoder pipeline. DRF negotiation,
permissions, exception handling and response hooks remain active.
Options specific to a vendor's JSON encoder are not automatically renderer
options; test the final response bytes with the selected DRF renderer. Output
conversion may run schema validators again, especially for mappings and list
output. Validators and conversion hooks should not perform side effects.
Native validators are synchronous vendor callbacks, not async hooks. Await
external I/O in the view or an aiodrf serializer hook. Async operations keep
unclassified callbacks in Django's thread-sensitive worker by default. Only
select VALIDATION_UNKNOWN="inline" or REPRESENTATION_MODE="inline" for
application code known not to block or load lazy relations. A thread boundary
protects loop responsiveness but does not accelerate GIL-bound Python work.
Msgspec value extraction uses its shallow structs.asdict() API. Stable Struct
metadata and nested-output classification share a bounded cache; bound DRF fields
remain independent per serializer. Pydantic reuses a bounded cache of list
TypeAdapters. Neither cache stores request data. Metadata is for classes fixed
at application startup; dynamically rebuilding an already-adapted schema is not
a supported cache-invalidation mechanism.
Warm validation also avoids constructing synthetic read-only fields just to
collect defaults they do not define. Materialized or customized fields retain
DRF's default handling; this optimization does not suppress user validators.
Relationship to third-party adapters¶
drf-pydantic generates DRF fields
from Pydantic model declarations. Its optional Pydantic validation runs after
DRF field validation. That contract is useful when a project needs generated
DRF serializers; it is different from delegating validation and representation
directly to Pydantic. aiodrf therefore does not add it as a dependency of the
native serializer path.
django-msgspec-field
provides schema-backed Django model/form fields and DRF field/parser/renderer
integrations. That persistence-oriented scope does not replace aiodrf's existing
whole-serializer, async operation and drf-spectacular contracts. Native contrib
adapters call msgspec directly rather than introduce another adapter layer.
These are design distinctions, not universal speed comparisons or support
guarantees for third-party packages.
Allowed serializer backends¶
AIODRF = {"ALLOWED_SERIALIZER_BACKENDS": ["drf", "pydantic"]} # default: all three
"drf" is a DRF serializer (compiled by SERIALIZER_BACKEND or not),
"msgspec" a Struct or MsgspecSerializer, "pydantic" a model or
PydanticSerializer. A view whose serializer or schemas are of another kind
fails when its URL is built, with ImproperlyConfigured naming the view;
manage.py check lists every such view at once (aiodrf.E005), and a
serializer chosen per request is checked when it is built. The setting does
not affect SERIALIZER_BACKEND, which only decides how DRF serializers are
executed.
PATCH¶
For partial=True a schema with every field optional is derived from the
input schema, but only when nothing is lost by that: a derived class runs
none of the schema's own validation. With a msgspec __post_init__, or
pydantic validators, model_post_init or validate_default (in the model's
config or on one field), set Meta.partial_schema; without it a partial
update raises ImproperlyConfigured before anything is validated. Rules that
need the complete object belong in the view, after the stored state and the
patch are combined. Compiled DRF serializers (section 1) use DRF's partial
semantics and need none of this.
A derived msgspec schema keeps the Struct's names, unknown-field policy and
tag (tag, tag_field): a wrong tag is rejected as in full validation. An
array_like Struct needs Meta.partial_schema, since a shorter array cannot
say which fields it leaves out.
.data of partial input before a save holds the given fields only,
represented by the output schema: pydantic field serializers, serialization
aliases and excluded fields apply, and computed fields are left out. An output
model with a model_serializer, or an array_like output Struct, cannot
represent part of an object and raises TypeError; represent the saved
instance instead.
pydantic aliases¶
Form QueryDict input preserves repeated values for collection fields with
string aliases, string members of AliasChoices, and single-segment
AliasPath aliases. Population by the field name follows the model's own
populate_by_name / validate_by_name configuration; aiodrf does not enable it.
Nested alias paths are not reconstructed from flat form keys. They produce the
backend's normal missing-field validation result (or remain omitted in PATCH),
not an exception while inspecting aliases. JSON mappings keep normal pydantic
alias-path behavior. Msgspec fixed-length tuple fields also preserve repeated
form values.
Output uses serialization aliases (by_alias=True) in single objects, lists
and the synthetic .fields, because that is what the response schema
documents; input uses pydantic's validation schema and aliases. Test wire names explicitly
when migrating a model that declares a serialization_alias.
OpenAPI¶
With drf-spectacular installed, request bodies are documented from the input
schema (an explicit Meta.partial_schema for PATCH) and responses from the
output schema. Components are identified by their shape, so a pydantic model
that validates and serializes differently (a serialization_alias) is
documented as two components, Item for responses and ItemRequest for
requests, nested or not; two different classes that share a name are reported
by spectacular. pydantic and
msgspec write JSON Schema 2020-12, which OpenAPI 3.1 is; for OpenAPI 3.0
(spectacular's default OAS_VERSION) the schemas are rewritten to its
dialect: nullable, boolean exclusiveMinimum/exclusiveMaximum, enum
for const, and a tuple's items as any of its types (with a warning). A
generic model's component is named as pydantic names it (Page_int_).
See the ecosystem guide.
3. JSON renderer and parser¶
MsgspecJSONRenderer renders on the event loop after the payload check
DRF's JSONRenderer gets (implementation guide section 5): data made of plain
values renders inline, anything its encoder hook would evaluate (a lazy
translation, a QuerySet) renders in the request's thread. msgspec Structs
and dataclasses in the data are not plain values and cost that one hop.
It differs from DRF's encoder for these values:
| Value | MsgspecJSONRenderer |
DRF's JSONRenderer |
|---|---|---|
timedelta(hours=1) |
"PT3600S" (ISO 8601 duration) |
"3600.0" |
b"ab" |
"YWI=" (base64) |
"ab"; an error for bytes that are not UTF-8 |
float("nan"), float("inf") |
null |
an error |
Decimal("1.50"), Decimal("2"), Decimal("-0"), Decimal("1E+30") |
1.50, 2, -0, 1E+30 |
1.5, 2.0, -0.0, 1e+30 |
1e300, 1e-7 |
1e300, 1e-7 |
1e+300, 1e-07 |
time(1, tzinfo=UTC) |
"01:00:00Z" |
an error |
Each is valid JSON of the same value, except the rows where DRF raises.
Data msgspec cannot encode as DRF does goes through DRF's renderer, so its
bytes, or its error, are DRF's: a dict with a True, False or None key
({"true": 1}), and a Decimal NaN or infinity (DRF's ValueError, where
msgspec would write a bare NaN token). Indented output (the browsable API,
; indent= in Accept) goes through DRF's renderer. For other data, the
output is identical to DRF's.
MsgspecJSONParser decodes with msgspec and reports malformed input as
DRF's ParseError. Unlike DRF's own JSONParser it is not declared pure,
so the body is parsed in the request's thread. It refuses what RFC 8259
does not allow and DRF's parser accepts: a number out of the float range
(1e400) and a lone surrogate escape ("\ud800"). Both are then a parse
error ({"detail": "JSON parse error - ..."}) where DRF's parser passes the
value on and the field answers ({"a": ["A valid number is required."]});
a body with such a value in a field the serializer ignores is refused
whole.
4. Rolling out¶
Set Meta.serializer_backend on the serializers of list endpoints first and
run aiodrf_inspect_serializers to see which compile. Use
serializer_backend_fallback = "error" in tests for serializers that must
not fall back silently. serializer_backend = "drf" on a serializer is the
rollback. Compare responses before and after: in "strict" parity they must
be identical, in "fast" they differ where section 1 says.
5. Converting between serializers and schemas¶
manage.py aiodrf_convert writes source code for the other side, to start a
migration or to keep a schema next to a serializer:
python manage.py aiodrf_convert app.serializers.BookSerializer --to pydantic
python manage.py aiodrf_convert app.serializers.BookSerializer --to msgspec --output app/schemas.py
python manage.py aiodrf_convert app.schemas.BookIn --to drf --name Book
A serializer is instantiated without a request and read from its fields;
a pydantic model or msgspec Struct is read from its class. Nested
serializers and models become classes of their own, written before the
class that uses them. A serializer with read-only or write-only fields
becomes a BookIn and a BookOut class, ready for SchemaViewMixin's
input_schema and output_schema. The output is formatted as
ruff format would format it.
Field types and the options that have an equivalent are converted:
lengths, bounds, patterns, choices, allow_null, constant defaults,
required=False (T | None = None for pydantic, with a comment, and
msgspec.UNSET for msgspec), wire names (source=). Everything else is
kept visible, not guessed: the field becomes Any (or
serializers.JSONField()) with a # TODO(aiodrf_convert): ... comment.
The same comment lists validate_<field>(), validate(), every validator
a field or serializer carries beyond what its options build (explicit
validators=, a model's validators, the unique-together validators
ModelSerializer derives), pydantic validators and other Annotated
metadata that is not a constraint (AfterValidator, Strict,
msgspec.Meta(tz=...)), and __post_init__, which are code and are not
converted. A blank-able CharField accepts "" whatever its pattern
(the pattern gains ^$|) or min_length (noted, not converted). A field
pydantic would treat as private (_id) is renamed (id_) with its name as
the alias.
The generated classes accept and reject the same canonical JSON input as
the original. The libraries still
coerce differently: DRF accepts "12" for an integer and strips
whitespace from CharFields; strict msgspec does neither. Read the output
before using it.