OpenAPI and the Docs

The document your annotations generate, and how much of an API's usability is decided by it.

Overview

What is generated

app.openapi() returns one OpenAPI document describing every route: paths, methods, parameters, request bodies, response shapes, status codes, descriptions and examples.

It is assembled from things you already wrote — the path in the decorator, the parameters in the signature, the models, the constraints, the docstrings. There is no separate specification to maintain and no way for it to drift, because it *is* the code.

FastAPI serves it at /openapi.json, renders it at /docs (Swagger UI) and /redoc (ReDoc).

Worth knowing

app.openapi() assembles the document from your models and signatures. The docs page at /docs renders it.
title, version and description on FastAPI() become the document's header; a function's docstring becomes an endpoint's description.
Tags group endpoints into sections, and openapi_tags gives each section a description.
examples pre-fill the interactive request form — the difference between a caller's first attempt working and being a guess.
responses= documents the failure shapes, which is what lets a generated client type its errors.
include_in_schema=False hides an endpoint from the document without protecting it. Undocumented is not private.

OpenAPI and the Docs

The document your annotations generate, and how much of an API's usability is decided by it.

The document, and where it comes from

app.openapi() assembles one OpenAPI document from every model and signature. Nothing else was written to produce it.

example_01.pyFastAPI
Output

Tags organise it

Tags group endpoints into sections, and metadata on the app gives each section a description.

example_02.pyFastAPI
Output

Examples fill the Try it out form

The single highest-value thing you can add: a caller gets a working request instead of an empty box.

example_03.pyFastAPI
Output

Documenting the failures

responses= puts the error shapes in the document, so a generated client can type them.

example_04.pyFastAPI
Output

Hiding what should not be published

include_in_schema=False keeps an internal endpoint out of the document while leaving it working.

example_05.pyFastAPI
Output

Reading it as a review

One line, and you see what a consumer sees. It is the fastest review available for an API model.

example_06.pyFastAPI
Output

The most practical use of this module. One line:

print(json.dumps(app.openapi(), indent=2))

and you see exactly what your consumers see.

What it reliably surfaces: fields whose names are not self-explanatory and have no description; a str parameter where a Literal would have given clients a set of options; a rule enforced by a validator that appears nowhere; a required field you meant to default; an endpoint with no example.

The last editor above automates two of those checks. It is worth running over a real model, because the results are usually uncomfortable and always cheap to fix.

The header

FastAPI(title=..., version=..., description=...) becomes the top of the document and the top of the docs page.

The description supports Markdown and is the one piece of genuine prose in the whole thing. It is worth writing: what the API is for, how authentication works, what the rate limits are, where to get a key. That is the first thing a new consumer reads, and the alternative is that they read nothing.

version should be your API's version, not your library's. Consumers pin against it.

Tags

Tags group endpoints into sections. On a router, tags=["modules"] applies to every route in it.

openapi_tags on the app gives each tag a description, which becomes a paragraph above that section in the docs.

For an API past a dozen routes this is the difference between a navigable document and a flat alphabetical list. It costs one argument per router.

Examples are the highest-value addition

Everything else in this module is worth doing. This is the one that changes whether people succeed.

A Field(examples=[...]) or a model-level json_schema_extra={"examples": [...]} pre-fills the interactive form. A developer opens /docs, presses Try it out, and gets a request that works — instead of an empty box they have to guess at, with a 422 for their first three attempts.

That difference is measurable in how many integrations get finished.

Write examples that are realistic rather than minimal. "title": "string" is what the generator produces without you; "title": "Dot Product" shows what the field is actually for.

Documenting failures

response_model covers the success case. Everything else is undocumented unless you say so:

responses={404: {"model": Problem, "description": "No such module"}}

Worth doing for the failures a caller is expected to handle. A generated client can then type its errors, and a human reading the docs knows what a rejection looks like before causing one.

Hiding endpoints

include_in_schema=False keeps a route out of the document. Useful for internal endpoints, legacy paths kept for one client, and health checks that would only clutter the page.

One warning worth being explicit about: this is not access control. The endpoint still works, still accepts requests, and is exactly as reachable as before. It is invisible, not protected. If it should not be called, it needs a dependency, not a flag.

deprecated=True is the other half of retiring something: the endpoint keeps working and the docs show it as deprecated, which gives consumers a signal without breaking them.

What it feeds

The document is read by more than the docs page, which is why its quality compounds.

Client generators produce typed clients in a dozen languages from it. A vague schema produces a vague client, and every consumer then writes their own guesses.

Contract tests can assert that a change did not break the published shape.

API gateways can validate requests before they reach you.

LLM tooling increasingly reads schemas to decide how to call an API.

None of those read your source. All of them read this.

The limits

Some things cannot be expressed, and pretending otherwise misleads consumers.

Validator logic, cross-field rules, anything requiring a lookup — none has a JSON Schema equivalent. Where a rule matters and cannot be declared, put it in the docstring or the model's description, so at least a human reading the documentation learns about it.

An endpoint whose real constraints live in code the schema cannot see is an endpoint whose documentation is quietly incomplete.

Mistakes people make

