Optional does not mean optional. The three-way difference between missing, null and defaulted, and the mutable-default trap.
Overview
The rule, first
A field is required when it has no default value. That is the entire rule, and the type annotation plays no part in it.
class Module(BaseModel):
title: str # required
minutes: int = 10 # optional, defaults to 10
Leave out title and the model refuses to be built, with an error whose type is missing. Leave out minutes and you get 10.
Everything confusing about this topic comes from one word being borrowed for two jobs.
Worth knowing
A field is required when it has no default. The type is irrelevant to that decision.
Optional[str] means str | None. To make a field genuinely optional you must also write = None.
model_fields_set tells you which fields the caller actually supplied — the only way to distinguish “omitted” from “sent as null”.
model_dump(exclude_unset=True) returns only what was supplied. This is the correct shape for a PATCH request, where absent means “leave alone”.
Unlike plain Python, a mutable default such as [] is safe here — Pydantic deep-copies it per instance. Use default_factory when the value must be computed each time.
Defaults are not validated unless you set validate_default=True. It is a deliberate speed trade, and it will hide a typo in a default.
Required, Optional and Defaults: The Three-Way Distinction
Why Optional does not mean optional, and how to tell "not sent" from "sent as null".
A field with no default is required
That is the whole rule. Leave it out and the model refuses to exist, with the error type missing.
example_01.pyPydantic
Output
Optional[int] is not an optional field
This is the single most common misreading in the library. Optional[int] means “int or None”. It says nothing about whether the field must be supplied.
example_02.pyPydantic
Output
Three states, not two
“Not sent” and “sent as null” are different facts, and an API often needs to tell them apart. model_fields_set is how you ask.
example_03.pyPydantic
Output
The mutable default trap
In ordinary Python a mutable default is shared by every call. Pydantic does not have that bug — but the habit it teaches, default_factory, is still what you want for anything computed.
example_04.pyPydantic
Output
Defaults are not validated by default
Pydantic trusts what you wrote in the class body and skips checking it. That is a speed decision, and it means a wrong default sits there quietly until you ask for it to be checked.
example_05.pyPydantic
Output
Putting it together
A realistic model: some fields the caller must supply, some with sensible defaults, one genuinely nullable, and a list that starts empty.
example_06.pyPydantic
Output
Optional does not mean optional
Optional[str] comes from the typing module, where it means exactly one thing: str or None. It is a statement about which *values* are allowed. It says nothing whatsoever about whether the field has to be supplied.
class Strict(BaseModel):
note: Optional[str] # nullable, and STILL REQUIRED
Strict(note=None) works. Strict() raises missing. This surprises nearly everyone once, and the surprise is entirely the fault of English rather than of Pydantic — Optional was named for type theory, not for form fields.
To get the behaviour people usually mean, add the default:
class Truly(BaseModel):
note: Optional[str] = None # nullable AND optional
It is worth internalising as two separate questions. *Can this value be null?* is answered by the type. *Must the caller provide it?* is answered by whether there is a default. They are independent, and all four combinations are legitimate and occasionally useful.
Pydantic v1 blurred this: it made Optional fields default to None automatically. Pydantic v2 stopped, precisely because the implicit default hid the distinction. If you find old code that relies on it, this is one of the changes that will bite during a migration.
Missing and null are different facts
For most models the difference does not matter. For anything that updates existing data, it matters enormously.
Consider a PATCH endpoint. A client sends {"title": "New Name"}. What should happen to summary? Obviously nothing — they did not mention it. Now a client sends {"summary": null}. What should happen? Just as obviously, summary should be cleared. Those are opposite intentions, and after validation both fields are None in the model.
model_fields_set recovers the distinction. It is a set of the field names the caller actually supplied:
And model_dump(exclude_unset=True) uses it, returning only the fields that were provided. That output is the correct thing to apply to an existing record: absent keys are absent, and an explicit None is present and clears the value.
Getting this wrong is a real and common bug. An update endpoint that dumps the whole model and writes every field will happily overwrite six columns with None because the client only mentioned one.
The mutable default trap, and why it is not one here
Every Python programmer eventually learns this the hard way:
The list is created once, when the function is defined, and shared by every call that does not pass one. The second call sees the first call's data.
Pydantic does not have this bug. A default like tags: List[str] = [] is deep-copied for each new instance, so two models never share a list. You can write the natural thing and it is safe.
default_factory still matters, though, for defaults that must be *computed* rather than copied. A timestamp, a generated id, a counter — anything whose value should reflect the moment of creation:
Written as = datetime.now() that would freeze the time the class was defined, and every model would claim to have been created at import.
Defaults are not checked
This one is quiet and occasionally costly. By default, Pydantic does not validate default values:
class Sloppy(BaseModel):
minutes: int = "not a number"
That class definition raises nothing, and Sloppy().minutes is the string. The reasoning is speed — you wrote the default yourself, so checking it on every instantiation is work with no expected payoff.
The cost is that a mistyped default is invisible until something downstream does arithmetic. If you would rather be told, model_config = ConfigDict(validate_default=True) turns the checking on, at a small cost per instance.
For a model with hand-written defaults that never change, the default behaviour is fine. For a model whose defaults come from configuration or a constant defined elsewhere, turning validation on is cheap insurance.
Choosing well
A few habits that keep models honest.
Prefer required fields. Every default is a decision made on behalf of a caller who did not make it, and a model where everything is optional documents nothing — a reader cannot tell what is actually guaranteed.
Do not use None as a stand-in for a real default. If a missing duration means ten minutes, write minutes: int = 10, not Optional[int] = None with a or 10 scattered through the code that follows.
Reserve None for values that are genuinely absent in the domain: a summary nobody has written yet, an end date for something still running. Then None carries meaning rather than marking a gap in the model.
And when you are modelling an update rather than a creation, reach for exclude_unset early. It is easier to build the endpoint correctly than to work out later why six columns went blank.
The ellipsis, and other spellings of "required"
You will meet Field(...) in older code and in a lot of documentation:
title: str = Field(..., min_length=3)
The literal Ellipsis was Pydantic v1's way of saying "there is no default, this is required", because Field needed something in the default position. It still works in v2, and it is redundant: omitting the default says the same thing.
title: str = Field(min_length=3) # identical, and clearer
Prefer the second. The first makes readers who have not met the convention stop and look it up, and it buys nothing.
Defaults that depend on the environment
A default does not have to be a literal. default_factory takes any callable, which means a default can come from configuration, the clock, or a generator:
The third one is worth a caution. Reading configuration inside a default factory works, but it happens at model-construction time rather than at import, which makes it harder to reason about and hard to override in tests. For anything that is really configuration, a settings model is the better home — that is a module in the last tier.
There is also a form of default_factory that receives the already-validated data, letting a default depend on other fields. It is powerful and easy to overuse; a value computed from other fields is often better expressed as a computed_field, which does not pretend to be an input.
How this appears in the schema
The distinction between required, optional and nullable is not just a Python concern — it shows up in the generated JSON Schema, and therefore in your API documentation and any client generated from it.
A field with no default appears in the schema's required array. A field with a default does not, and its default is recorded. A nullable field's type becomes an anyOf including null.
So the four combinations produce four genuinely different contracts for a consumer:
str — must be sent, cannot be null. str = "x" — may be omitted, cannot be null. Optional[str] — must be sent, may be null. Optional[str] = None — may be omitted, may be null.
Reading them as sentences like that is the fastest way to check you have written what you meant. If the sentence sounds wrong for your API, the annotation is wrong.
Designing create and update models
The place all of this comes together is a resource with more than one shape.
Create takes what a caller may supply. The server-assigned id is absent entirely — not optional, absent — because including it invites a caller to try setting it.
Update, for a PATCH, has every field optional with a None default, and is dumped with exclude_unset=True so that untouched fields stay untouched.
Output has everything the caller is allowed to see, with server-assigned fields required, because by the time you are returning one they exist.
class ModuleCreate(BaseModel):
title: str
minutes: int = 10
class ModuleUpdate(BaseModel):
title: Optional[str] = None
minutes: Optional[int] = None
class ModuleOut(BaseModel):
id: int
title: str
minutes: int
Three small classes rather than one clever one. Each says exactly what it means, and none of them needs a comment explaining which fields apply when.
The temptation is always to collapse them into a single model with everything optional. It looks like less code and it is: it is also a model that documents nothing, generates useless API docs, and cannot tell a client what is guaranteed in a response.
A note on validation order
Fields are validated in declaration order, and defaults are filled as part of that pass. This matters once you write a validator that reads another field: it can only see fields declared *above* it, because the ones below have not been processed yet.
If a rule needs the whole object, that is what model_validator(mode="after") is for — it runs once, after every field is in place. Trying to express a cross-field rule as a field validator on whichever field happens to come last works until somebody reorders the class.
A checklist for a field you are about to write
Four questions, in order, and the annotation falls out of the answers.
Can this be absent? If yes, it needs a default. If no, leave the default off and let the model refuse.
Can this be null, meaningfully? Only if "no value" is a real state in your domain — an unwritten summary, an unfinished end date. If None would just mean "nobody bothered", it is not nullable; it is defaulted.
Is the default a constant or does it depend on when we are? A constant goes in directly. Anything computed — a time, an id, a fresh container — goes in default_factory.
Am I creating or updating? Creating means required fields are required. Updating means everything is optional and you dump with exclude_unset=True.
Most confusing models are the result of answering the second question with "I suppose so" instead of thinking about it. Optional[X] = None is the annotation people reach for when they have not decided, and a model full of them has quietly recorded that nothing was ever decided.
What this buys downstream
The payoff for being precise here shows up somewhere else entirely: in the code that reads the model.
If summary: Optional[str] = None genuinely means "may not have been written yet", then if module.summary: is a meaningful branch about the domain. If it means "we were not sure", every reader has to defend against None on every field, and the type system has stopped helping.
Required fields are a promise to the rest of your program. Each one you make lets code downstream stop checking. That is the actual product of this module: not the syntax, but the discipline of deciding what is guaranteed and then writing it down where the compiler, the schema and the next reader can all see it.
One last distinction
There is a fourth state people occasionally need, beyond required, defaulted and nullable: a field that may be absent but has no sensible default, where you genuinely want to know whether it was supplied.
That is what model_fields_set and exclude_unset exist for, and it is worth naming explicitly because the instinct is to invent a sentinel — a magic string, a -1, a custom UNSET object — and thread it through the code. Pydantic already tracks the answer. Reach for the sentinel only when the value has to survive serialisation, which is rare, and think hard before you do, because every consumer of that data now has to know about your magic value.
Next
The next module is about the moment a model says no: how to read a ValidationError in full, what each part of an entry means, and how to turn one into a message a user can act on.
Check yourself
0 of 4
Answer without scrolling back up.
A field is declared `note: Optional[str]` with no default. Is it required?
`Optional[str]` means `str or None`, which is about allowed values. Requiredness is decided by whether a default exists. Pydantic v1 added the default implicitly; v2 deliberately does not.
Which tells you whether a caller actually supplied a field?
After validation an omitted field and an explicitly null one both read as `None`. `model_fields_set` is the only record of what was actually sent.
What does `model_dump(exclude_unset=True)` produce, and why does it matter?
An update that dumps every field will overwrite untouched columns with defaults or None. Excluding unset fields keeps 'not mentioned' meaning 'leave alone'.
Why use `default_factory=datetime.now` instead of `= datetime.now()`?
`datetime.now()` is evaluated once, when the class body runs, so every instance would claim the same creation time. A factory is called per instance.
Cheat sheet
Required, Optional and Defaults
This is the single most common misreading in the library. Optional[int] means “int or None”. It says nothing about whether the field must be supplied.
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.