DRF serializers, pydantic-core inside.
2-3x faster
.dataon existing endpoints. One line added.
Drop FastSerializerMixin into your existing serializer and .data
switches to pydantic-core's Rust JSON encoder. No rewrite. Same DRF
surface.
from rest_framework import serializers
from drf_fastserializers import FastSerializerMixin, FastJSONRenderer
class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Txn
fields = ["id", "name", "amount", "txn_date"]
class TxnListView(ListAPIView):
serializer_class = TxnSerializer # unchanged
renderer_classes = [FastJSONRenderer] # add this
queryset = Txn.objects.all()That's the migration. If TxnSerializer translates cleanly the endpoint
gets the speedup on the next request. SerializerMethodFields with a
-> T return annotation are translated automatically; un-annotated
getters work but render through a slower pydantic path until you add
one. If translation truly fails (a custom Field with no scalar
mapping), you get a one-time warning and .data falls back to standard
DRF. The endpoint keeps working either way.
Full table
21,393 synthetic rows, ~3 MB JSON, 5 runs each. Python 3.12, pydantic 2.13, DRF 3.17.
| Strategy | median_ms | min_ms | speedup |
|---|---|---|---|
DRF Serializer (stock) |
96 | 96 | 1.00x |
| drf-fastserializers (mixin) | 37 | 35 | 2.64x |
| drf-fastserializers (native) | 36 | 34 | 2.65x |
Raw dict via JSONRenderer (reference floor, no validation) |
20 | 20 | 4.80x |
Speedup is anchored on stock DRF. Reproduce on your hardware:
uv run python -m benchmarks.bench # text output
uv run python -m benchmarks.plot # regenerate docs/bench.svgbenchmarks/bench.py ships with the repo and uses synthetic data only.
Real-world gaps widen further on ModelSerializer paths (because of
ORM hydration overhead) and on payloads with nested models. In
production workloads we've seen 3-4x speedups on ModelSerializer
endpoints.
uv add drf-fastserializers
# or
pip install drf-fastserializersRequires Python 3.12+, pydantic 2.7+ (v3 supported), DRF 3.14+.
| stock DRF | drf-pydantic | django-ninja | drf-fastserializers | |
|---|---|---|---|---|
| Drop into existing DRF generics | ✅ | ✅ | ❌ | ✅ |
| Rust JSON encode (pydantic-core) | ❌ | ❌ | ✅ | ✅ |
| Migrate one endpoint at a time | ✅ | ✅ | ❌ | ✅ |
| Keeps DRF auth / perms / throttling | ✅ | ✅ | ❌ | ✅ |
| Strictly typed schemas | ❌ | ✅ | ✅ | ✅ |
| No serializer rewrite required | ✅ | ❌ | ❌ | ✅ |
drf-pydantic generates DRF serializers from pydantic models, which
keeps DRF in the request path and gives no speed win. django-ninja
replaces DRF wholesale. drf-fastserializers swaps only the encoder
inside DRF, so you keep the rest of your stack and migrate per
endpoint.
from drf_fastserializers import FastSerializerMixin
class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Txn
fields = ["id", "name", "amount", "txn_date"]FastSerializerMixin must come first in the MRO. On the first
.data access it translates the DRF field list into a pydantic schema,
caches it per class, and switches .data to the Rust path. many=True
is handled via a FastListSerializer wrapper installed automatically.
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"drf_fastserializers.FastJSONRenderer",
],
}FastJSONRenderer subclasses JSONRenderer and falls back to stock
encoding for error responses, hand-rolled dicts, the browsable API, and
anything else it doesn't recognize. Safe as a project-wide default. Set
renderer_classes per view if you want to roll it out gradually.
Bundle-style endpoints often return multiple serialized payloads under
one dict. The renderer handles that natively — every value that is a
FastPayload goes through pydantic-core (Rust), everything else
through DRF's stock JSONEncoder. Key order is preserved.
class WorksheetBundleView(APIView):
renderer_classes = [FastJSONRenderer]
def get(self, request, uid):
ws = Worksheet.objects.get(uid=uid)
return Response({
"worksheet": FastWorksheetHeader.drf(instance=ws).data,
"transactions": BundleTxnRow.drf(instance=ws.transactions.all(), many=True).data,
"balances": BundleBalanceRow.drf(instance=ws.balances.all(), many=True).data,
"summary_rows": BundleSummaryRow.drf(instance=ws.summary_rows.all(), many=True).data,
"meta": {"as_of": now()}, # plain value: stock JSON encoder
})Pagination envelopes flow through the same path with no special
configuration. Indented output (?format=json&indent=2) falls back to
a materialized render — the Rust encoder is compact-only — so leaving
COMPACT_JSON at its default is what unlocks the speedup.
Auto-translated. The bound get_* method runs once per row at validate
time against the source object (Django model, dict, ...), and the
result lands in a regular pydantic field that the Rust render path
encodes.
class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
formatted_amount = serializers.SerializerMethodField()
def get_formatted_amount(self, obj) -> str:
return f"${obj.amount:,.2f}"Add the -> T return annotation. Without it the field falls back to
Any (the field still renders correctly, but pydantic's Rust-side type
validation is bypassed; you'll see a one-time warning).
SMFs that hit the ORM remain your responsibility — the auto path gives you the field, not the query plan. Prefetch / annotate at the queryset level to avoid N+1.
If the result really can't be derived from the source object, drop the
SMF on the way through from_drf and replace it with a pydantic
@computed_field:
from drf_fastserializers import from_drf
FastTxnOut = from_drf(
TxnSerializer,
computed={
"formatted_amount": (lambda self: f"${self.amount:,.2f}", str),
},
)The callable receives the validated pydantic instance (so it can read
already-resolved fields like self.amount) and the second tuple
element is the return annotation.
Custom Field subclasses with an overridden to_representation that
isn't in the scalar table can't be auto-mapped. The mixin emits a
one-time warning and falls back to standard DRF .data. Two fixes:
1. Switch to explicit translation via from_drf(TxnSerializer, exclude=("weird_field",)) and redeclare the field manually on the
resulting FastSerializer.
2. Opt out for this serializer. Set Meta.fast = False. The mixin
stops trying, the warning goes away, and the endpoint stays on DRF.
Full DRF to pydantic field mapping table
| DRF field | Pydantic type |
|---|---|
CharField, EmailField, URLField, SlugField, RegexField |
str |
IntegerField |
int |
FloatField |
float |
DecimalField |
Decimal |
BooleanField |
bool |
DateField / DateTimeField / TimeField / DurationField |
date / datetime / time / timedelta |
UUIDField |
UUID |
IPAddressField, FileField, ImageField |
str |
ChoiceField |
str |
JSONField |
Any |
ReadOnlyField |
Any (no type info to extract; output renders as-is) |
DictField, HStoreField |
dict |
ListField(child=X) |
list[mapped(X)] |
Serializer(...) (nested) |
nested FastSerializer (recursive) |
ListSerializer(...) |
list[nested FastSerializer] |
PrimaryKeyRelatedField |
int |
StringRelatedField, HyperlinkedRelatedField, SlugRelatedField |
str |
SerializerMethodField |
annotated return type of get_* method (falls back to Any) |
Field options carried through:
| DRF option | Effect on pydantic field |
|---|---|
required=False |
non-required with default=None (or empty container for ListField/DictField) |
allow_null=True |
type widened to T | None |
default=... |
becomes the pydantic default |
source="a.b.c" |
becomes AliasPath("a", "b", "c") |
Standard DRF pagination works without changes:
class TxnListView(ListAPIView):
serializer_class = TxnSerializer
renderer_classes = [FastJSONRenderer]
pagination_class = LimitOffsetPagination
queryset = Txn.objects.all()The renderer recognizes {"results": <FastPayload>, "next": ..., "count": ...}
and splices the Rust-encoded list bytes into the paginator's wrapper.
When you'd rather skip the DRF serializer step entirely, derive a schema straight from your Django model:
from drf_fastserializers import from_model, FastJSONRenderer
TxnOut = from_model(Txn, fields=["id", "name", "amount", "txn_date"])
class TxnListView(ListAPIView):
serializer_class = TxnOut.drf
renderer_classes = [FastJSONRenderer]
queryset = Txn.objects.all()from_model walks Model._meta and maps each concrete Django field to
its pydantic equivalent (nullability, defaults, FK PK types, callable
defaults via default_factory). Pass fields="__all__" to include
every concrete field, or exclude=(...) to drop a subset.
For new endpoints, skip the DRF serializer and define a pydantic schema directly. Same renderer, tighter types, less boilerplate.
from datetime import date
from decimal import Decimal
from drf_fastserializers import FastSerializer, FastJSONRenderer
class Flags(FastSerializer):
is_nsf: bool = False
is_refund: bool = False
class TxnOut(FastSerializer):
id: int
name: str
amount: Decimal | None = None
txn_date: date
flags: Flags
tags: list[str] = []
class TxnListView(ListAPIView):
serializer_class = TxnOut.drf
renderer_classes = [FastJSONRenderer]
queryset = Txn.objects.all()FastSerializer is a pydantic.BaseModel. Everything pydantic does
(nested models, @computed_field, validators, model_config, enums)
works.
TxnOut.drf is a class-level descriptor returning a DRFAdapter
subclass bound to the schema. It quacks like
rest_framework.serializers.Serializer:
serializer = TxnOut.drf(instance=qs, many=True)
serializer.data # FastPayload, encoded on render
serializer.is_valid() # validates incoming request data
serializer.errors # DRF-shape: {"field": ["msg", ...]}
serializer.validated_data # pydantic instancesInput validation in a view:
class TxnCreateView(APIView):
def post(self, request):
serializer = TxnIn.drf(data=request.data)
serializer.is_valid(raise_exception=True)
txn = serializer.validated_data
Txn.objects.create(**txn.model_dump())
return Response(status=201)Errors land in DRF's standard shape:
{
"amount": ["Input should be a valid decimal"],
"flags.is_nsf": ["Input should be a valid boolean"]
}Pass partial=True at construction (matches DRF). Every field becomes
optional with default None, and
validated_data.model_dump(exclude_unset=True) returns only the keys
the client actually sent.
class TxnPatchView(APIView):
def patch(self, request, pk):
serializer = TxnIn.drf(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
Txn.objects.filter(pk=pk).update(
**serializer.validated_data.model_dump(exclude_unset=True)
)
return Response(status=200)Stock DRF parses JSON into a Python dict, then is_valid re-walks that
dict to validate it. FastJSONParser skips the first pass and hands
raw request bytes directly to pydantic-core's validate_json. Add it
to parser_classes per view, or to DEFAULT_PARSER_CLASSES globally.
class TxnCreateView(APIView):
parser_classes = [FastJSONParser]
renderer_classes = [FastJSONRenderer]
def post(self, request):
serializer = TxnIn.drf(data=request.data)
serializer.is_valid(raise_exception=True)
Txn.objects.create(**serializer.validated_data.model_dump())
return Response(status=201)Middleware or tests that read request.data still get a dict. The
parser returns a lazy-decoded proxy that triggers json.loads on the
first non-validator access.
Spell the queryset projection once, on the schema:
qs = Txn.objects.values(*TxnOut.values_fields())Returns the field names declared on TxnOut in declaration order.
Pass exclude=(...) to drop specific fields.
Prefer an explicit import over the .drf descriptor? Use drf_serializer:
from drf_fastserializers import drf_serializer
class TxnListView(ListAPIView):
serializer_class = drf_serializer(TxnOut)Both forms return the same cached DRFAdapter subclass.
Install the extra and import the extension module once during app
startup, typically in your AppConfig.ready():
pip install 'drf-fastserializers[spectacular]'# myapp/apps.py
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
import drf_fastserializers.spectacular # noqa: F401Every FastSerializer-backed view is then rendered into the OpenAPI
schema using model_json_schema(). Nested models, enum choices,
validators, and @computed_fields all carry through. No
@extend_schema boilerplate needed for the common case.
Stock DRF serializers iterate field objects in Python on every response. That overhead dominates response time for endpoints returning thousands of rows.
drf-fastserializers skips DRF's field pipeline. On .data access the
serializer hands back a FastPayload marker carrying the validated
pydantic instances plus a TypeAdapter. FastJSONRenderer recognizes
the marker and routes encoding to TypeAdapter.dump_json, which is
implemented in Rust as part of
pydantic-core. Bytes go
straight to the HTTP response. No Python-side json.dumps step.
flowchart LR
View["DRF View"] --> Data["serializer.data"]
Data --> Marker["FastPayload<br/>(adapter + instances)"]
Marker --> Renderer["FastJSONRenderer"]
Renderer -->|Rust dump_json| Bytes[("bytes")]
Bytes --> Response["HTTP Response"]
classDef rust fill:#dea584,stroke:#8b4513,color:#000
classDef drf fill:#a30000,stroke:#600,color:#fff
class Renderer,Marker rust
class View,Data,Response drf
For payloads the renderer doesn't recognize (error responses, plain
dicts, the browsable API, paginated wrappers), it falls back to stock
JSONRenderer. The library never breaks code paths it doesn't
explicitly handle.
Built against the stable pydantic surface used by both v2.7+ and v3.x
(BaseModel, TypeAdapter, ConfigDict, ValidationError,
model_dump_json). The pyproject spec is pydantic>=2.7,<4. The
PYD_V3 flag is exposed for downstream code that needs to branch.
- Not a
ModelSerializerreplacement that auto-infers fields from a Django model. Usefrom_drf(MyModelSerializer)to lift an existing one, or declare fields explicitly in aFastSerializer. - Not a framework. Keep your DRF generics, viewsets, routers, permissions, auth backends, throttling, filtering. Only the serializer and renderer paths change. Migrate one endpoint at a time.
- Not a drf-spectacular replacement. For the common case, install the
[spectacular]extra and letmodel_json_schema()flow through; for anything custom, declare response shapes with@extend_schema.
MIT