Treating include_in_schema=False as security. The endpoint still works and is exactly as reachable. Invisible is not protected.

Leaving fields undescribed. weight needs a description - of what, in what unit. A name that is not self-explanatory and has no description produces documentation that technically exists.

No examples. The generated placeholder is "string". A caller's first three attempts then return 422, and some of them stop there.

Documenting only success. An endpoint that can 404 should say so, or a generated client has no type for failure.

Using str where a Literal belongs. The schema then offers no options, so no client can render a choice and no consumer knows what is valid.

Never looking at it. It is one line, and it is the only view of your model that matches what consumers receive.

Versioning it with the library version. Consumers pin against your API's version, not your package's.

The review worth doing

Print the document for a model you have just written and read it as a stranger.

What it surfaces: required fields you meant to default, patterns that should have been enumerations, rules that live in validators and appear nowhere, endpoints with no summary, and fields whose names carry meaning only to whoever wrote them.

Every one of those is cheap to fix at that moment and expensive once clients exist, because by then the shape is a contract.

Next

Putting the pieces together: how a FastAPI project is laid out once it is more than one file.

What it costs to skip

An API without a good document still works, and the cost is paid by everyone else.

Every consumer writes their own guesses about shapes. Every generated client is untyped. Every question that could have been answered by reading becomes a message to whoever wrote it. And every change is potentially breaking, because nobody wrote down what the contract was.

The work to avoid that is small and front-loaded: a description on the app, tags on the routers, descriptions on the fields whose names are not obvious, one example per body model, and responses= on the failures a caller is expected to handle.

An hour, once, and it is read by every person and tool that touches the API afterwards.

The chain it feeds

Worth holding in mind, because it explains why small omissions matter.

Your models generate schemas. FastAPI assembles them into one document. That document is read by the interactive docs, by client generators in several languages, by API gateways, by contract-testing tools, and increasingly by LLM tooling deciding how to call you.

A missing description is missing in all of them. So is a str that should have been a Literal. None of those tools read your source code, and none of them can ask.

Summary

The document is generated from your models, signatures, decorators and docstrings, and served at /openapi.json, /docs and /redoc.

Give the app a title, version and description; group routes with tags and describe the groups with openapi_tags; document failures with responses=; and write examples, which are the single highest-value addition because they turn a caller's first attempt from a guess into a working request.

include_in_schema=False hides without protecting. And the fastest review available for any API model is printing the document and reading it as a stranger would.

The limits, stated plainly

Some things cannot be expressed, and knowing which keeps the documentation honest.

Validator logic has no schema equivalent. A rule enforced by a field_validator is invisible to every consumer.

Cross-field rules likewise. "End date must be after start date" appears nowhere.

Anything needing a lookup - does this reference exist, is this name taken - is not expressible and should not be attempted.

Custom serialisation changes output without changing the schema unless return_type is set, which is how a document quietly starts describing something the endpoint no longer returns.

Where a rule matters and cannot be declared, put it in the model's docstring or the field's description. It will not be machine-readable, and a human reading the documentation will at least learn it exists rather than discovering it through a rejection.

Who reads it

Worth being concrete, because the audience is larger than the docs page.

A developer integrating with you opens /docs, presses Try it out, and either gets a working request or does not. That first minute decides how the rest goes.

A client generator turns the document into a typed library. Its quality is entirely your schema's quality.

A gateway may validate requests against it before they reach your process.

A contract test can assert that today's document is compatible with yesterday's.

An LLM increasingly reads it to decide how to call you, and reads only what is written.

None of them can ask a question, and none of them read your source. Everything they know is in the document, which is why the descriptions and examples are not decoration.

A closing thought

The generated document is the closest thing an API has to a public interface definition, and it is produced entirely as a side effect of writing types.

That is unusual and worth appreciating. In most stacks the specification is a separate artefact that somebody maintains, and it drifts from the implementation immediately because nothing forces them together.

Here it cannot drift, because there is only one source. What varies is how much you put into that source - and the difference between a document consumers can build against and one they have to guess at is a handful of descriptions, one example per model, and a Literal where a str would have done.

In one line

Your annotations already wrote your API documentation; the only question is how much you put into them, and the answer is decided by a handful of descriptions, one example per body model, and a Literal wherever a str would have left a consumer guessing.

And the cheapest habit available is to print the document once for every model you write. Five minutes, no tooling, and it shows you the API as a stranger receives it rather than as its author remembers it.

One more habit worth the minute it costs: after adding an endpoint, open /docs and try it as a stranger would. If your own first attempt returns a 422, so will everybody else's, and you are the only person who can still fix it cheaply.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Where does the OpenAPI document come from?

  2. What does `include_in_schema=False` do?

  3. Which addition most improves a first-time caller's experience?

  4. A rule lives in a `field_validator`. What does the schema say about it?

Cheat sheet

OpenAPI and the Docs

app.openapi() returns one OpenAPI document describing every route: paths, methods, parameters, request bodies, response shapes, status codes, descriptions and examples.

FASTAPI · vizlearn.in/fastapi/openapi_and_docs.html

About the author

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.