The instinct is that @app.get wraps the function in something. It does not. It registers it: the app records the path, the HTTP method and the function's signature in a routing table, and returns the function unchanged.
You can verify that, and the first editor above does — read_modules() called directly still returns the list. This is worth knowing because it means an endpoint is an ordinary function, testable on its own, with no framework machinery attached to it.
What the app records from the signature is more than the name. It notes which parameters correspond to placeholders in the path, which have defaults, and what each is annotated as. That analysis happens once, at import, which is why routing is cheap at request time.
Worth knowing
The decorator registers a route; it does not wrap your function. Calling the function directly still works exactly as before.
Whatever you return is serialised to JSON — dicts, lists, models and primitives all work. The default status is 200.
status_code= on the decorator sets that route's default. The status module gives every code a readable name.
Routes match in registration order, so /modules/latest must be declared before /modules/{id} or it will never be reached.
summary, tags and the function's docstring all flow into the OpenAPI document and the docs page.
A 204 response must have no body. Declare status_code=204 and return None — returning a value with a 204 produces a malformed response.
Your First Endpoint, and What the Decorator Really Does
How a function becomes a route, what happens to the return value, and the ordering rule that catches everyone once.
The decorator registers a route
It does not wrap the function. It records the path, the method and the signature in the app's routing table.
example_01.pyFastAPI
Output
The return value becomes JSON
Dicts, lists, models, primitives — all serialised for you. The default status is 200.
example_02.pyFastAPI
Output
Choosing the status code
status_code on the decorator sets the default for that route. The status module names them so you do not have to remember numbers.
example_03.pyFastAPI
Output
Order matters when paths overlap
Routes are matched in the order they were registered, so a fixed path must come before a variable one that would also match it.
example_04.pyFastAPI
Output
Describing the endpoint
summary, description and tags shape the documentation. The docstring becomes the description if you do not supply one.
example_05.pyFastAPI
Output
A whole small API
Everything so far in one app: two routes, a model, a status code and an in-memory store.
example_06.pyFastAPI
Output
The return value
Whatever you return is converted to JSON: dictionaries, lists, Pydantic models, and primitives like strings and numbers. The default status is 200 and the content type is application/json.
Returning a model is the most useful case, because the model decides the shape. Returning a bare string produces "just a string" — valid JSON, with the quotes — which occasionally surprises people expecting plain text. If you want plain text, say so with PlainTextResponse.
For anything you care about, declare a response_model rather than relying on what the handler happens to return. That gets a module of its own shortly, and it is the difference between an endpoint that documents its output and one that does not.
That sets the *default* for the route. A handler can still override it per response when it needs to.
The status module is worth using over bare integers. status.HTTP_201_CREATED says what it means, and your editor will autocomplete it, which matters more than it sounds for the codes people reach for less often.
One rule that bites: a 204 must have no body. Returning a dict with status_code=204 produces a malformed response. Declare status_code=204 and return None.
Worth getting right, briefly: 200 for a successful read, 201 for something created, 204 for a successful action with nothing to say, 202 for accepted-but-not-done. Returning 200 for everything is common and throws away information every HTTP client already knows how to act on.
Route order
This is the mistake almost everyone makes once.
Routes are matched in registration order, first match wins. So:
@app.get("/modules/{module_id}") # registered first
@app.get("/modules/latest") # never reached
A request for /modules/latest matches the first route, with module_id="latest". If module_id is annotated int, the caller gets a confusing 422 about latest not being an integer. If it is annotated str, they get a successful lookup for a module that does not exist.
The fix is to declare fixed paths before variable ones. The fourth editor above shows both orders side by side.
The int annotation does soften this: a genuinely numeric path still routes correctly, and /modules/latest fails loudly rather than silently. That is one more small argument for annotating path parameters precisely.
Describing what you built
Three things shape the documentation, and all are free.
summary is the short label in the docs list. Without one, FastAPI generates a title from the function name, which is usually worse than a sentence you write.
tags group endpoints into sections. On an API of any size this is the difference between a navigable document and a flat list of forty routes.
The docstring becomes the description. That is a nice property: documentation written where a Python developer would naturally write it also appears in the API docs, and Markdown is rendered.
response_description and deprecated=True are there too, the latter being the polite way to retire an endpoint — it still works and the docs show it as deprecated.
Path design, briefly
FastAPI does not enforce a convention, and one is worth having.
Use plural nouns for collections: /modules, not /module or /getModules. The method already says what you are doing, so putting a verb in the path duplicates it.
Nest only where there is genuine ownership: /modules/{id}/lessons is reasonable; /tracks/{t}/modules/{m}/lessons/{l}/comments/{c} is a URL nobody will type correctly.
Keep the identifier in the path and the filtering in the query string. /modules/7 identifies a thing; /modules?track=maths narrows a set. Which is which is the subject of the next two modules.
What running these means
The editors call the app through ASGI rather than over a network, so everything you can observe — status codes, headers, JSON bodies, routing decisions, validation — is the real behaviour.
client.get("/modules") here does what the same line does in a real test file using fastapi.testclient.TestClient. The code you are reading is the code you would write.
The other methods
@app.get has siblings for every HTTP method: post, put, patch, delete, head, options, trace.
They are not interchangeable, and choosing correctly gives you behaviour from the wider web for free.
GET reads and must not change anything. It is cacheable, retryable and safe to repeat, and browsers, proxies and CDNs all assume that. A GET that mutates state will eventually be replayed by something and cause a problem you did not write.
POST creates, or performs an action that is not idempotent. Sending it twice does it twice.
PUT replaces a resource at a known URL. Sending it twice leaves the same result, which makes it safe for a client to retry after a timeout.
PATCH updates part of one.
DELETE removes. Also idempotent: deleting twice leaves the thing deleted.
That idempotency property is the practical payoff. A client whose connection drops mid-request can safely retry a PUT or DELETE and cannot safely retry a POST, and every HTTP library in the world already knows this.
Trailing slashes
/modules and /modules/ are different paths, and FastAPI redirects between them with a 307 by default.
That is usually invisible and occasionally maddening: a 307 preserves the method and body, but some clients drop the body on redirect, and a POST that mysteriously arrives empty is often this. Being consistent in your own routes — pick no trailing slash and stay with it — avoids the whole category.
Returning a Response directly
Sometimes you need control over the exact response: a specific content type, a header, a status the route's default does not cover.
Returning a Response object bypasses serialisation and the response model entirely. JSONResponse, PlainTextResponse, HTMLResponse and RedirectResponse are all available.
The trade is that you have opted out of the machinery. Nothing validates what you sent and nothing documents it, so the schema no longer describes the endpoint. Do it for the genuine exceptions — a redirect, a file, a bespoke content type — and let the normal path handle everything else.
Where handlers should stop
A recurring question with a stable answer: how much logic belongs in the handler?
As little as possible. A handler's job is to translate between HTTP and your application — take validated input, call something that does the work, turn the result into a response. Business logic inside a handler cannot be tested without constructing a request, cannot be reused by a background job or a CLI, and tends to accumulate.
The shape that stays healthy is a thin handler calling a plain function:
Everything HTTP-specific is in the decorator and the signature. Everything else is a function you could call from anywhere.
Mistakes people make
Declaring a variable route before a fixed one./modules/{id} before /modules/latest makes the second unreachable. Fixed segments first, always.
Returning a body with a 204. It produces a malformed response. Declare the status and return None.
Using 200 for everything. A creation is a 201, a successful delete is a 204, an accepted-but-unfinished job is a 202. Clients already know how to act on these, and returning 200 throws that away.
Mutating state in a GET. Browsers prefetch, proxies cache, and monitoring replays. A GET that changes something will eventually be called when nobody asked.
Forgetting the response model. Without one the handler's return value is sent verbatim, including any field a future migration adds to the underlying row.
Inconsistent trailing slashes./modules and /modules/ differ, and the 307 between them loses the body in some clients. Pick one convention and hold it.
Next
Three modules on where data comes from: the path, the query string and the body. Between them they cover almost everything an API accepts, and each has rules worth knowing precisely.
A shape worth copying
By the end of a first pass, a healthy endpoint looks like this:
@app.post("/modules", response_model=ModuleOut,
status_code=status.HTTP_201_CREATED, tags=["modules"])
def create_module(module: ModuleCreate) -> ModuleOut:
"""Create a module and return it with its assigned id."""
return service.create_module(module)
Every line is doing something. The path names a collection. The response model states what leaves. The status code says what happened. The tag groups it in the docs. The input model states what may be sent. The docstring becomes the description. And the body is one call into code that knows nothing about HTTP.
Nothing there is clever, and that is the point — the interesting parts are in the models and the service, where they can be tested without constructing a request.
Where routes live as an app grows
One file works until it does not. The usual progression is a single main.py, then a hundred routes in it, then a rewrite nobody enjoys.
APIRouter is the answer and it gets a full module later, but it is worth knowing the shape now so the first file is written in a way that can grow. A router is a mini-app you register routes on and then include into the main one with a prefix and tags:
The practical advice for a new project: create the router file on day one, even with two routes in it. Moving three endpoints later is trivial; moving eighty is a weekend.
Reloading while you work
uvicorn main:app --reload restarts the process whenever a file changes, which is what you want in development and never in production — the reloader spawns an extra process and watches the filesystem.
Two things people trip on. Module-level state resets on every reload, so anything held in a global disappears when you save. And an import error leaves the previous version running, so a change that "does nothing" is sometimes a syntax error scrolled off the top of the terminal.
The one-line summary
The decorator registers a function as a route without changing it. The return value becomes JSON. status_code sets the route's default and a 204 must carry nothing. Fixed paths go before variable ones. And the docstring you were going to write anyway becomes the description in your API documentation.
A closing thought
The endpoints in this module are four lines each, and that is representative rather than simplified. A well-factored FastAPI handler usually is short, because everything it would otherwise contain has moved somewhere better: the validation into a model, the shape of the response into another model, and the work into a function that knows nothing about HTTP.
If a handler is growing, that is usually the signal — not that the endpoint is complicated, but that something in it belongs elsewhere.
What to check before shipping one
A short list, all of which this module has covered.
Does it declare a response model? Is the status code right for what it does? Does the method match its effect — nothing mutating behind a GET? Is a fixed path registered before any variable one that would shadow it? Does it have a summary and a tag, so the documentation is navigable? And is the handler thin enough that the logic could be tested without a request?
Six questions, most answerable in seconds, and between them they catch nearly everything this module described.
Check yourself
0 of 4
Answer without scrolling back up.
What does `@app.get("/x")` do to the function?
The decorator records the path, method and signature in the routing table. The function itself is untouched and still callable directly.
Why is `/modules/latest` unreachable when declared after `/modules/{module_id}`?
The variable route matches first, with module_id="latest". Declare fixed paths before variable ones - and annotating the parameter `int` makes the failure loud rather than silent.
What must a 204 response contain?
204 means success with nothing to say. Returning a value with status_code=204 produces a malformed response; return an explicit `Response(status_code=204)`.
Where does an endpoint's description in the docs come from?
The docstring becomes the description and Markdown is rendered - so documentation written where a Python developer naturally writes it also reaches the API docs.
Cheat sheet
Your First Endpoint
The instinct is that @app.get wraps the function in something. It does not. It registers it: the app records the path, the HTTP method and the function's signature in a routing table, and returns the function unchanged.
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.