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.
The return value becomes JSON
Dicts, lists, models, primitives — all serialised for you. The default status is 200.
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.
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.
Describing the endpoint
summary, description and tags shape the documentation. The docstring becomes the description if you do not supply one.
A whole small API
Everything so far in one app: two routes, a model, a status code and an in-memory store.
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.
Status codes
The decorator takes status_code:
@app.post("/modules", status_code=status.HTTP_201_CREATED)
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:
@app.post("/modules", status_code=201)
def create(module: ModuleCreate) -> ModuleOut:
return service.create_module(module)
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:
router = APIRouter(prefix="/modules", tags=["modules"])
@router.get("")
def list_modules(): ...
app.include_router(router)
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.