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

Serializer

ModelSerializer

Field source

Manually declared type annotations

Auto-generated from Meta.model

Meta class

Not required

Required (model, fields / exclude)

ORM save() / create() / update()

Not provided

Built-in, delegates to Model.objects

File field handling

Manual

Automatic (FileField / ImageField)

readonly_fields

Class variable

Also set via Meta.readonly_fields

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 a QuerySet. Defaults to 25.

Parsing / deserialization:

classmethod validate(data, *, partial=False) Self

Parse and validate data (dict, ORM object, or mapping). Raises ValidationError with structured error details on failure.

When partial=True every field becomes optional, which is ideal for PATCH endpoints. The returned instance tracks which fields were supplied via model_fields_set; call model_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_fields are automatically excluded. Pass an additional exclude set 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 via hasattr(objs, "batch")), objects are fetched in PAGE_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 uses PAGE_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 PaginatedSerializer envelope for a single page of qs. Uses asyncio.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 / previous URL 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 a next_cursor field 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, or None.

  • previous — URL for the previous page, or None.

  • next_cursor — base64-encoded cursor for keyset pagination, or None.

  • results — list of serialized dicts for the current page.

class openviper.serializers.ModelSerializer

Extends Serializer with automatic field generation from an ORM model. Requires a nested Meta class.

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 to fields).

  • 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.

update(instance) Awaitable[Model]

Apply validated data to an existing instance and save it. Only fields present in model_fields_set (i.e. fields the caller explicitly provided) are written — safe for PATCH semantics.

save(instance=None) Awaitable[dict]

Smart create-or-update. If instance is provided, update it. If the validated data contains a non-None PK, fetch the existing record and update it. Otherwise create a new record. Returns the serialized dict of the persisted object.

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 in serialize() 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

IntegerField, BigIntegerField, AutoField

int

FloatField

float

DecimalField

Decimal

CharField, TextField, EmailField, SlugField, URLField, IPAddressField

str

BooleanField

bool

DateTimeField

datetime.datetime

DateField

datetime.date

TimeField

datetime.time

UUIDField

uuid.UUID

JSONField

Any

ForeignKey, OneToOneField

int (raw FK id)

FileField, ImageField

str (stored path)

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:

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_cursor uses keyset pagination — O(log N), fast at any depth.

  • Always include id as 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)