Skip to content

How Pydantic models become your API documentation. Where each schema comes from, what each Field argument produces, naming collisions, and customising the output.

Your OpenAPI document is generated from the same objects that do the validating. There is no second declaration to keep in step, which is the whole argument for declaring constraints on models rather than checking them in handlers.

class PostCreate(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Shown in listings.")
published: bool = False

becomes

{
"PostCreate": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Shown in listings."
},
"published": {"type": "boolean", "default": false}
},
"required": ["title"]
}
}
DeclarationIn the document
request_model=requestBody schema
response_model=The success response schema
Query, Path, Header, Cookieparameters entries
Form, FilerequestBody as form or multipart
responses={...}The other status codes
tags, summary, descriptionOperation metadata
Field()Schema
defaultdefault
titletitle
descriptiondescription
examplesexamples
gt / geexclusiveMinimum / minimum
lt / leexclusiveMaximum / maximum
multiple_ofmultipleOf
min_length / max_length on a stringminLength / maxLength
min_length / max_length on a listminItems / maxItems
patternpattern
deprecateddeprecated
exclude=True(omitted)
no defaultlisted in required

The parameter markers take the same constraint arguments and produce the same keywords, so Query(1, type=int, ge=1) is documented as minimum: 1.

PythonSchema
strstring
intinteger
floatnumber
boolboolean
Decimalstring with format: decimal
datetimestring, format: date-time
datestring, format: date
UUIDstring, format: uuid
EmailStrstring, format: email
HttpUrlstring, format: uri
list[T]array of T
dict[str, T]object with additionalProperties
Literal["a", "b"]enum
An Enum classenum, as a named component
A nested model$ref to a component
T | NoneanyOf with null
Any{}: anything

Any is the one to avoid. It becomes an empty schema, and a generated client produces unknown or object. If the shape is known, declare it.

Every model becomes a component keyed by its class name.

app/schemas/posts.py
class PostOut(BaseModel): ...
# app/schemas/admin.py
class PostOut(BaseModel): ... # collides

Same name, different modules, and the second overwrites the first in your document. Nothing warns you.

Name them for what they are: PostOut, PostSummary, PostAdminOut, PostCreate. Reusing Post across modules is the reliable way to publish a schema that describes the wrong thing.

Generic models get generated names (Page[PostOut] becomes PagePostOut) which are unique by construction.

class Payment(BaseModel):
method: Card | BankTransfer = Field(discriminator="kind")

produces a proper discriminator with a mapping, so a generated client builds a real tagged union instead of an untyped oneOf it has to try in turn.

Worth the Literal tag for anything polymorphic that crosses the wire. See Nested models.

At the field level:

title: str = Field(examples=["Async Python in practice"])

At the model level, which is more useful:

class PostCreate(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"title": "Async Python in practice", "body": "…", "tags": ["python"]}
]
}
)

A whole valid request beats a set of per-field placeholders. It is what someone will copy into a terminal, and it demonstrates the fields’ relationship to each other.

Per response:

@app.post(
"/posts",
response_model=PostOut,
responses={
201: {
"description": "Created",
"content": {
"application/json": {
"example": {"id": 1, "title": "Async Python in practice"}
}
},
}
},
)

For a schema keyword Pydantic does not generate:

class Coordinates(BaseModel):
model_config = ConfigDict(
json_schema_extra={"externalDocs": {"url": "https://example.com/geo"}}
)

Per field:

value: str = Field(json_schema_extra={"x-internal": True})

x- extensions are how you carry information for your own tooling (a code generator, a documentation theme) without inventing keywords the spec does not have.

PostCreate.model_json_schema()

The dict Sillo embeds. Useful in a test:

def test_title_is_documented_as_bounded():
schema = PostCreate.model_json_schema()
assert schema["properties"]["title"]["maxLength"] == 200

Which is a reasonable thing to assert for a published contract. It fails if someone removes the constraint, and the constraint is the contract.

@app.get("/internal/metrics", exclude_from_schema=True)

Keeps an operation out of the document entirely. For health checks, internal tooling, anything that is not part of the public surface.

It does not make the route private. Use auth for that. An undocumented endpoint is still an endpoint.

The documentation UI renders it, and the raw document is served alongside. See the OpenAPI guides for configuring the title, version, servers and security schemes.

The document is generated, so it cannot describe a shape your code does not enforce, with two exceptions worth knowing:

  • A custom serialiser can make the output diverge from the declared schema.
  • A handler returning a raw response.json(...) with no response_model is undocumented and unenforced.

Both are avoidable. Declare response_model on everything that returns data, and keep custom serialisers for cases where you have checked the schema still matches.