← Essays
§ Essay

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.

— the house style

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 without from __future__ import annotations in 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 a pyupgrade pass 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.