Changing what comes out without changing what goes in - per field, per model, and only when the default is genuinely wrong.
Overview
What they do
Pydantic knows how to turn most values into JSON. Sometimes the default is not what you want. A datetime should go out in a particular format. A set should become a sorted list. A secret value should be masked. A serializer lets you control what comes out.
The important point is that a serializer changes only the output. The data inside the model stays exactly as it was validated. Nothing about how the object is stored or used in your code changes. You are choosing how it looks at the moment it leaves.
This article explains what serializers do and what they leave alone. It shows that you can get two different outputs from one serializer, depending on whether you dump to Python or to JSON. You will see how to serialise a whole model rather than a single field, which is useful for reshaping the output. The last section covers the schema problem, since a serializer can change the type a client sees, and your documentation needs to say so. It is the tool to reach for when the default output is almost right.
Use a serializer only when the default output is not what you want, because every one adds code you must maintain. The article shows the common cases, so you can tell quickly whether you need one at all. Often the answer is a small function of three lines. It also explains how serializers interact with the exclusion options you met earlier, so your output stays predictable when you combine them.
Think of a serializer as the last step before data leaves your program. It is the right place for presentation choices such as date formats, rounding and masking. It is the wrong place for business rules, which belong in validation. Keeping that split clear makes models much easier to reason about, and it keeps the output code short and predictable.
Pydantic's defaults are good: dates become ISO strings, decimals become strings, enums become values, nested models become nested objects. For most fields there's nothing to decide.
When the default is truly wrong, @field_serializer replaces the output for one field:
The method takes self and the value, and returns what should appear.
Worth knowing
@field_serializer("x") replaces the output for one field. Validation and the in-memory type are unaffected.
The method takes self and the field's value, and returns whatever should appear in the output.
when_used="json" limits a serialiser to JSON output, so model_dump() keeps the real Python object.
@model_serializer replaces the whole output, which is how you emit an envelope or a shape that is not just your fields.
A serialiser changes the output but not the schema, so the documented type and the actual output can silently disagree unless you set return_type.
For secrets prefer SecretStr or Field(exclude=True); a masking serialiser still leaks the value into repr and logs.
Custom Serializers: Changing What Comes Out
Per-field and per-model output control, and the strong arg for not using it.
A field serialiser
@field_serializer replaces the output for one field. The input side is untouched.
example_01.pyPydantic
from decimal import Decimal
from pydantic import BaseModel, field_serializer
class Price(BaseModel):
amount: Decimal
@field_serializer("amount")
def show_money(self, value: Decimal) -> str:
return "£%.2f" % value
p = Price(amount="12.5")
print("on the object:", p.amount, "(a", type(p.amount).__name__ + ")")
print("dumped :", p.model_dump())
print("as json :", p.model_dump_json())
Output
Only the output changes
Validation still produces the real type, so arithmetic and comparisons keep working. Serialisation is the last step, not a conversion.
example_02.pyPydantic
from decimal import Decimal
from pydantic import BaseModel, field_serializer
class Basket(BaseModel):
unit: Decimal
quantity: int
@field_serializer("unit")
def money(self, v: Decimal) -> str:
return "£%.2f" % v
b = Basket(unit="4.25", quantity=3)
print("still a Decimal:", b.unit * b.quantity)
print("comparison :", b.unit > Decimal("4"))
print("but dumped :", b.model_dump())
Output
This is the key property. A serialiser doesn't change check. Doesn't change what the field holds.
p.amount is still a Decimal. Math works, checks work. Each rule you attached still ran. The serialiser is the last step on the way out, not a conversion applied to the model.
That split is what makes the feature safe. You aren't weakening the model to meet a consumer's format preference.
Different output for JSON and Python
when_used restricts a serialiser to one mode, so internal dumps keep the real object.
example_03.pyPydantic
from datetime import date
from pydantic import BaseModel, field_serializer
class Module(BaseModel):
title: str
published_on: date
@field_serializer("published_on", when_used="json")
def pretty(self, value: date) -> str:
return value.strftime("%d %B %Y")
m = Module(title="Vectors", published_on="2026-08-26")
print("mode=python:", m.model_dump())
print("mode=json :", m.model_dump(mode="json"))
print("json string:", m.model_dump_json())
print()
print("The Python dump keeps a real date for code that needs one.")
Output
Serialising a whole model
@model_serializer replaces the entire output, which is how you emit a shape that is not simply your fields.
A serialiser is one way to redact. SecretStr and Field(exclude=True) are usually better, and the comparison is worth seeing.
example_05.pyPydantic
from pydantic import BaseModel, Field, SecretStr, field_serializer
class User(BaseModel):
name: str
email: str
token_a: str # redacted by a serialiser
token_b: SecretStr # hidden by its type
token_c: str = Field(exclude=True) # never serialised at all
@field_serializer("token_a")
def mask(self, v: str) -> str:
return v[:4] + "…" + v[-2:]
u = User(name="Ada", email="ada@vizlearn.in",
token_a="tok_live_abcdef", token_b="tok_live_ghijkl",
token_c="tok_live_mnopqr")
print("repr :", u)
print()
print("dump :", u.model_dump())
Output
When the default was already right
Most custom serialisers are display formatting in the wrong layer. Compare what a consumer can do with each.
example_06.pyPydantic
from datetime import date
from pydantic import BaseModel, field_serializer
class Pretty(BaseModel):
on: date
@field_serializer("on")
def fmt(self, v: date) -> str:
return v.strftime("%d %B %Y")
class Plain(BaseModel):
on: date
d = date(2026, 8, 26)
print("pretty:", Pretty(on=d).model_dump_json())
print("plain :", Plain(on=d).model_dump_json())
print()
print("A client can sort, filter and localise the second.")
print("The first has to be parsed back, and only by an English reader.")
Output
Two modes, two outputs
when_used restricts a serialiser to one direction:
"always" is the default. "json" applies only to model_dump_json() and model_dump(mode="json"). "unless-none" skips it for null values.
The "json" case is the handy one. It lets model_dump() keep a real date for code inside your program while JSON output gets what format a consumer needs. Without it, a formatting serialiser makes the Python dump useless for anything that wanted the object.
This is for shapes that aren't simply your fields. A JSON:API envelope, a legacy format you must emit, a payload where the structure differs from your inner one.
It's powerful and it's a big hammer. each field is now your job. A field added to the model doesn't appear in the output until somebody remembers to add it to the serialiser. That divergence is just the drift models exist to prevent.
A mode="wrap" variant receives a handler that makes the default output, letting you take that and adjust it rather than rebuilding it. That is much safer. New fields still flow through:
@model_serializer(mode="wrap")
def add_meta(self, handler):
data = handler(self)
data["_type"] = "module"
return data
Prefer the wrap form when you're boosting rather than replacing.
The schema problem
Here is the sharp edge, and it's easy to miss.
A serialiser changes the output. It doesn't change the schema. So a Decimal field serialised as "£12.50" still appears in the made schema as a decimal, your API docs says one thing, and the endpoint returns another.
For a public API that is a real defect. Made clients will give a type that doesn't match the data they receive.
return_type fixes it:
@field_serializer("amount", return_type=str)
Now the output schema reports a string. The docs matches reality. Set it when the serialised type differs from the field type. This Is most of the time.
Secrets: a serialiser is the weakest option
Masking a token with a serialiser is a common idea and the worst of the three on hand.
@field_serializer making tok_…ef keeps the value out of dumps and leaves it in repr, in logs, in tracebacks. In any code that reads the field.
SecretStr hides it from repr and needs .get_secret_value(). This Makes access deliberate and greppable.
Field(exclude=True) keeps it out of each output fully.
For anything truly secret, use the type and the exclusion. Reach for a serialiser only when you want a *partial* value in the output on purpose. Showing the last four digits of a card. This Is a product choice rather than a security one.
The argument against most custom serialisers
Most custom serialisers are display formatting that has ended up in the wrong layer.
"26 August 2026" is a formatting choice. It assumes English, a date order. A reader rather than a program. A client receiving it can't sort by it, can't filter on it, can't show it in another language. Has to parse it back to do anything handy.
"2026-08-26" is data. each client can sort it, compare it and format it but that client's user needs.
The same arg applies to currency symbols, thousands separators, scaled times ("3 days ago") and rounded numbers. All of them are choices that belong where the audience is known. This Is the presentation layer, not the data model.
So the honest guidance: before writing a serialiser, ask whether the default was wrong or merely unseen. If it's being changed for a human reader, that logic probably belongs closer to the human.
When they are genuinely right
Emitting a legacy format you don't control and can't change.
Redacting on purpose, where a partial value is the intended product action.
Wrapping in an envelope needed by a specification, best done with mode="wrap".
finding a form that is costly to store but cheap to derive. Here A found field would be the option.
Interoperating with a thing exact — a system that wants timestamps in milliseconds, or booleans as "Y"/"N".
Each of those is a real constraint from outside, rather than a preference from inside.
The info argument
Both decorators accept an optional info param carrying mode, context and the exclusion settings in force.
mode lets one serialiser behave differently for Python and JSON output without needing when_used.
context is the notable one. It's a dict passed to the dump call. This Makes output dependent on a thing the model doesn't know:
@field_serializer("salary")
def maybe_hide(self, v, info):
if (info.context or {}).get("role") != "hr":
return None
return v
Used carefully that is a clean answer to field-level visibility rules. Used freely it makes an endpoint whose output can't be predicted from the model. Split output models per audience are easier to reason about and to doc.
Serialising unusual types
The other legitimate reason to write a serialiser is a field whose type Pydantic has no view about. A third-party object accepted via arbitrary_types_allowed.
Such a field has no default output. A dump either fails or makes a thing unhelpful. A serialiser turning it into a dict or a string isn't a formatting preference. It's the only way the model can be serialised at all.
If you find yourself doing this for a type used in a few models, the better shape is a custom type that knows how to serialise itself. The knowledge lives with the type rather than being repeated in each model that holds one.
Testing what you emit
Custom output is worth testing right. It's the part of a model that no other test exercises.
check tests make models. They don't check what comes out. A serialiser can be broken for months while each check test passes.
The test is short — build a model, dump it, assert on the result — and it's the only thing standing between a formatting change and a silently altered API response.
Assert on model_dump() and model_dump_json() apart when when_used is involved. That is just the case where they differ.
The decision, restated
Before writing one, three questions.
Is the default really wrong, or just unseen? ISO dates and decimal strings look odd and are correct.
Is this for a machine or a person? Machines want data. People want formatting. That belongs where the person is.
Will the schema still be true? If not, set return_type, or you're publishing a doc that lies about your own output.
Most proposed serialisers fail one of those. This Is the reason this module argues against its own subject as much as for it.
Custom Serializers: summary
@field_serializer for one field, @model_serializer for the whole output, mode="wrap" when boosting rather than replacing so new fields still flow through.
when_used="json" keeps Python dumps handy. return_type keeps the schema honest. info.context makes output gated when that is truly needed.
For secrets prefer SecretStr and Field(exclude=True). And reach for a serialiser when an external constraint demands a shape. Not when a default merely looks unseen.
What to remember about Custom Serializers
output is the last thing that happens and the first thing a consumer sees.
Pydantic's defaults are chosen so that output is data. Sortable, close, parseable, unambiguous across locales. Most reasons to replace them turn out to be formatting for a human. Humans are downstream of the layer that knows who they're.
replace when a thing outside truly demands a shape. Set return_type so the schema keeps telling the truth. And test what you emit. No other test does.
Mistakes people make with Custom Serializers
Formatting for a person in a data layer. Covered at length. Still the most common. A date rendered as "26 August 2026" can't be sorted, filtered or localised by anyone receiving it.
**Losing return_type.** The output changes, the schema doesn't. Your docs quietly starts showing a thing the endpoint no longer returns. made clients then give a type that doesn't match the data. The failure surfaces in someone else's code.
**Using a plain @model_serializer for a small addition.** Replacing the entire output to add one key means each field is now hand-kept. A field added to the model six months later never appears. mode="wrap" adds the key and lets all else flow through untouched.
Masking a secret with a serialiser and stopping there. The dump is clean and the value is still in repr, still in logs, still in tracebacks, still readable by any code holding the model. SecretStr and Field(exclude=True) address the parts a serialiser can't.
Not testing the output. check tests make models. They never look at what comes out. A serialiser can be broken for months while the suite stays green. The first person to notice is a consumer.
based on context that isn't always passed. A serialiser reading info.context needs a sensible default for each call that doesn't supply one, including the ones in tests and the ones a framework makes inside. (info.context or {}) rather than info.context[...] is the gap between a graceful default and a TypeError during a response.
The test
Is the default wrong for a machine, or merely unformatted for a person?
Only the first is a serialiser's job. all else. Currency symbols, readable dates, scaled times, rounded numbers. Is a choice about an audience. Belongs where the audience is known.
Applying that one question honestly drops most proposed serialisers. Makes the left ones easy to justify.
Questions people ask about Custom Serializers
How do I change how one field is written out? Decorate a method with @field_serializer("published_at") and return the value you want. Only the output changes, and validation on input is untouched.
What is the difference between @field_serializer and @model_serializer?field_serializer reshapes a single field. model_serializer receives the whole model and returns the dict you want, so it can rename, merge or drop fields in one place.
How do I apply a serialiser only to JSON output? Pass when_used="json" to the decorator. The default is "always", and "json-unless-none" and "unless-none" are the other options.
Does a custom serialiser change the JSON schema? No, the schema still describes the original type. Set return_type on the serialiser or tune the schema with json_schema_extra if the output shape differs.
Should I use a serialiser to hide a password? Prefer Field(exclude=True) or a SecretStr, which are simpler and harder to bypass. A serialiser that blanks the value is easy to forget when you add the next field.
Can the serialiser see the other fields? Yes. A field_serializer method receives self, so it can read other attributes, and it can take an info argument (a SerializationInfo) for context such as the mode.
A final framing
It helps to think of output as the boundary in the other direction.
check is where you stop trusting the outside world and convert its text into your types. output is where you stop given your types and give a thing the outside world can read.
Both are conversions at an edge. Both work best when they convert to a thing *neutral*. check doesn't try to guess what a caller meant. It accepts unambiguous readings and refuses the rest. output should be symmetrical. Emit data with a single unambiguous reading, and let the consumer decide how to present it.
A custom serialiser that formats for a human breaks that symmetry. It takes a value with one meaning and makes one with a presentation baked in. This The next layer has to undo before it can do anything else.
That is the underlying reason the advice in this module runs against its own subject. The feature is well designed and at times key. It's just that most of the reasons people reach for it are reasons to do the work one spot else.
Check yourself
0 of 4
Answer without scrolling back up.
Does a `@field_serializer` change what the field holds in memory?
It runs on the way out. The attribute is still the validated type, so arithmetic and comparisons keep working - which is what makes the feature safe.
A Decimal field is serialised as "£12.50". What does the schema say?
Serialisers change output but not the schema, so docs and generated clients describe a type the endpoint no longer returns. `return_type` keeps them honest.
Why prefer `@model_serializer(mode="wrap")` over the plain form?
The plain form makes every field your responsibility, so a field added later silently never appears. Wrapping augments the default rather than replacing it.
What is the argument against serialising a date as "26 August 2026"?
ISO output is data every client can work with. Formatting assumes a language and a reader, and belongs in the layer that knows who the audience is.
Cheat sheet
Custom Serializers
Control how individual fields or entire models serialize, and understand when leaving the default alone is the better choice.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.