The document your model generates - and the reason FastAPI can document your API without you writing any docs.
Overview
The output you did not write
Every Pydantic model can describe itself as a JSON Schema. You did not write that document. It comes from the field names, the types, the constraints and the defaults you already declared. The model is the single source of truth, and the schema follows from it.
That matters because many tools read JSON Schema. API documentation uses it. Form builders use it. Code generators and validators in other languages use it. FastAPI builds its OpenAPI docs from it. When your model changes, the schema changes with it, so the documentation does not drift.
This article starts with the output you never wrote, and what it looks like. It covers what reads the schema, and why that makes it more than a curiosity. It then compares constraints with validators in concrete terms, since the schema can express the first but usually not the second. You will see how definitions and references keep nested models tidy. The last section covers the two modes, one for validation and one for serialisation, which can produce different schemas for the same model.
The schema is the bridge between your Python code and everything else that talks to your service. A front-end developer can read it. A tester can generate cases from it. A partner can validate their own payloads against it before they send anything. This article shows what that document contains and how closely it follows the model. It also shows where the two part ways, because some rules written in Python cannot be expressed in a schema at all. Knowing where the line falls tells you what your documentation really promises. You will finish with a clear view of what is described for free and what you must explain yourself, which helps you keep your documentation honest as the model grows.
Module.model_json_schema() returns a dict that is a valid JSON Schema doc. Types, needed fields, defaults, constraints, descriptions, and meanings for nested models.
You wrote none of it. It's derived fully from the annotations, Field args and docstrings already in the class.
This is quietly the reason Pydantic became a dependency of half the modern Python ecosystem. check is handy. A *machine-readable description of your data that can't drift from the code* is what other tools can build on.
Worth knowing
model_json_schema() generates a standard JSON Schema document from the annotations, constraints and metadata you already wrote.
Constraints map to schema keywords: gt to exclusiveMinimum, min_length to minLength, pattern to pattern. A validator maps to nothing.
Nested models become entries in $defs and are referenced, so a reused model becomes a named type in a generated client.
mode="validation" describes what the model accepts; mode="serialization" describes what it emits, including computed fields.
Literal and Enum become enum, which documentation and form builders can render as a set of choices.
description, examples and json_schema_extra flow straight into the document — the cheapest API documentation available.
JSON Schema: The Document Your Model Already Wrote
What the made schema holds, and why it's the reason Pydantic is all over.
What a model already knows
model_json_schema() turns the annotations into a JSON Schema document. Nothing extra was written to produce it.
example_01.pyPydantic
import json
from pydantic import BaseModel, Field
class Module(BaseModel):
title: str = Field(min_length=3, description="Shown as the page heading.")
minutes: int = Field(default=10, gt=0, le=180)
published: bool = False
print(json.dumps(Module.model_json_schema(), indent=2))
Output
Constraints become schema keywords
Every constraint has a standard equivalent, which is why a constraint is worth more than an equivalent validator.
example_02.pyPydantic
import json
from pydantic import BaseModel, Field, field_validator
class ByConstraint(BaseModel):
minutes: int = Field(gt=0, le=180)
title: str = Field(min_length=3, max_length=60)
class ByValidator(BaseModel):
minutes: int
title: str
@field_validator("minutes")
@classmethod
def positive(cls, v):
if not 0 < v <= 180:
raise ValueError("out of range")
return v
for cls in (ByConstraint, ByValidator):
props = cls.model_json_schema()["properties"]
print(cls.__name__)
print(" minutes:", json.dumps(props["minutes"]))
print(" title :", json.dumps(props["title"]))
Output
Nested models become $defs
A reused model appears once as a definition and is referenced, which is what lets a generated client produce a named type.
example_03.pyPydantic
import json
from typing import List
from pydantic import BaseModel
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author
reviewers: List[Author]
schema = Module.model_json_schema()
print("properties:")
for name, spec in schema["properties"].items():
print(" %-11s %s" % (name, json.dumps(spec)))
print()
print("$defs:", list(schema["$defs"]))
print()
print("Author is defined once and referenced twice.")
Output
Two modes: what it takes, what it gives
Computed fields appear only in the serialisation schema, because they are output and never input.
example_04.pyPydantic
import json
from pydantic import BaseModel, computed_field
class Module(BaseModel):
title: str
minutes: int
@computed_field(description="Reading time with a margin.")
@property
def estimated(self) -> int:
return round(self.minutes * 1.2)
for mode in ("validation", "serialization"):
props = Module.model_json_schema(mode=mode)["properties"]
print("%-14s %s" % (mode, list(props)))
print()
print("An input model must not advertise a field callers cannot send.")
Output
Literals and enums become choices
This is the concrete payoff from the enums module: a client can render a dropdown, and a pattern gives it nothing to work with.
example_05.pyPydantic
import json
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
class Track(str, Enum):
MATHS = "maths"
PYTHON = "python"
class M(BaseModel):
a: Literal["draft", "published"]
b: Track
c: str = Field(pattern=r"^(draft|published)$")
schema = M.model_json_schema()
for name, spec in schema["properties"].items():
print("%-3s %s" % (name, json.dumps(spec)))
print()
print("$defs:", json.dumps(schema.get("$defs", {})))
Output
Adding what the annotations cannot say
json_schema_extra attaches anything the schema supports but Pydantic has no annotation for.
example_06.pyPydantic
import json
from pydantic import BaseModel, ConfigDict, Field
class Module(BaseModel):
model_config = ConfigDict(
json_schema_extra={"examples": [{"title": "Vectors", "minutes": 8}]})
title: str = Field(
min_length=3,
description="Shown as the page heading.",
examples=["Dot Product", "Eigenvalues"],
json_schema_extra={"x-editable": True},
)
minutes: int = Field(default=10, gt=0)
print(json.dumps(Module.model_json_schema(), indent=2))
Output
What reads it
FastAPI turns these schemas into your OpenAPI doc. This Turns the interactive docs at /docs. The descriptions you wrote on fields appear next to those fields. The examples pre-fill the request form. The constraints show as documented limits.
Client gens turn OpenAPI into typed clients in TypeScript, Go, Java and the rest. The quality of that made client is a direct function of the quality of your schema.
LLM tooling uses schemas to constrain structured output. The schema tells the model what shape to answer in. The same model then validates the answer.
Form builders and checkers on the other side of the wire read the same doc. A browser can enforce your minLength before a request is ever sent.
One class meaning feeds all of them.
Constraints versus validators, concretely
This is the strongest hands-on arg in the whole track. The schema is where you can see it.
Field(gt=0, le=180) makes "exclusiveMinimum": 0, "max": 180. A made client knows the range. The docs states it. A form can enforce it.
A field_validator that checks the same thing makes nothing. The schema says "type": "int" and the rule is invisible to each consumer. It's still enforced. A bad value is still rejected. But the caller only finds out by being rejected.
Same enforcement, fully other experience for whoever is calling you. That is why the guidance has been. Express a rule as a constraint when a constraint can express it.
The same holds for Literal against a pattern. A Literal turns "enum": ["draft", "published"] and a client can render a dropdown. A pattern matching the same two values turns a regular formula the client can't do anything with.
Definitions and references
A nested model appears once in $defs and is referenced with $ref wherever it's used.
That matters for made clients: a referenced meaning often turns a named type in the target language. Author used in two fields turns one Author type used twice, rather than two anonymous objects that happen to match.
It also keeps the doc small when a model is reused heavily. It's why recursive models give a schema at all. A self-link is just a $ref back to the same meaning.
The two modes
model_json_schema(mode="check") is the default and shows what the model accepts.
mode="output" shows what it emits.
They differ in real ways. Found fields appear only in the output schema. They're output and can never be supplied. A field with exclude=True appears in check and not output. output aliases apply to one and check aliases to the other.
Frameworks pick the right one for you: FastAPI uses the check schema for request bodies and the output schema for response models. Knowing the gap matters when you make a schema yourself and wonder why a field is missing.
Metadata is the cheap win
description, title and examples on a field change no action and flow straight into the doc.
For anything with consumers beyond yourself, this is the highest-value writing you can do per character. A field called weight needs a description. Of what, in what unit? A field called title doesn't.
examples deserve given focus because they populate the interactive docs's request form. A developer trying your API for the first time gets a working request they can send, rather than an empty box. That gap shows up in how many of them succeed.
json_schema_extra attaches anything else the schema format supports that Pydantic has no dedicated arg for. Vendor extensions like x- keys, or keywords from a newer draft. It takes a dict, or a callable that receives and modifies the made schema.
What does not translate
Some things simply can't be expressed in JSON Schema, and knowing which keeps expectations right.
checker logic. Any Python has no schema equal.
Cross-field rules. A model_validator enforcing "end after start" has no form. doc it in the model's docstring. This Turns the schema's description. At least a human reading the docs learns about it.
Custom output. As the previous module covered, a serialiser changes output without changing the schema unless you set return_type.
Where a rule can't be expressed, the honest thing is to show it in prose so the docs isn't silently incomplete.
A habit worth adopting
Print the schema for a model you've just written. It takes one line. It shows you what your consumers will really see.
Constraints you thought you had documented and didn't. Fields with no description whose names aren't self-explanatory. A pattern where a Literal would have made a set of choices. A needed field you meant to make optional.
It's the fastest review on hand for an API model. It uses data the model already holds.
Customising the whole document
model_config accepts json_schema_extra, which can be a dict merged into the model's schema or a callable that receives and edits it:
Model-level examples appear in docs as full sample payloads. This Is more handy to a first-time caller than per-field examples: they can copy one and send it.
For deeper control there's GenerateJsonSchema, a class you can subclass to change how schemas are made across a whole use. Renaming meanings, altering how optionals are shown, adding vendor extensions all over. It's the right tool for a house style applied to an entire API and large overkill for one model.
Docstrings become descriptions
A model's docstring turns the schema's description. That is worth knowing because it's free docs for the rules that can't be expressed structurally.
A cross-field rule — "ends_on must be after starts_on" — has no schema form. Writing it in the docstring means a consumer reading the docs learns about it, instead of discovering it through a 422.
use_attribute_docstrings=True extends this to fields, taking the string literal beneath an field as its description. It keeps the docs next to what it shows. This Is where it stays accurate.
Reading the schema as a review
The most hands-on use of this module is as a review tool. One line. You see what your consumers see:
Things it well surfaces: fields whose name doesn't explain them and which have no description. A pattern where a Literal would have made a set of choices. A rule you thought was documented that turns out to live in a checker. A field in needed that you meant to default. A nested model inlined because it's used once. Here A named meaning would give clients a better type.
None of that needs running the API. It's the highest-value five minutes on hand on a model that other people will consume.
Where schemas end up
Worth knowing the chain, because it shows why the small things matter.
Your model makes a schema. FastAPI collects those into an OpenAPI doc. That doc is read by the interactive docs, by client gens in a few languages, by API gateways, by contract-testing tools. Increasingly by LLM tooling deciding what shape to give.
A description you write once is read by everybody in that chain. So is a missing one.
What to remember about JSON Schema
The schema is made from what you already wrote. Its quality is a direct function of how exact your annotations are.
Constraints appear. Checkers don't. Literal and Enum become choices. Patterns become opaque. Nested models become named meanings and so named client types. Descriptions and examples cost nothing and are read by each tool downstream.
The habit worth forming is simply printing it. all above turns visible the moment you look at the doc your model already makes.
JSON Schema: summary
model_json_schema() makes a standard doc from what you already wrote. Constraints become keywords. Checkers become nothing. Nested models become referenced meanings and so named types in made clients. Literal and Enum become renderable choices.
Two modes, for input and output. Metadata. Descriptions and examples above all. Is the cheapest docs you'll ever write. It's read by tools you may never see.
The habit
Print the schema for the next model you write.
It takes one line and it's the only view of your model that matches what consumers get. all this module shows. A checker that documented nothing, a pattern that should have been a Literal, a field with no description, a needed field you meant to default. Turns visible at once.
The schema was made from work you had already done. Looking at it's the cheapest quality check on hand.
Mistakes people make with JSON Schema
Expressing a rule as a checker when a constraint would do. The rule is enforced and invisible. docs, made clients and browser-side forms all remain unaware of it. The caller only learns the limit by breaching it.
**Using a pattern where a Literal belongs.** A regular formula matching four values turns an opaque string in the schema. The enumeration turns a set of choices a form can render as a dropdown and a client can turn into a union type.
Leaving fields undescribed.weight needs a description. Of what, in what unit. Names that aren't self-explanatory and have no description give docs that strictly exists and helps no one.
Never looking at it. The schema is one line away and it's the only view of your model that matches what consumers really receive. all above turns obvious the moment you print it.
Expecting cross-field rules to appear. A model_validator has no schema form at all. Where a rule can't be expressed structurally, put it in the model's docstring so at least a human reading the docs learns about it.
Losing the two modes. making a check schema and wondering why a found field is missing, or a output schema and wondering why an excluded field is absent, are both the same misunderstanding. One shows what goes in, the other what comes out.
Why this is the reason
check is handy and other libraries do it.
What made Pydantic a dependency of half the modern Python ecosystem is this. A machine-readable description of your data, made from the code that enforces it. So incapable of drifting from it.
docs that can't go stale. Clients made from the truth. Forms enforcing the same rules as the server. All from annotations you were going to write anyway.
Questions people ask about JSON Schema
How do I get the JSON schema of a model? Call Model.model_json_schema(). It returns a dict you can serialise with json.dumps.
Why are there two modes?mode="validation" describes what the model accepts and mode="serialization" describes what it emits. They differ for computed fields, aliases and custom serialisers.
How do I add descriptions and examples? Use Field(description=..., examples=[...]) for fields and model_config = ConfigDict(json_schema_extra={...}) for the model. A class docstring becomes the model description.
What are $defs and $ref in the output? Nested models are listed once under $defs and referenced with $ref, so a model used in three places is not repeated.
Which Python rules cannot appear in a schema? Custom validator functions and arbitrary logic. The schema carries types and constraints, not code.
What reads these schemas in practice? FastAPI turns them into OpenAPI docs, and code generators, form builders and LLM tool-calling APIs consume them to build clients or constrain output.
What good looks like
A well-given model makes a schema somebody could run a client against without asking you a question.
each field has a type narrow enough to be handy. Literal rather than str where the set is closed, date rather than str where it's a date. each constraint that exists in your head exists in the doc. each field whose name isn't self-explanatory has a description. There's at least one full example. Nested concepts are named models rather than inline objects. The made client has named types.
A poorly-given model makes a doc that is strictly valid and useless. All is a string, nothing has bounds, no field is given. The real rules live in checkers the consumer can't see.
Both are made on its own from code that validates the same way. The gap is fully in how exact the annotations were. Which is the arg this whole track has been making, arriving finally at the place where it turns visible to somebody other than you.
Check yourself
0 of 4
Answer without scrolling back up.
What does a `field_validator` contribute to the generated schema?
Arbitrary logic has no schema equivalent. The rule is still enforced, but documentation, generated clients and browser forms know nothing about it - which is why constraints are preferred where they can express the rule.
Where do computed fields appear?
They are output-only, so advertising them in an input schema would tell callers to send something that cannot be sent.
Why does a nested model appear in `$defs` rather than inline?
A referenced definition becomes one named type used in several places, rather than several anonymous objects that happen to match. It is also what makes recursive models expressible.
What do `examples` on a field do?
They change no behaviour and flow into the schema, where documentation tools use them to give a first-time caller something that works rather than an empty box.
Cheat sheet
JSON Schema
Generate a JSON Schema from any Pydantic model and see why that automatic document powers OpenAPI, FastAPI and every tool that reads your API.
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.