Turning a model back into data: model_dump and model_dump_json, include and exclude, aliases, computed fields, custom serialisers and the round-trip guarantees.
post.model_dump() # dict of Python objectspost.model_dump_json() # JSON stringmodel_dump versus model_dump_json
Section titled “model_dump versus model_dump_json”class Event(BaseModel): id: UUID at: datetime
event.model_dump()# {"id": UUID('...'), "at": datetime(2026, 8, 15, ...)}
event.model_dump_json()# '{"id":"...","at":"2026-08-15T10:30:00Z"}'model_dump() gives Python objects. model_dump_json() gives JSON, converting
UUID, datetime, Decimal and Enum along the way.
For a dict that is JSON-safe without going through a string:
event.model_dump(mode="json")# {"id": "...", "at": "2026-08-15T10:30:00Z"}Which is what you want when handing a dict to response.json(). The default
mode="python" leaves a datetime in place, and the encoder then has to deal
with it.
Choosing fields
Section titled “Choosing fields”post.model_dump(include={"id", "title"})post.model_dump(exclude={"body"})Nested, with a dict:
order.model_dump(exclude={"customer": {"address"}})order.model_dump(include={"lines": {"__all__": {"sku", "quantity"}}})__all__ applies to every item in a collection.
Prefer a separate model over exclude for anything leaving the process.
An exclusion is a per-call-site decision, and one call site eventually forgets;
a PostOut model states the shape once. See Patterns.
exclude_unset
Section titled “exclude_unset”post.model_dump(exclude_unset=True)Only the fields the caller actually provided. This is what makes a PATCH endpoint correct:
class PostUpdate(BaseModel): title: str | None = None body: str | None = None published: bool | None = None
@app.patch("/posts/{id:int}", request_model=PostUpdate)async def update_post(request, response, payload, id=Path(type=int)): post = await Post.get(id=id) await post.update_from_dict(payload.model_dump(exclude_unset=True)) return response.json(post.to_dict())Without it, a client sending only {"title": "New"} also writes
body = None and published = None, because those are the model’s defaults.
That is a data-loss bug, and it looks like a working endpoint until someone
patches one field.
Two related options:
post.model_dump(exclude_defaults=True) # omit anything equal to its defaultpost.model_dump(exclude_none=True) # omit anything that is Noneexclude_none is tempting for tidy responses and is usually wrong. It makes
“absent” and “explicitly null” indistinguishable to the client.
Aliases
Section titled “Aliases”webhook.model_dump(by_alias=True)Emits the serialisation aliases rather than the field names. The camelCase form, when you have set one. See Fields.
Sillo’s response models do not set this for you,
so a model with aliases needs it explicitly, or an
alias generator plus serialize_by_alias.
Computed fields
Section titled “Computed fields”A value derived from others, included in the output:
from pydantic import computed_field
class OrderOut(BaseModel): subtotal: Decimal tax: Decimal
@computed_field @property def total(self) -> Decimal: return self.subtotal + self.tax{"subtotal": "100.00", "tax": "20.00", "total": "120.00"}Computed fields are output only: they appear in model_dump() and in the
response schema, and are not accepted on input. Which is exactly right for a
derived value. A client should not be able to send a total that disagrees with
its parts.
The return annotation is required; it is what the schema is generated from.
Custom serialisers
Section titled “Custom serialisers”Per field:
from pydantic import field_serializer
class PostOut(BaseModel): published_at: datetime | None
@field_serializer("published_at") def serialize_date(self, value: datetime | None) -> str | None: return value.strftime("%Y-%m-%d") if value else NoneWhole model:
from pydantic import model_serializer
class Money(BaseModel): amount: Decimal currency: str
@model_serializer def to_string(self) -> str: return f"{self.amount} {self.currency}"Use these sparingly. A custom serialiser makes the output diverge from the schema Pydantic generates, so your OpenAPI document can end up describing something you no longer send. Where the shape genuinely differs, a separate model is more honest.
Secrets
Section titled “Secrets”class Credentials(BaseModel): username: str password: SecretStrcreds.model_dump()# {"username": "ada", "password": SecretStr('**********')}SecretStr keeps the value out of
repr, tracebacks and logs. Getting it requires saying so:
creds.password.get_secret_value()Which is greppable. You can audit every place a secret is actually read.
Warnings on mismatch
Section titled “Warnings on mismatch”post.model_dump(warnings="error")By default, serialising a value that does not match its annotation emits a
warning and proceeds. warnings="error" makes it raise instead.
Worth turning on in tests. A response model quietly serialising the wrong type is how a client’s generated code breaks against a schema that says otherwise.
Round-tripping
Section titled “Round-tripping”PostCreate.model_validate(post.model_dump()) == postHolds for plain models. It does not hold when:
- a field has a custom serialiser that is not the inverse of its validator;
excludeorexclude_unsetdropped something required;- a computed field is present, since it cannot be validated back in.
Where a round trip matters (caching a model, queueing one as a job payload) use
model_dump_json() and model_validate_json(), and keep the model plain.
In a Sillo handler
Section titled “In a Sillo handler”@app.get("/posts/{id:int}", response_model=PostOut)async def get_post(request, response, id=Path(type=int)): post = await Post.get(id=id) return response.json(PostOut.model_validate(post).model_dump(mode="json"))With response_model= declared, Sillo validates and shapes the return value
for you. See Response models, which is the
shorter and safer form of the above.