JSON Schema Customization in Pydantic and FastAPI
Your OpenAPI document is not written; it is derived. FastAPI asks each Pydantic model for its JSON Schema and assembles the results, which means the quality of your API documentation is a direct function of how much your models say about themselves. The useful question is therefore not "how do I edit the schema" but "what does my model already imply, and what do I need to tell it".
This guide belongs to Advanced Pydantic Validation and Serialization. It covers the generation pipeline, the two schemas every model has, and how to make a type of your own document itself properly. Sample payloads, tagged unions and control over the finished document each have their own page beneath this one.
Prerequisites
You should know how Field constraints are declared, since most schema customization is just richer field declarations, and how nested serialization produces a response, since the serialization schema is the description of that output.
If your models predate Pydantic v2, note that the config key controlling extra schema keys was renamed — schema_extra became json_schema_extra — and that the renamed keys fail quietly. The Pydantic V2 Migration Guide covers why.
Core Mechanics: The Generator Walks a Compiled Schema
Defining a model compiles a core schema: a nested structure describing how to validate this type, built from your annotations. The JSON Schema generator does not read your class body. It walks that compiled core schema and translates each node into JSON Schema keywords.
That indirection explains the single most consequential property of the whole system. A Field(le=100) becomes a node in the core schema that the generator recognises and renders as "maximum": 100. A field_validator becomes a node that says "call this Python object", and the generator has nothing to say about it. Declarative metadata survives the journey; imperative logic does not. Every "why doesn't my rule show up in the docs" question resolves to this.
Two modes, two documents
Because a model describes both what may be sent and what will be returned, the generator runs in two modes and produces two different documents. The example has a model with a defaulted-and-aliased field, a nested model, and a computed field:
class Order(BaseModel):
id: int
reference: str = Field(default="unset", alias="ref")
items: list[LineItem]
@computed_field
@property
def item_count(self) -> int:
return len(self.items)
The recorded output of asking for each mode:
$ GET /validation-schema
200 OK
{
"required": [
"id",
"items"
],
"property_names": [
"id",
"items",
"ref"
],
"defs": [
"LineItem"
],
"items_ref": {
"$ref": "#/$defs/LineItem"
}
}
$ GET /serialization-schema
200 OK
{
"required": [
"id",
"items",
"item_count"
],
"property_names": [
"id",
"item_count",
"items",
"ref"
]
}
Three differences, each of them meaningful to a consumer. The computed field item_count is absent from the validation schema entirely and present in the serialization schema, because a caller cannot supply it and will always receive it. It is also listed as required on the way out — a guarantee, where required on the way in is an obligation. And reference appears under its alias ref in both, because the schema describes the wire format rather than your attribute names.
The items property is rendered as {"$ref": "#/$defs/LineItem"} rather than inline. That is deliberate deduplication: each model is declared once under $defs and referenced wherever it is used. Every OpenAPI consumer resolves references, and the indirection is what allows a client generator to emit one class per model instead of an anonymous duplicate at each use site. Suppressing it to make the document more readable trades a machine-usable artefact for a human-skimmable one, which is usually a bad trade.
Production Implementation: Making Types Document Themselves
Push metadata onto the field
The lowest-effort improvement to any OpenAPI document is fuller Field declarations. A description explains intent, an example lets a reader copy something that works, and a constraint documents the rule it enforces:
class CreatePayment(BaseModel):
amount_cents: Annotated[int, Field(gt=0, description="Charge amount in minor units.",
examples=[1999])]
currency: Annotated[str, Field(description="ISO 4217 code.", examples=["USD"])]
idempotency_key: Annotated[str, Field(description="Client-generated dedupe key.")]
The reason to prefer this over documentation written elsewhere is not tidiness. Metadata attached to the field cannot drift away from the rule it describes, because they are the same declaration. Prose in a wiki describing a validation rule is correct until someone changes the rule.
For keys JSON Schema supports but Pydantic has no argument for — vendor extensions, external documentation links, whole-payload samples — json_schema_extra is merged into the model's schema object verbatim. Sample payloads in particular have more mechanisms than you would expect, and they land in different parts of the document with different precedence; Examples in the OpenAPI Schema with FastAPI sorts out which one to use where.
Teach a custom type both halves
When a domain concept deserves its own type, that type has to answer two independent questions: how do I validate this, and how do I describe this. They are answered by two different hooks, and implementing only the first is the common mistake.
class Sku(str):
"""A domain type that teaches Pydantic how to validate it AND how to document it."""
@classmethod
def __get_pydantic_core_schema__(cls, source: type, handler: GetCoreSchemaHandler) -> CoreSchema:
def check(value: str) -> "Sku":
if not value.startswith("SKU-"):
raise ValueError("sku must start with SKU-")
return cls(value)
return core_schema.no_info_after_validator_function(check, core_schema.str_schema())
@classmethod
def __get_pydantic_json_schema__(
cls, schema: CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
# Without this the core schema alone would publish a bare {"type": "string"}.
json_schema = handler(schema)
json_schema.update(pattern="^SKU-", examples=["SKU-1001"], description="Catalogue SKU.")
return json_schema
__get_pydantic_core_schema__ builds the validator: a string schema with a function applied after it. __get_pydantic_json_schema__ receives the generated schema, calls the handler to get the default translation, and enriches it. With both in place, the published schema for the field is:
$ GET /custom-type-schema
200 OK
{
"description": "Catalogue SKU.",
"examples": [
"SKU-1001"
],
"pattern": "^SKU-",
"title": "Sku",
"type": "string"
}
Had only the first hook been implemented, that object would have been {"title": "Sku", "type": "string"} — correct, enforced at runtime, and useless to anyone reading the documentation. The rule is enforced either way; the difference is entirely in what consumers can learn.
The validation side works as declared, and the error carries a full path into the nested structure:
$ POST /orders/ {"id": 1, "ref": "PO-9", "items": [{"sku": "1001", "quantity": 2}]}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"items",
0,
"sku"
],
"msg": "Value error, sku must start with SKU-",
"input": "1001",
"ctx": {
"error": {}
}
}
]
}
Note the type is value_error — the generic code any Python function produces. The pattern you added for documentation is not enforcing anything; the function is. If you would rather callers received a machine-readable string_pattern_mismatch, express the rule as a pattern constraint in the core schema instead of as a function, and both the enforcement and the documentation come from one declaration.
Documenting closed sets and optionality
Two very common field shapes produce schema output that surprises people, and both are worth knowing because clients are generated from them.
A field whose value must come from a fixed set should be typed as an Enum or a Literal, not as a str with a validator checking membership. Both produce a JSON Schema enum keyword listing the permitted values, which means the interactive docs render a dropdown and a generated client produces a real enumerated type. A validator enforcing the same rule publishes an unconstrained string, so every consumer has to discover the permitted values by trial and error.
class Currency(str, Enum):
usd = "USD"
eur = "EUR"
class Payment(BaseModel):
currency: Currency # publishes enum: ["USD", "EUR"]
channel: Literal["web", "mobile"] # publishes enum: ["web", "mobile"]
The difference between the two is mostly ergonomic. An Enum gives you a named Python member to pass around and gets a $ref to its own definition, so it is the better choice when the set is used in several places. A Literal is lighter and inlines, which suits a set used once.
Optionality is the second surprise. Pydantic v2 emits OpenAPI 3.1, where a nullable field is expressed as a union with null rather than through a separate flag:
"nickname": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}
This is correct 3.1 and it is what generates the enormous diff during a v2 migration. It also makes visible a distinction worth being deliberate about: str | None = None is a field that may be absent or explicitly null, while str | None with no default is a field that must be present and may be null. Those are different contracts, and only the presence of default in the schema tells a consumer which one you meant.
Where a union needs help
A field typed as a union of several models produces a schema listing every alternative, and both the schema and the resulting error messages are considerably more useful when Pydantic knows which field distinguishes the members. That is a substantial topic of its own, covered in Discriminated Unions in OpenAPI with Pydantic.
Controlling the finished document
Everything above shapes the schema of a model. The document those schemas are assembled into has its own controls — operation identifiers, documented error responses, routes excluded from publication, and the ability to post-process the whole document before it is served. Those live at the application and route level rather than on models, and are covered in Customizing OpenAPI Schema Generation in FastAPI.
Performance Notes
Schema generation is startup work, not request work. FastAPI builds the OpenAPI document on the first request for it and caches the result, so nothing here appears in your per-request latency. What it does affect is import time and cold starts on serverless platforms, where a very large model graph makes core schema compilation visible.
The document's size has downstream costs that are easy to miss because they are not paid by your service. A sprawling schema slows client SDK generation, bloats the payload served to anyone loading the interactive docs, and makes the document harder to review in a pull request. Sharing models so they are referenced through $defs rather than duplicated keeps the document proportional to the number of distinct shapes rather than the number of endpoints.
If you find yourself generating models dynamically at runtime — one per tenant, say — remember that each one compiles its own core schema and stays in memory. That is a slow leak rather than a per-request cost, but it is a real one.
Testing Strategy
The schema is a build artefact, and it should be tested like one rather than inspected by eye in the docs UI.
def test_schema_publishes_the_rules_clients_need():
schema = CreatePayment.model_json_schema()
amount = schema["properties"]["amount_cents"]
assert amount["exclusiveMinimum"] == 0
assert amount["examples"] == [1999]
assert amount["description"]
Two broader checks are worth adding once and leaving in place. First, assert that every property in your public request models carries a description — a single loop over model_json_schema()["properties"] turns "we should document our fields" into something CI enforces. Second, commit the generated openapi.json and regenerate it in CI, failing on an unreviewed diff. That turns every accidental contract change into a visible line in a pull request, which is far more reliable than hoping someone notices.
When a schema and a validator can disagree — as with the Sku type above, where a documented pattern and an enforcing function are separate statements — write the test that feeds the model a value matching the documented pattern and asserts it is accepted. That is the assertion that catches documentation quietly becoming a lie.
Failure Modes and Diagnosis
A rule is enforced but undocumented. Diagnosis: it is expressed as a validator function. The generator cannot see inside one. Re-express it as a constraint if possible, or add the equivalent keyword through json_schema_extra and test that the two agree.
A custom type documents as a bare string or integer. Diagnosis: __get_pydantic_json_schema__ is not implemented. The core schema hook alone gives validation without description.
Request and response schemas disagree about a field name. Diagnosis: a validation alias without a matching serialization alias, or the reverse. Set both when the wire name must round-trip.
A generated client is missing a field the API returns. Diagnosis: the client was generated against the validation schema, or the field is computed and the response model was not the one you thought. Compare both modes for the model in question.
A config key has no effect on the schema. Diagnosis: a v1 key name. schema_extra is silently inert in v2; the key is json_schema_extra.
The document changes on every deploy. Diagnosis: default operation identifiers derived from function names, which move when code is refactored. Set them explicitly if you publish an SDK.
Where to Attach Metadata
| Metadata | Attach it to | Applies to | Reaches |
|---|---|---|---|
| Bounds, length, pattern | Field(...) on the field | one field | schema and enforcement |
| Description, single example | Field(...) on the field | one field | schema only |
| Whole-payload sample, vendor keys | json_schema_extra on the model | one model | schema only |
| Validation and documentation of a domain concept | the two hooks on a custom type | every use of the type | schema and enforcement |
| Operation ids, error responses, visibility | route and app arguments | one route or the document | document only |
The general principle is to attach metadata at the widest scope where it is still true. A rule about what a SKU is belongs on a Sku type, where it cannot be forgotten at the next use site; a rule about what this endpoint accepts belongs on the field.
FAQ
Why does my model have two different JSON Schemas? Because a model describes two different documents: what a caller may send and what a caller will receive. Computed fields exist only in the serialization schema, and fields with defaults are optional on the way in but always present on the way out. FastAPI generates both and uses the validation schema for request bodies and the serialization schema for responses.
How do I make a custom type appear correctly in the OpenAPI document?
Implement two hooks. __get_pydantic_core_schema__ tells Pydantic how to validate the type, and __get_pydantic_json_schema__ tells the schema generator how to describe it. Implementing only the first gives you a type that validates correctly and documents itself as a bare string, because the generator cannot inspect a Python function.
Why is my nested model a $ref instead of being written out inline?
JSON Schema deduplicates reused definitions by declaring each model once under $defs and pointing at it with $ref. Every tool that consumes OpenAPI resolves references, and the indirection is what allows a client generator to emit one class per model rather than duplicating the same shape at every use site.
Which metadata reaches the schema and which does not?
Anything declarative reaches it: Field constraints, descriptions, examples, defaults, and json_schema_extra. Anything expressed as a Python function does not, because the generator has no way to inspect a validator's body. A rule enforced only in a validator is invisible to your documentation and to generated clients.
Does an alias change the field name in the documentation? Yes, and that is usually what you want, since the schema should describe the wire format rather than your attribute names. Because validation and serialization aliases are separate settings, a model that sets only one produces a request schema and a response schema that disagree about the field's name.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization for how schema generation fits alongside validation and serialization.
- Controlling the assembled document itself: Customizing OpenAPI Schema Generation in FastAPI.
- Sample payloads and where each mechanism lands: Examples in the OpenAPI Schema with FastAPI.
- Tagged unions, and the error messages they fix: Discriminated Unions in OpenAPI with Pydantic.
- Why validator functions never reach the schema: Custom Validators and Field Constraints.