Serializers
The openviper.serializers package provides a Pydantic v2-based
serialization and validation layer. Serializer is a thin wrapper
over pydantic.BaseModel giving it OpenViper-idiomatic helpers, while
ModelSerializer auto-generates fields directly from an ORM model.
Overview
Use Serializer to:
Validate and deserialize incoming request data.
Serialize outgoing ORM instances to plain dicts / JSON.
Produce OpenAPI-compatible JSON Schema for Swagger UI.
Use ModelSerializer to remove duplication between ORM model
fields and serializer fields — just point Meta.model at your model class.
The two classes share a common API. The main difference is where fields come from:
Feature |
|
|
|---|---|---|
Field source |
Manually declared type annotations |
Auto-generated from |
|
Not required |
Required ( |
ORM |
Not provided |
Built-in, delegates to |
File field handling |
Manual |
Automatic ( |
|
Class variable |
Also set via |
Key Classes & Functions
- class openviper.serializers.Serializer
Base serializer backed by Pydantic v2. Declare fields as annotated class attributes exactly as you would in a
BaseModel.Class variables (set at class level, not instance level):
- readonly_fields: tuple[str, ...]
Fields included in serialized output but stripped before
create()/update()writes. Defaults to().
- writeonly_fields: tuple[str, ...]
Fields accepted on input but excluded from
serialize()output (e.g. passwords). Defaults to().
- PAGE_SIZE: int
Batch size used by
serialize_many()when the input is aQuerySet. Defaults to25.
Parsing / deserialization:
- classmethod validate(data, *, partial=False) Self
Parse and validate data (dict, ORM object, or mapping). Raises
ValidationErrorwith structured error details on failure.When
partial=Trueevery field becomes optional, which is ideal forPATCHendpoints. The returned instance tracks which fields were supplied viamodel_fields_set; callmodel_dump(exclude_unset=True)to get only the changed keys.
- classmethod validate_json_string(json_str) Self
Parse a raw JSON string directly (bypasses the
body()call).
- classmethod from_orm(obj) Self
Build a serializer instance from an ORM model instance (or any object with the right attributes).
- classmethod from_orm_many(objs) list[Self]
Build a list of serializer instances from a list of ORM objects.
Serialization:
- serialize(*, exclude=None) dict[str, Any]
Return a JSON-safe
dict.writeonly_fieldsare automatically excluded. Pass an additionalexcludeset to drop more fields.
- serialize_json(*, exclude=None) bytes
Return JSON bytes via pydantic-core’s Rust encoder (fast path).
- classmethod serialize_many(objs, *, exclude=None) Awaitable[list[dict]]
Serialize a list or QuerySet of ORM objects to a list of dicts.
When objs is a
QuerySet(detected viahasattr(objs, "batch")), objects are fetched inPAGE_SIZE-sized batches to avoid loading the entire result set into memory. Plain lists are handled with a simple comprehension (no DB calls).Performance: Uses direct ORM→dict mapping without intermediate Pydantic model validation, providing ~35-40% faster serialization compared to traditional double-conversion approaches.
- classmethod serialize_many_json(objs, *, exclude=None) Awaitable[bytes]
Like
serialize_many()but returns a JSON bytes array. Also usesPAGE_SIZE-sized batches for QuerySets.Performance: Uses direct ORM→dict mapping (35-40% faster) and optimized JSON encoding for bulk serialization.
- classmethod paginate(qs, *, page=1, page_size=None, cursor=None, base_url='', exclude=None) Awaitable[PaginatedSerializer]
Return a
PaginatedSerializerenvelope for a single page of qs. Usesasyncio.gather()to execute COUNT and data fetch queries concurrently for ~2x faster performance.qs must be a QuerySet (supports
.count(),.offset(),.limit(), and.all()).page is 1-based (default: 1).
page_size defaults to
cls.PAGE_SIZE(default: 25).cursor — optional base64-encoded cursor for keyset pagination (faster for Next/Prev navigation).
base_url — when given,
next/previousURL strings are built.exclude — set of field names to omit from serialized output.
Cursor pagination: When the queryset has ordering (e.g.,
.order_by("name", "id")), the response includes anext_cursorfield for efficient sequential navigation. Cursors avoid OFFSET performance issues on deep pages.
- (~2x faster).
Results use direct ORM→dict mapping (35-40% faster than double conversion).
OFFSET-based page jumps (e.g., page 1000) are O(N) and can be slow.
Cursor-based Next/Prev navigation is O(log N) using keyset seeks.
Exclude field computation is cached to avoid repeated set allocation
COUNT and data fetch run in parallel via
asyncio.gather().OFFSET-based page jumps (e.g., page 1000) are O(N) and can be slow.
Cursor-based Next/Prev navigation is O(log N) using keyset seeks.
# Basic usage result = await PostSerializer.paginate( Post.objects.filter(published=True).order_by("-created_at"), page=2, page_size=20, base_url="/posts" ) # result.count → total matching rows # result.next → "/posts?page=3&page_size=20" # result.previous → "/posts?page=1&page_size=20" # result.next_cursor → base64 cursor for next page (or None) # result.results → list[dict] for page 2 # With cursor for fast sequential navigation cursor = request.query_params.get("cursor") result = await PostSerializer.paginate( Post.objects.order_by("created_at", "id"), page=1, page_size=20, cursor=cursor, base_url="/posts" ) # Click "Next" uses result.next_cursor for O(log N) performance
- class openviper.serializers.PaginatedSerializer
Returned by
Serializer.paginate().Fields:
count— total number of matching objects.next— URL for the next page, orNone.previous— URL for the previous page, orNone.next_cursor— base64-encoded cursor for keyset pagination, orNone.results— list of serialized dicts for the current page.
- class openviper.serializers.ModelSerializer
Extends
Serializerwith automatic field generation from an ORM model. Requires a nestedMetaclass.Meta attributes:
model— the ORM model class (required).fields—"__all__"or a list of field names to include.exclude— list of field names to exclude (alternative tofields).readonly_fields— tuple of field names that are output-only.writeonly_fields— tuple of field names that are input-only.extra_kwargs— dict of{field_name: {"required": False, ...}}overrides applied after field auto-generation.
CRUD helpers (only on
ModelSerializer):- create() Awaitable[Model]
Persist a new model instance from the validated data. Read-only fields and the PK are stripped before the INSERT. Returns the saved model instance.
- openviper.serializers.field_validator(field_name, *, mode='before')
Re-export of
pydantic.field_validator. Attach to a serializer method to add per-field validation logic.
- openviper.serializers.model_validator(*, mode='after')
Re-export of
pydantic.model_validator. Attach to a serializer method for cross-field validation.
- openviper.serializers.computed_field()
Re-export of
pydantic.computed_field. Decorate a property to include computed values inserialize()output.
Field Type Mapping
When ModelSerializer auto-generates fields from an ORM model it maps
field class names to Python types as follows:
ORM Field |
Pydantic Type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A field is automatically marked optional when the ORM field has
null=True, auto_now=True, auto_now_add=True, a default value,
or is the primary key.
Example Usage
See also
Working projects that use serializers:
examples/ai_moderation_platform/ — pydantic-based serializers with validation
examples/ecommerce_clone/ —
ModelSerializerfor products, orders, reviews
Manual Serializer
from openviper.serializers import Serializer, field_validator
class PostSerializer(Serializer):
title: str
body: str
tags: list[str] = []
@field_validator("title")
@classmethod
def title_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be blank.")
return v.strip()
# Validate incoming data
data = PostSerializer.validate({"title": "Hello", "body": "World"})
async def example():
# Serialize an ORM instance
post = await Post.objects.get(id=1)
out = PostSerializer.from_orm(post).serialize()
Model Serializer
from openviper.serializers import ModelSerializer
from myapp.models import Post
class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = ["id", "title", "body", "created_at"]
readonly_fields = ("id", "created_at")
async def example():
post = await Post.objects.get(id=1)
out = PostSerializer.from_orm(post).serialize()
# {"id": 1, "title": "...", "body": "...", "created_at": "..."}
Exclude Fields
class PostPublicSerializer(ModelSerializer):
class Meta:
model = Post
exclude = ["internal_notes", "admin_flag"]
write_only and read_only Fields
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
readonly_fields = ("id", "created_at")
writeonly_fields = ("password",) # never appears in serialize()
user = UserSerializer.validate({"username": "alice", "password": "s3cr3t"})
out = user.serialize()
# "password" is absent from out; "id" and "created_at" cannot be set
Cross-Field Validation
from openviper.serializers import Serializer, model_validator
class PasswordChangeSerializer(Serializer):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "PasswordChangeSerializer":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match.")
return self
Computed Fields
from openviper.serializers import Serializer, computed_field
class ProductSerializer(Serializer):
price: float
tax_rate: float = 0.2
@computed_field
@property
def price_with_tax(self) -> float:
return round(self.price * (1 + self.tax_rate), 2)
out = ProductSerializer(price=100.0).serialize()
# {"price": 100.0, "tax_rate": 0.2, "price_with_tax": 120.0}
Partial Validation (PATCH)
@router.patch("/posts/{post_id:int}")
async def patch_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json(), partial=True)
# Only update the fields the caller sent
changes = ser.model_dump(exclude_unset=True)
for key, value in changes.items():
setattr(post, key, value)
await post.save()
return JSONResponse(PostSerializer.from_orm(post).serialize())
serialize_many — Bulk Serialization
async def example():
posts = await Post.objects.filter(is_published=True).all()
# From a plain list — no DB call
data = await PostSerializer.serialize_many(posts)
# From a QuerySet — streamed in PAGE_SIZE batches (default 25)
qs = Post.objects.filter(is_published=True).order_by("-created_at")
data = await PostSerializer.serialize_many(qs)
# Exclude specific fields
data = await PostSerializer.serialize_many(posts, exclude={"body"})
Pagination
from openviper.serializers import ModelSerializer
from openviper.http import JSONResponse, Request
from myapp.models import Post
class PostSerializer(ModelSerializer):
PAGE_SIZE = 20 # override default of 25
class Meta:
model = Post
fields = ["id", "title", "body", "created_at"]
@router.get("/posts")
async def list_posts(request: Request) -> JSONResponse:
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", PostSerializer.PAGE_SIZE))
cursor = request.query_params.get("cursor") # for fast Next/Prev
qs = Post.objects.filter(is_published=True).order_by("-created_at", "id")
# Uses asyncio.gather() for concurrent COUNT + fetch (~2x faster)
paginated = await PostSerializer.paginate(
qs,
page=page,
page_size=page_size,
cursor=cursor,
base_url="/posts",
)
return JSONResponse(paginated.model_dump())
# {
# "count": 120,
# "next": "/posts?cursor=eyJjcmVhdGVkX2F0IjouLi59&page=3&page_size=20",
# "previous": "/posts?page=1&page_size=20",
# "next_cursor": "eyJjcmVhdGVkX2F0IjouLi4sImlkIjoxMjN9",
# "results": [...]
# }
Performance tips:
COUNT and fetch queries run concurrently (using
asyncio.gather()).Direct page jumps (e.g.,
?page=1000) use OFFSET and are O(N) — slower for deep pages.Sequential navigation via
next_cursoruses keyset pagination — O(log N), fast at any depth.Always include
idas the last ordering field for stable cursor pagination.
ORM-level pagination:
For more control, paginate at the ORM level before serialization:
@router.get("/posts")
async def list_posts(request: Request) -> JSONResponse:
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", 20))
qs = Post.objects.filter(is_published=True).order_by("-created_at", "id")
# Paginate at ORM level (also uses asyncio.gather)
page_obj = await qs.paginate(page_number=page, page_size=page_size)
# Serialize only the paginated items
results = await PostSerializer.serialize_many(page_obj.items)
return JSONResponse({
"count": page_obj.total_count,
"page": page_obj.number,
"page_size": page_obj.page_size,
"next_cursor": page_obj.next_cursor,
"results": results,
})
Performance Optimizations
The serializers module includes several performance optimizations that provide significant speed improvements for production workloads:
Direct ORM→Dict Mapping (35-40% faster)
serialize_many(), serialize_many_json(), and paginate() use direct
attribute access instead of double conversion (ORM → Pydantic model → dict).
This eliminates unnecessary validation and model instantiation overhead:
# Old approach (slow):
instances = [cls.model_validate(obj) for obj in objs] # Conversion 1
results = [inst.model_dump(mode="json") for inst in instances] # Conversion 2
# Optimized approach (35-40% faster):
results = [
{fname: getattr(obj, fname, None) for fname in cls.model_fields}
for obj in objs
]
Cached Exclude Field Computation (5-10% reduction)
The _get_excluded_fields() helper caches write-only field sets to avoid
repeated set construction on every serialization call:
excl = cls._get_excluded_fields(exclude) # Cached, returns frozenset
LRU-Cached File Fields (Memory Safe)
File field introspection uses @lru_cache(maxsize=512) instead of unbounded
dict caching, preventing memory leaks in applications with dynamic serializer
creation:
@classmethod
@lru_cache(maxsize=512)
def _get_file_fields(cls) -> dict[str, Any]:
# Cached per serializer class with automatic eviction
Parallel Query Execution (2x faster)
paginate() uses asyncio.gather() to run COUNT and data fetch queries
concurrently, doubling baseline performance:
total, objs = await asyncio.gather(
qs.count(), # Query 1
page_qs.all(), # Query 2 (runs in parallel)
)
Expected Performance Gains
When combined with db query optimizations, these improvements provide:
60-80% faster paginated list endpoints (COUNT + fetch + serialization)
35-40% faster bulk serialization (
serialize_many/serialize_many_json)50% faster file field validation (cached introspection)
Eliminated O(N) allocation overhead for exclude field computation
For highest performance on large lists, consider using cursor-based pagination (keyset seeks) instead of OFFSET, and ensure proper database indexes exist for ordering columns.
ModelSerializer CRUD Helpers
class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = "__all__"
readonly_fields = ("id", "created_at")
# CREATE
@router.post("/posts")
async def create_post(request: Request) -> JSONResponse:
ser = PostSerializer.validate(await request.json())
saved = await ser.save() # calls ser.create() internally
return JSONResponse(saved, status_code=201)
# UPDATE (PUT — full replacement)
@router.put("/posts/{post_id:int}")
async def update_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json())
saved = await ser.save(post) # calls ser.update(post) internally
return JSONResponse(saved)
# UPDATE (PATCH — partial)
@router.patch("/posts/{post_id:int}")
async def patch_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json(), partial=True)
saved = await ser.save(post)
return JSONResponse(saved)
Nested Serializers
class AuthorSerializer(Serializer):
id: int
username: str
class PostSerializer(Serializer):
id: int
title: str
author: AuthorSerializer # nested — expects author to be an object
tags: list[str] = []
# When building from ORM, use select_related to avoid N+1:
async def example():
post = await Post.objects.select_related("author").get(id=1)
out = PostSerializer.from_orm(post).serialize()
# {"id": 1, "title": "...", "author": {"id": 5, "username": "alice"}, "tags": []}
Error Handling
validate() wraps all Pydantic errors in
ValidationError whose errors attribute
is a list of structured dicts:
from openviper.exceptions import ValidationError
try:
data = PostSerializer.validate({"title": ""})
except ValidationError as exc:
print(exc.errors)
# [{"field": "title", "message": "Title cannot be blank.", "type": "value_error"}]
Using in a View
from openviper.routing.router import Router
from openviper.http.request import Request
from openviper.http.response import JSONResponse
from openviper.exceptions import ValidationError
router = Router()
@router.post("/posts")
async def create_post(request: Request) -> JSONResponse:
try:
data = PostSerializer.validate(await request.json())
except ValidationError as exc:
return JSONResponse({"errors": exc.errors}, status_code=422)
post = await Post.objects.create(**data.model_dump())
return JSONResponse(post._to_dict(), status_code=201)