Optional[T] or T | None: pick one
Python has two ways to spell “this value might be None”:
Optional[str] from typing, and
str | None using PEP 604 syntax. They mean the same
thing. The choice between them is style. The real decision
— the one that affects your code correctness — is whether
the field is always present and sometimes None, or
sometimes absent from the payload entirely. Python lets you collapse
those two cases into one annotation. Most people do. It is a mistake,
and it is the kind of mistake that only hurts later.
Start with a single Stripe customer response. The address
field is always there. When the customer has not added one, Stripe
sends it as null.
{ "id": "cus_abc", "address": null, "email": "j@example.com" }
{ "id": "cus_def", "address": { "city": "NYC" }, "email": null }
Now compare that to a Shopify product. Some products have a
vendor field. Some simply do not include it.
{ "id": 1, "title": "Widget", "vendor": "Acme" }
{ "id": 2, "title": "Gizmo" }
In TypeScript these two cases force you to write different types —
T | null vs T?. Python does not force you. A
Pydantic model can paper over both cases with the same annotation:
class Customer(BaseModel):
id: str
address: Optional[Address] = None # always sent, may be null — OK
class Product(BaseModel):
id: int
title: str
vendor: Optional[str] = None # sometimes absent — ALSO OK? Both models parse successfully. Both produce an instance with a single attribute. And now your code cannot tell the two cases apart.
A field that is always sent and sometimes None is not the
same as a field that is sometimes missing. Python lets you pretend
they are. They are not.
Three states, three declarations
The Stripe/Shopify contrast has three distinct answers the model must support, not two. Each has a different declaration:
from pydantic import BaseModel, Field
from typing import Optional
class Customer(BaseModel):
id: str # always present, never null
address: Optional[Address] # always present, may be null
email: Optional[str] # always present, may be null
class Product(BaseModel):
id: int
title: str
vendor: Optional[str] = None # may be absent entirely
The difference between Optional[Address] and
Optional[str] = None is the = None default.
Without a default, Pydantic requires the key to be in the input;
None is allowed as a value but the key is not. With the
default, the key may be missing from the input entirely and the
attribute falls back to None.
The tool, when you paste multiple samples, picks up this distinction
automatically. A field that appears in every sample and carries
null in at least one emits Optional[T] with no
default. A field that is missing from at least one sample emits
Optional[T] = None. Paste one sample and it cannot tell
— which is why the single-sample case warns you to provide more.
The Optional[str] = None trap
Here is the subtle part. If you want to distinguish “the user
sent null” from “the user omitted the
key” — say, in a PATCH endpoint where omission means
“do not change” and null means “clear
it” — Optional[str] = None collapses both
into the same attribute value. Pydantic does record which fields were
actually set, but you have to opt in:
class UpdateProfile(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
update = UpdateProfile.model_validate({"email": None})
update.email # None — explicitly cleared
update.phone # None — not mentioned
update.model_fields_set # {'email'} — the escape hatch
If the semantics matter — and for PATCH-style partial updates
they always do — use model_fields_set to see which
keys were actually in the input. If you need this everywhere, use
TypedDict with NotRequired instead of a
BaseModel; TypedDict preserves key presence natively.
from typing import TypedDict, NotRequired
class UpdateProfile(TypedDict):
email: NotRequired[Optional[str]] # may be absent OR null
phone: NotRequired[Optional[str]] # same
Here "email" in update tells you whether the key was
passed. The distinction Optional[str] encoded inside
NotRequired[...] carries the null-vs-string decision.
Two axes, two annotations, no ambiguity.
Optional[T] or T | None: pick one
Once you have the three-state discipline above, the remaining decision
is purely cosmetic. Optional[T] (from typing)
and T | None (PEP 604, Python 3.10+) are exactly
equivalent. The union operator at type level was added precisely to
replace the import. Reasons to pick each:
-
Optional[T]— works on Python 3.7+. Required if your target is 3.9 or below, or if your linter runs withoutfrom __future__ import annotationsin every file. The tool emits this by default for maximum compatibility. -
T | None— Python 3.10+. No import, shorter, reads like the union it is. The style most modern codebases standardize on. Enable it project-wide with apyupgradepass once your minimum version allows. - Mixing both in one codebase — avoid. Lint rules exist for this; use them. The inconsistency reads as accidental to any reader.
One real asymmetry worth knowing. In a function signature, these two subtly differ:
def f(x: Optional[str]): ... # x is required, must be str or None
def g(x: Optional[str] = None): ... # x may be omitted; defaults to None
The Optional[...] spelling used to be (ambiguously) read
as implying a default of None. PEP 484 officially ended
that; both spellings require the default to be explicit. Old habits
survive in older codebases. If you inherit one, be ready for the
argument.
Why the tool chooses the conservative default
Faced with a single JSON sample where a field is null,
the tool cannot know whether that null is typical or
exceptional. It emits the narrower annotation — the key is
required, the value may be None — and lets you
widen if reality contradicts. The opposite default —
“make everything Optional[T] = None just in
case” — is a false economy. It forces every downstream
caller to handle an absent key the server never actually omits, and
erases the nullability signal you were trying to capture.
If you want the types to reflect the contract rather than a single sample, paste enough samples that a field’s presence or absence is stable across all of them, or paste the OpenAPI schema, or override per-field. The conservative default is the one most likely to be correct with the information provided; widening is easier than discovering a collapsed ambiguity three months later in a PATCH handler.
The short version: three states — present-and-typed,
present-and-null, absent — three declarations. Optional
and T | None are the same thing; pick one and stick with
it. The real decision is the one about the default, not about the
syntax.