Call .model_dump() (Pydantic v2). It replaces the v1 .dict() method.
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
in_stock: bool = True
item = Item(name="Widget", price=9.99)
item.model_dump()
# {"name": "Widget", "price": 9.99, "in_stock": True}
Key keyword arguments:
exclude_none=True— dropNonevaluesexclude_unset=True— drop fields not explicitly setexclude={"field"}— drop specific fieldsinclude={"field"}— keep only specific fieldsmode="json"— convert to JSON-safe types (datetime → str, UUID → str)
Rule of thumb: use model_dump(mode="json") before storing to NoSQL or
passing to JSONResponse(content=...) — it handles non-serialisable types.
.model_dump_json() serialises directly to a JSON string (bytes) without
going through a Python dict intermediate. It's faster because Pydantic uses its
Rust core to serialise.
item = Item(name="Widget", price=9.99)
item.model_dump_json()
# b'{"name":"Widget","price":9.99,"in_stock":true}'
# round-trip: parse from JSON string
Item.model_validate_json('{"name":"Widget","price":9.99}')
Use model_dump_json() when writing to a cache, message queue, or file
where you need a JSON string directly.
Rule of thumb: use model_dump() when you need a Python dict (to merge, modify,
or pass to another dict); use model_dump_json() when you need a string/bytes.
By default, aliases affect input (parsing) only. To use aliases during
output (serialisation), pass by_alias=True to model_dump():
from pydantic import BaseModel, Field
class Order(BaseModel):
order_id: int = Field(alias="orderId")
item_count: int = Field(alias="itemCount")
order = Order.model_validate({"orderId": 42, "itemCount": 3})
order.model_dump() # {"order_id": 42, "item_count": 3} (Python names)
order.model_dump(by_alias=True) # {"orderId": 42, "itemCount": 3} (aliases)
In FastAPI, set response_model_by_alias=True in the decorator to use aliases
in the HTTP response:
@app.get("/orders/{id}", response_model=Order, response_model_by_alias=True)
Rule of thumb: if your API contract uses camelCase, set by_alias=True globally
in serialisation; don't mix Python names and aliases in the same API response.
Use @field_serializer("field_name") to override the default serialisation for
a single field:
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
created_at: datetime
@field_serializer("created_at")
def serialise_dt(self, v: datetime) -> str:
return v.strftime("%Y-%m-%d %H:%M") # custom format instead of ISO 8601
e = Event(name="Launch", created_at=datetime(2026, 6, 20, 9, 0))
e.model_dump()
# {"name": "Launch", "created_at": "2026-06-20 09:00"}
Rule of thumb: use @field_serializer when you need a non-default format for
dates, decimals, or custom types; prefer standard ISO 8601 datetimes unless
the client explicitly requires a different format.
@model_serializer replaces the entire default serialisation of a model with
a custom function. The function receives self (the model) and must return a
JSON-serialisable value.
from pydantic import BaseModel, model_serializer
class Money(BaseModel):
amount: int # stored in cents
currency: str
@model_serializer
def to_dict(self):
return {
"display": f"{self.amount / 100:.2f} {self.currency}",
"cents": self.amount,
}
m = Money(amount=999, currency="USD")
m.model_dump()
# {"display": "9.99 USD", "cents": 999}
Rule of thumb: use @model_serializer only when the serialised shape differs
fundamentally from the model's fields (e.g., presenting a monetary value as
display text + machine value).
exclude_unset=True returns only the fields the client explicitly sent,
skipping fields that were left at their defaults. This is the correct behaviour
for a PATCH — you only update what was provided.
class ItemUpdate(BaseModel):
name: str | None = None
price: float | None = None
in_stock: bool | None = None
@app.patch("/items/{id}")
async def patch_item(id: int, patch: ItemUpdate):
updates = patch.model_dump(exclude_unset=True)
# client sent {"price": 14.99}
# updates = {"price": 14.99} — name and in_stock NOT included
await db.update(id, updates)
return await db.get(id)
Without exclude_unset=True, updates would include {"name": None, "price": 14.99, "in_stock": None}, accidentally overwriting existing values with None.
Rule of thumb: always use model_dump(exclude_unset=True) in PATCH handlers —
it prevents accidental nulling of fields the client didn't mention.
In Pydantic v2 with mode="json" or model_dump_json(), standard types like
datetime, UUID, Decimal, and Enum are handled automatically.
For custom third-party types, use @field_serializer:
from decimal import Decimal
from pydantic import BaseModel, field_serializer
class Price(BaseModel):
amount: Decimal
@field_serializer("amount")
def encode_decimal(self, v: Decimal) -> str:
return str(v) # "9.99" instead of Decimal("9.99")
Price(amount=Decimal("9.99")).model_dump(mode="json")
# {"amount": "9.99"}
Rule of thumb: run model_dump(mode="json") in tests to check that all fields
are JSON-serialisable — catch TypeError early before it surfaces in production.
- Handler returns a Python value (dict, ORM object, or Pydantic model).
- FastAPI calls
response_model.model_validate(value)to filter and coerce it. - The validated Pydantic instance is passed to
jsonable_encoder()which callsmodel.model_dump(mode="json")internally. - The resulting JSON-safe dict is serialised to bytes with
json.dumps()(ororjson.dumps()ifORJSONResponseis configured). - Bytes are sent as
Content-Type: application/json.
# simplified internal equivalent
validated = ResponseModel.model_validate(handler_return)
payload = jsonable_encoder(validated) # → JSON-safe dict
body = json.dumps(payload).encode() # → bytes
Rule of thumb: understanding this flow explains why response_model filters
extra fields (step 2) and why datetime values arrive as ISO strings (step 3).
Nested BaseModel instances are serialised recursively. .model_dump() returns
nested dicts; .model_dump_json() returns a flat JSON string.
class Address(BaseModel):
city: str
country: str
class User(BaseModel):
name: str
address: Address
u = User(name="Alice", address=Address(city="London", country="UK"))
u.model_dump()
# {"name": "Alice", "address": {"city": "London", "country": "UK"}}
exclude and include work recursively:
u.model_dump(exclude={"address": {"country"}})
# {"name": "Alice", "address": {"city": "London"}}
Rule of thumb: test serialisation of nested models explicitly — a missing
from_attributes=True on an inner model is a common bug when using ORM objects.
Call .model_dump() on each element, or use a RootModel for a top-level list:
items = [Item(name="A", price=1.0), Item(name="B", price=2.0)]
[i.model_dump() for i in items]
# [{"name": "A", "price": 1.0}, {"name": "B", "price": 2.0}]
In FastAPI, returning a list[Item] from a handler with response_model=list[Item]
handles this automatically.
For a root-level list model:
from pydantic import RootModel
class ItemList(RootModel[list[Item]]):
pass
ItemList([Item(name="A", price=1.0)]).model_dump()
# [{"name": "A", "price": 1.0}]
Rule of thumb: let FastAPI handle list serialisation via response_model=list[MyModel];
use RootModel only when you need to attach methods or validators to the list itself.
Use .model_copy(update={...}) (v2 replacement for .copy(update=...)):
original = Item(name="Widget", price=9.99, in_stock=True)
updated = original.model_copy(update={"price": 14.99})
print(original.price) # 9.99 — unchanged
print(updated.price) # 14.99 — new copy
This is useful in PATCH handlers after merging the update dict with the existing DB row's values:
db_item = await db.get(id)
pydantic_item = ItemOut.model_validate(db_item)
merged = pydantic_item.model_copy(update=patch.model_dump(exclude_unset=True))
Rule of thumb: use model_copy(update=...) instead of mutating model attributes
directly — it keeps models immutable and makes the data flow explicit.
By default, Pydantic serialises an Enum to its .value. String enums
(str, Enum) serialise as plain strings; int enums as integers.
from enum import Enum
from pydantic import BaseModel
class Status(str, Enum):
active = "active"
inactive = "inactive"
class User(BaseModel):
status: Status
User(status=Status.active).model_dump()
# {"status": "active"} — the string value, not the Enum object
If you need the enum member name instead of value:
user.model_dump(mode="python") # {"status": <Status.active: 'active'>}
Rule of thumb: always inherit from str (or int) when defining enums for
Pydantic models — it ensures JSON-safe serialisation without extra config.
Use json_schema_extra in model_config to add or override schema properties:
from pydantic import BaseModel, ConfigDict
class Item(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"title": "Inventory Item",
"examples": [
{"name": "Widget", "price": 9.99}
],
}
)
name: str
price: float
For programmatic customisation (add/remove properties):
model_config = ConfigDict(
json_schema_extra=lambda schema: schema.update({"deprecated": True})
)
Rule of thumb: use json_schema_extra to add examples and title to your
models — Swagger UI renders examples in the "Try it out" body, saving testers time.
More Pydantic & Validation interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.