Cheatsheet for JSON Schema generation. Used by FastAPI for OpenAPI; by LLM SDKs for tool calling.
model_json_schema
class User(BaseModel):
id: int
email: str
name: str | None = None
User.model_json_schema()
{
"type": "object",
"properties": {
"id": {"type": "integer", "title": "Id"},
"email": {"type": "string", "title": "Email"},
"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}
},
"required": ["id", "email"]
}
Mode
User.model_json_schema(mode="validation") # default, for input
User.model_json_schema(mode="serialization") # for output
Differ with computed fields, exclude, aliases.
json_schema_extra
class User(BaseModel):
email: str
age: int
model_config = {
"json_schema_extra": {
"examples": [
{"email": "[email protected]", "age": 30},
{"email": "[email protected]", "age": 25},
]
}
}
Surfaces in Swagger UI.
Field-level metadata
class User(BaseModel):
email: str = Field(..., description="User email", examples=["[email protected]"])
age: int = Field(..., ge=0, description="Age in years", title="Age")
Custom schema for type
class MyType:
@classmethod
def __get_pydantic_json_schema__(cls, schema, handler):
result = handler(schema)
result["title"] = "MyType"
result["description"] = "Custom"
result["examples"] = ["example1"]
return result
In FastAPI
FastAPI auto-uses model_json_schema() for request bodies and response models. Shows up at /openapi.json and /docs.
@app.post("/users", response_model=UserOut)
async def create_user(data: UserCreate):
...
OpenAPI schema includes UserCreate (request body) and UserOut (response).
$defs / $ref
For nested models, Pydantic generates $ref:
{
"type": "object",
"properties": {
"user": {"$ref": "#/$defs/User"}
},
"$defs": {
"User": {...}
}
}
OpenAPI translates $defs to components/schemas.
Discriminated union in schema
Animal = Annotated[Cat | Dog, Field(discriminator="kind")]
Schema:
{
"oneOf": [
{"$ref": "#/$defs/Cat"},
{"$ref": "#/$defs/Dog"}
],
"discriminator": {"propertyName": "kind", "mapping": {...}}
}
Cleaner SDKs and docs.
LLM tool calling
from anthropic import Anthropic
class WeatherInput(BaseModel):
city: str = Field(..., description="City name")
units: Literal["c", "f"] = "c"
tools = [{
"name": "get_weather",
"description": "Get current weather",
"input_schema": WeatherInput.model_json_schema(),
}]
client.messages.create(model="claude-sonnet-4-6", tools=tools, ...)
LLM providers consume the schema directly.
Nullable types
class M(BaseModel):
x: int | None
Schema in JSON Schema 2020-12 / OpenAPI 3.1:
{"x": {"anyOf": [{"type": "integer"}, {"type": "null"}]}}
FastAPI emits OpenAPI 3.1 by default.
Title / description
class M(BaseModel):
"""User model.
Long description here.
"""
name: str = Field(..., title="Name", description="User's name")
Class docstring → schema description.
Generic in schema
class Page(BaseModel, Generic[T]):
items: list[T]
# Page[User].model_json_schema() generates "Page_User_" schema
Customize JSON schema globally
from pydantic.json_schema import GenerateJsonSchema
class MyJsonSchemaGenerator(GenerateJsonSchema):
def generate(self, schema, mode="validation"):
result = super().generate(schema, mode=mode)
# post-process
return result
User.model_json_schema(schema_generator=MyJsonSchemaGenerator)
Skip from schema
class M(BaseModel):
public: int
internal: int = Field(..., exclude=True)
exclude=True excludes from serialization (and schema follows).
Common mistakes
- Using
model_json_schema(mode="validation")for output — different shape. - Massive
json_schema_extra— bloats OpenAPI. - Custom JSON schema that drifts from runtime validation — confusing.
- Forgetting
model_rebuild()for recursive types in schema.
Read this next
If you want my Pydantic + LLM tool-schema patterns, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .