Chapter 4: serialization. Pydantic → dict / JSON / arbitrary forms.

model_dump

user = User(id=1, email="[email protected]", name="Alice")
user.model_dump()
# {"id": 1, "email": "[email protected]", "name": "Alice", ...}

Returns Python types (datetime, UUID, etc. stay as objects).

model_dump(mode=“json”)

user.model_dump(mode="json")
# {"id": 1, "email": "[email protected]", "name": "Alice", "created_at": "2026-05-09T10:00:00Z"}

Converts datetime / UUID / Decimal / etc. to JSON-friendly forms (ISO strings, etc.).

model_dump_json

user.model_dump_json()
# '{"id":1,"email":"[email protected]","name":"Alice","created_at":"..."}'

Returns a JSON string. Faster than json.dumps(user.model_dump()) because Pydantic’s serializer is Rust.

exclude / include

user.model_dump(exclude={"created_at"})
user.model_dump(include={"id", "email"})
user.model_dump(exclude={"profile": {"address"}})  # nested

Drop fields from output.

exclude_none / unset / defaults

user.model_dump(exclude_none=True)         # drops None values
user.model_dump(exclude_unset=True)        # drops fields not explicitly set
user.model_dump(exclude_defaults=True)     # drops fields equal to default

For PATCH-style “send only the changed fields”:

user.model_dump(exclude_unset=True)

by_alias

class User(BaseModel):
    full_name: str = Field(alias="fullName")

user = User.model_validate({"fullName": "Alice"})
user.model_dump()              # {"full_name": "Alice"}
user.model_dump(by_alias=True) # {"fullName": "Alice"}

For round-tripping JSON with externalKey conventions.

field_serializer

from pydantic import BaseModel, field_serializer
from datetime import datetime

class Event(BaseModel):
    when: datetime
    
    @field_serializer("when")
    def serialize_when(self, v: datetime, _info) -> str:
        return v.isoformat()

Custom output for one field. _info carries context.

For per-mode:

@field_serializer("when", when_used="json")
def to_iso(self, v, _info):
    return v.isoformat()

when_used: “always”, “json”, “json-unless-none”, “unless-none”.

model_serializer

from pydantic import model_serializer

class User(BaseModel):
    id: int
    email: str
    
    @model_serializer
    def serialize(self) -> dict:
        return {"user_id": self.id, "user_email": self.email}

Override the whole model’s serialization. Most apps don’t need this.

Round-tripping

user = User(id=1, email="[email protected]")
data = user.model_dump()
restored = User.model_validate(data)
assert restored == user

Standard pattern. Use mode="json" for JSON-safe round-trip:

data = user.model_dump(mode="json")
restored = User.model_validate(data)  # ISO string → datetime

Computed fields in output

class User(BaseModel):
    first: str
    last: str
    
    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first} {self.last}"

User(first="Alice", last="X").model_dump()
# {"first": "Alice", "last": "X", "full_name": "Alice X"}

Serialized; not accepted on input.

Aliases in output

class User(BaseModel):
    full_name: str = Field(serialization_alias="fullName")

User(full_name="Alice").model_dump(by_alias=True)
# {"fullName": "Alice"}

Custom serialization for types

from pydantic import BaseModel
from datetime import datetime
from typing import Annotated
from pydantic import PlainSerializer

UnixTimestamp = Annotated[
    datetime,
    PlainSerializer(lambda dt: int(dt.timestamp()), return_type=int, when_used="json"),
]

class Event(BaseModel):
    when: UnixTimestamp

Reusable serializer.

WrapSerializer

from pydantic import WrapSerializer

def conditional(value, handler, info):
    if info.context and info.context.get("verbose"):
        return {"value": handler(value), "ts": time.time()}
    return handler(value)

class M(BaseModel):
    name: Annotated[str, WrapSerializer(conditional)]

Wraps the default serializer. Context-dependent output.

Nested serialization

Nested Pydantic models serialize automatically:

class Profile(BaseModel):
    address: str

class User(BaseModel):
    profile: Profile

User(profile=Profile(address="...")).model_dump()
# {"profile": {"address": "..."}}

Lists / dicts of models

class Group(BaseModel):
    members: list[User]

g = Group(members=[User(...), User(...)])
g.model_dump()
# {"members": [{"id": 1, ...}, {"id": 2, ...}]}

Performance

Pydantic v2’s serializer is Rust. model_dump_json() is significantly faster than:

json.dumps(user.model_dump())

Use the built-in for hot paths.

Bytes output

user.model_dump_json().encode()
# or
user.__pydantic_serializer__.to_json(user)  # bytes directly

For network protocols.

Excluding sensitive fields

class User(BaseModel):
    id: int
    email: str
    password_hash: str = Field(..., exclude=True)

exclude=True on Field permanently excludes from serialization. Useful for ORM-derived models that include sensitive columns.

Or use a separate Read model (preferred):

class UserDB(BaseModel):
    id: int
    email: str
    password_hash: str  # internal

class UserRead(BaseModel):
    id: int
    email: str  # API output

Common mistakes

1. Returning ORM model directly

return user where user has password_hash. Use a Read model.

2. dict() instead of model_dump()

v1 API. Use model_dump().

3. JSON encoding manually

json.dumps(user.model_dump()) works but is slow. Use model_dump_json().

4. Forgetting mode=‘json’

{"created_at": datetime(...)} not JSON-serializable. Use mode="json" for JSON output.

5. Inconsistent alias use

by_alias=True on output but not handling aliases on input. Be consistent or populate_by_name=True.

What’s next

Chapter 5: Nested models, generics, discriminated unions.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .