Replacing json_encoders with field_serializer in Pydantic v2
Key takeaways:
Config.json_encodersis gone in Pydantic v2; there is no drop-in replacement dict.@field_serializer("name")formats one field and applies tomodel_dump()andmodel_dump_json()alike.@model_serializerreplaces the whole model's output when the wire shape differs from the model shape.Annotated[T, PlainSerializer(fn)]attaches the rule to the type, so it is reusable across models.- Serializers changed the
pythondump mode too — v1 encoders only ran on the JSON path.
This page is a focused slice of the Pydantic V2 Migration Guide, covering the one config key that has no mechanical equivalent.
The Problem This Solves
In Pydantic v1 you taught a model how to render awkward types with a dict in the model config:
# Pydantic v1 — this is the idiom being removed. It is NOT executable in v2.
class Invoice(BaseModel):
issued_at: datetime
total: Decimal
class Config:
json_encoders = {
datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%SZ"),
Decimal: float,
}
It was convenient and it was quietly wrong in three ways. It ran only when you called .json(), so .dict() returned the raw datetime and Decimal objects and the two paths disagreed. It was keyed by type, so a model with two datetime fields could not format them differently. And the schema generator had no idea it existed, so OpenAPI advertised format: date-time for a field that actually shipped a custom string.
Pydantic v2 pushed serialization into the same Rust core that does validation. A serializer is now a first-class part of the field's schema rather than a Python callback bolted on at the end, which is why json_encoders had nowhere to live. Unlike the config key renames, there is no one-to-one substitution here — you have to decide, per field, which of three replacements fits.
Prerequisites
- Pydantic 2.13.4, FastAPI 0.139.2, Python 3.12 — the versions every transcript below was produced on.
- A model that currently carries
json_encoders, or a custom type FastAPI cannot encode.
Step-by-Step Implementation
1. Establish the baseline: what happens with no serializer
Before replacing anything, look at what the core does on its own. A datetime and a Decimal both have built-in serializers, and they are probably not what your v1 encoders were producing.
2. Attach @field_serializer per field
@field_serializer names the field or fields it applies to and receives the already-validated value. It is a normal method, so a model with two datetime fields can format each one differently — the thing v1's type-keyed dict could not do.
3. Attach PlainSerializer to the type with Annotated
When the rule belongs to a type rather than to a model — a domain Money object, an internal ID wrapper — put it on the type. Every field annotated with it inherits the behaviour, and you do not repeat a decorator in ten models.
4. Use @model_serializer when the shape changes
If the response envelope differs structurally from the model — a {"type": ..., "data": {...}} wrapper, a flattened representation — @model_serializer takes over the whole output and you build the dict yourself.
Here is the complete example, exercised end to end:
"""Replacing v1 Config.json_encoders with field_serializer, model_serializer and PlainSerializer."""
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Any
from fastapi import FastAPI
from pydantic import BaseModel, PlainSerializer, field_serializer, model_serializer
app = FastAPI()
class Money:
"""A custom type Pydantic knows nothing about until we tell it how to serialize."""
def __init__(self, amount: Decimal, currency: str) -> None:
self.amount = amount
self.currency = currency
def money_to_str(value: Money) -> str:
return f"{value.amount:.2f} {value.currency}"
# PlainSerializer attaches the rule to the *type*, so every field using it inherits the rule.
SerializedMoney = Annotated[
Money,
PlainSerializer(money_to_str, return_type=str, when_used="always"),
]
class Invoice(BaseModel):
model_config = {"arbitrary_types_allowed": True}
id: int
issued_at: datetime
total: Decimal
balance: SerializedMoney
# Replaces v1's Config.json_encoders = {datetime: lambda v: v.isoformat()}
@field_serializer("issued_at")
def serialize_issued_at(self, value: datetime) -> str:
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
# Replaces v1's Config.json_encoders = {Decimal: float}
@field_serializer("total")
def serialize_total(self, value: Decimal) -> float:
return float(value)
class Receipt(BaseModel):
"""model_serializer takes over the whole model's output shape."""
id: int
issued_at: datetime
total: Decimal
@model_serializer
def to_envelope(self) -> dict[str, Any]:
return {
"type": "receipt",
"data": {
"id": self.id,
"issued_at": self.issued_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"total": f"{self.total:.2f}",
},
}
The endpoints call model_dump(mode="python") and model_dump(mode="json") on the same instance, wrapping the python-mode values in repr() so the transcript shows the true Python types rather than FastAPI's later JSON coercion of them.
Real output from _verify/output/pyd-field-serializer.txt, produced by running the app above:
$ GET /invoices/1/default
200 OK
{
"python_mode": {
"id": "1",
"issued_at": "datetime.datetime(2026, 7, 20, 9, 30, tzinfo=datetime.timezone.utc)",
"total": "Decimal('1249.507')"
},
"json_mode": {
"id": 1,
"issued_at": "2026-07-20T09:30:00Z",
"total": "1249.507"
}
}
$ GET /invoices/1
200 OK
{
"python_mode": {
"id": "1",
"issued_at": "'2026-07-20T09:30:00Z'",
"total": "1249.507",
"balance": "'300.50 EUR'"
},
"json_mode": {
"id": 1,
"issued_at": "2026-07-20T09:30:00Z",
"total": 1249.507,
"balance": "300.50 EUR"
}
}
$ GET /invoices/1/raw-json
200 OK
{
"model_dump_json": "{\"id\":1,\"issued_at\":\"2026-07-20T09:30:00Z\",\"total\":1249.507,\"balance\":\"300.50 EUR\"}"
}
$ GET /receipts/1
200 OK
{
"type": "receipt",
"data": {
"id": 1,
"issued_at": "2026-07-20T09:30:00Z",
"total": "1249.51"
}
}
Three things in that transcript are worth pausing on.
The baseline is not what v1 produced. With no serializer, mode="python" keeps the real datetime and Decimal objects, and mode="json" renders the Decimal as the string "1249.507" — Pydantic v2's default, chosen to avoid float precision loss. A v1 codebase with {Decimal: float} in json_encoders was emitting a JSON number. If you delete the encoder dict and add nothing, every Decimal in your API silently changes from number to string. That is a wire-format break your type checker will not catch.
The serializer applies to both modes. In the second response, python_mode shows '2026-07-20T09:30:00Z' — a str, not a datetime. The v1 encoder would have left a datetime there. This is usually what you want, but if internal code was relying on .dict() returning real objects, it now gets strings.
model_serializer fully replaces the output. The receipt response has no top-level id at all; the method's return value is the serialization. Field-level serializers on the same model would still run for values you reference inside it, but nothing outside your returned dict survives.
Choosing Between the Three
| Situation | Use |
|---|---|
| One field needs custom formatting | @field_serializer("field") |
| Several fields of the same type in one model | @field_serializer("a", "b") |
| A type that should always render the same way everywhere | Annotated[T, PlainSerializer(fn)] |
| The response envelope differs from the model structure | @model_serializer |
| Only the JSON path should be affected | @field_serializer(..., when_used="json") |
That last row is the closest thing to a literal json_encoders replacement. when_used accepts always (the default), unless-none, json, and json-unless-none. If you are migrating a large codebase and want to preserve v1's exact split — encoders on the JSON path, raw objects in .dict() — set when_used="json" and the two paths diverge again the way they used to.
Edge Cases and Gotchas
- Unknown types raise, they do not fall back. Without a serializer, an arbitrary class produces
PydanticSerializationError: Unable to serialize unknown type. There is no silentstr()coercion. Setarbitrary_types_allowedand attach a serializer; the config flag alone only gets you past validation. - Declare
return_type.PlainSerializer(fn, return_type=str)tells the schema generator what comes out, so OpenAPI advertisesstringrather than guessing. Skipping it means the generated schema can contradict the actual response body. - Decorator order matters with
@property. A@field_serializeris not a classmethod — it takesself. Applying@classmethodto it, out of habit fromfield_validator, fails at class definition. model_serializerandresponse_modelinteract. FastAPI validates the returned object againstresponse_modeland then serializes; amodel_serializerthat returns a shape not matching the declared response model will produce a response body that does not match your OpenAPI schema. Declare the envelope as the response model, or return a plain dict from the endpoint.- Inheritance. A serializer defined on a base model applies to subclasses. A subclass that redeclares the field without redeclaring the serializer still inherits it, which is usually right and occasionally surprising.
Verification
Assert on both dump modes, because that is exactly where v1 and v2 differ:
def test_serializer_applies_to_both_modes():
inv = Invoice(
id=1,
issued_at=datetime(2026, 7, 20, 9, 30, tzinfo=timezone.utc),
total=Decimal("1249.507"),
balance=Money(Decimal("300.5"), "EUR"),
)
assert inv.model_dump(mode="python")["issued_at"] == "2026-07-20T09:30:00Z"
assert inv.model_dump(mode="json")["total"] == 1249.507
The higher-value test during a migration is a golden-file contract test: capture a response body from the v1 service, and assert the v2 service produces byte-identical JSON for the same input. The Decimal-to-string change above is the kind of drift only that test catches.
Trade-offs and When Not To
Serializers are logic on the response path, and logic on the response path is easy to forget. If a field's presentation is a transport concern rather than a property of the data, consider giving the endpoint a dedicated response model with a plain str field and formatting in the service layer instead. That keeps the domain model honest and makes the wire format visible in the type, at the cost of one more class. For genuinely type-intrinsic formatting — money, durations, opaque IDs — PlainSerializer on an Annotated type is the better home, because it cannot be forgotten.
Do not reach for @model_serializer to add or rename a couple of fields; a computed_field and a field alias do that with less code and keep the schema accurate. Reserve it for cases where the wire shape is genuinely a different structure from the model.
FAQ
Why was json_encoders removed in Pydantic v2?
It was a Python-side post-processing dict applied only on the JSON path, so model_dump and model_dump_json could disagree, and it could not be inspected by the schema generator. Pydantic v2 moves serialization into the Rust core, where a serializer is attached to a field or a type and participates in both dump modes and in JSON Schema generation.
What is the difference between field_serializer and model_serializer?field_serializer replaces the output of one named field and leaves the rest of the model alone. model_serializer replaces the entire output of the model, so you return the complete dict yourself. Use field_serializer for type formatting and model_serializer when the wire shape differs structurally from the model.
When should I use PlainSerializer in an Annotated type instead of a decorator?
When the rule belongs to the type rather than to one model. PlainSerializer attached through Annotated travels with the type, so every field and every model using that type gets the same output without repeating a decorator.
Does a field_serializer affect model_dump as well as model_dump_json?
By default yes, in both python and json mode. You can restrict it with when_used, which accepts always, unless-none, json, and json-unless-none, so a serializer can apply only on the JSON path if that is genuinely what you want.
How do I serialize a type Pydantic does not know at all?
Set arbitrary_types_allowed on the model and attach a PlainSerializer through Annotated, or write a field_serializer with an explicit return_type. Without one of those, serialization raises PydanticSerializationError for the unknown type.
Related Reading
- Up to the topic: the Pydantic V2 Migration Guide covers the rest of the API surface that moved.
- The other config key with no mechanical equivalent is covered in model_config vs class Config.
- Validators moved at the same time as serializers — see migrating @validator to @field_validator and @root_validator to @model_validator.
- Once serializers are in place, nested model serialization explains how they compose through nested structures, and serialization performance covers the cost of adding Python callbacks to the Rust path.