What the decorator does, what the return value becomes, and how a function turns into a route.
Overview
The decorator does not wrap anything
An endpoint is a Python function that answers one kind of request at one address. You write the function, add a decorator that names the method and the path, and FastAPI does the rest. The first one is the smallest useful program you can write with the framework, and it teaches more than its size suggests.
The decorator looks like it wraps your function, but it does not. It registers the function in a table of routes and hands the function back untouched. That is why you can still call it directly in a test. Once you see this, a lot of behavior stops feeling like magic.
This article walks through what happens to the value you return and how it becomes JSON. It then covers status codes and why the default is rarely the right one for creation. After that it explains route order, the rule that decides which handler wins when two paths could match the same URL. It closes with how to describe what you built so the generated docs make sense, and a short note on designing paths that age well.
If you only read one thing here, read the part on route order. It is the mistake that catches nearly everyone exactly once, and it is easy to avoid once you know it exists.
You do not need any earlier FastAPI experience to follow it. A basic grasp of Python functions is enough, and every idea is introduced as it comes up. Keep a terminal open if you want to try the examples as you read.
The instinct is that @app.get wraps the function in a thing. It doesn't. 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 right still returns the list. This is worth knowing because it means an endpoint is an plain 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 params match to placeholders in the path. This Have defaults. What each is annotated as. That analysis happens once, at import. This 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 turns a route, what happens to the return value. The sorting rule that catches all 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
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules")
def read_modules():
return [{"title": "Vectors"}, {"title": "Norms"}]
print("routes registered:")
for route in app.routes:
methods = getattr(route, "methods", None)
if methods:
print(" %-8s %s" % (",".join(sorted(methods)), route.path))
print()
print("the function is untouched:", read_modules())
Output
The return value becomes JSON
Dicts, lists, models, primitives — all serialised for you. The default status is 200.
example_02.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Module(BaseModel):
title: str
minutes: int
@app.get("/dict")
def a(): return {"kind": "dict"}
@app.get("/list")
def b(): return [1, 2, 3]
@app.get("/model")
def c(): return Module(title="Vectors", minutes=8)
@app.get("/scalar")
def d(): return "just a string"
client = TestClient(app)
for path in ["/dict", "/list", "/model", "/scalar"]:
r = client.get(path)
print("%-8s %s %-14s %s" % (path, r.status_code,
r.headers.get("content-type"), r.text))
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
from fastapi import FastAPI, status
app = FastAPI()
@app.post("/modules", status_code=status.HTTP_201_CREATED)
def create():
return {"created": True}
@app.delete("/modules/{i}", status_code=status.HTTP_204_NO_CONTENT)
def delete(i: int):
return None # 204 means "nothing to say"
client = TestClient(app)
r1 = client.post("/modules")
r2 = client.delete("/modules/1")
print("create :", r1.status_code, r1.json())
print("delete :", r2.status_code, repr(r2.text), "<- 204 has no body")
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.
summary, description and tags shape the documentation. The docstring becomes the description if you do not supply one.
example_05.pyFastAPI
import json
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules", summary="List modules", tags=["modules"])
def read_modules():
"""Return every published module, newest first.
Unpublished drafts are excluded.
"""
return []
spec = app.openapi()["paths"]["/modules"]["get"]
print("summary :", spec["summary"])
print("tags :", spec["tags"])
print("description:")
print(spec["description"])
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
from fastapi import FastAPI, status
from pydantic import BaseModel, Field
app = FastAPI(title="VizLearn")
DB = {1: {"id": 1, "title": "Vectors", "minutes": 8}}
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
minutes: int = Field(default=10, gt=0)
@app.get("/modules", tags=["modules"])
def list_modules():
return list(DB.values())
@app.post("/modules", status_code=status.HTTP_201_CREATED, tags=["modules"])
def create_module(module: ModuleIn):
new_id = max(DB) + 1
DB[new_id] = {"id": new_id, **module.model_dump()}
return DB[new_id]
client = TestClient(app)
print("before :", client.get("/modules").json())
r = client.post("/modules", json={"title": "Eigenvalues", "minutes": 14})
print("created:", r.status_code, r.json())
print("after :", client.get("/modules").json())
Output
The return value
what you return is converted to JSON: dicts, lists, Pydantic models. Primitives like strings and numbers. The default status is 200 and the content type is use/json.
Returning a model is the most handy case. The model decides the shape. Returning a bare string makes "just a string". Valid JSON, with the quotes. Which at times 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 leaning on what the handler happens to return. That gets a module of its own shortly. It's the gap between an endpoint that docs its output and one that doesn't.
That sets the *default* for the route. A handler can still replace it per response when it needs to.
The status module is worth using over bare ints. status.HTTP_201_CREATED says what it means. Your editor will autocomplete it. This 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 makes a malformed response. Declare status_code=204 and return None.
Worth getting right, briefly: 200 for a good read, 201 for a thing created, 204 for a good action with nothing to say, 202 for accepted-but-not-done. Returning 200 for all is common and throws away data each HTTP client already knows how to act on.
Route order
This is the mistake almost all 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 int. If it's annotated str, they get a good lookup for a module that doesn't exist.
The fix is to declare fixed paths before var ones. The fourth editor above shows both orders side by side.
The int annotation does soften this: a truly numeric path still routes right. /modules/latest fails loudly rather than silently. That is one more small arg for annotating path params just.
Describing what you built
Three things shape the docs, and all are free.
summary is the short label in the docs list. Without one, FastAPI makes a title from the function name. This Is often worse than a sentence you write.
tags group endpoints into sections. On an API of any size this is the gap between a navigable doc and a flat list of forty routes.
The docstring turns the description. That is a nice property. Docs written where a Python developer would on its own 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 doesn't enforce a rule, and one is worth having.
Use plural nouns for sets: /modules, not /module or /getModules. The method already says what you're doing. Putting a verb in the path copies it.
Nest only where there's genuine ownership: /modules/{id}/lessons is fair. /tracks/{t}/modules/{m}/lessons/{l}/comments/{c} is a URL no one will type right.
Keep the identifier in the path and the filtering in the query string. /modules/7 finds 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. All you can see. Status codes, headers, JSON bodies, routing choices, check. Is the real action.
client.get("/modules") here does what the same line does in a real test file using fastapi.testclient.TestClient. The code you're reading is the code you'd write.
The other methods
@app.get has siblings for each HTTP method: post, put, patch, delete, head, options, trace.
They aren't the same, and choosing right gives you action from the wider web for free.
GET reads and must not change anything. It's cacheable, retryable and safe to repeat. Browsers, proxies and CDNs all assume that. A GET that mutates state will eventually be replayed by a thing and cause a problem you didn't write.
POST creates, or performs an action that isn't idempotent. Sending it twice does it twice.
PUT replaces a resource at a known URL. Sending it twice leaves the same result. This Makes it safe for a client to retry after a timeout.
PATCH updates part of one.
DELETE drops. Also idempotent: deleting twice leaves the thing deleted.
That idempotency property is the hands-on payoff. A client whose link drops mid-request can safely retry a PUT or DELETE and can't safely retry a POST. Each HTTP library in the world already knows this.
Trailing slashes
/modules and /modules/ are other paths. FastAPI redirects between them with a 307 by default.
That is often invisible and at times maddening. A 307 preserves the method and body. Some clients drop the body on redirect. A POST that mysteriously arrives empty is often this. Being steady in your own routes. Pick no trailing slash and stay with it. Avoids the whole group.
Returning a Response directly
at times you need control over the exact response. A exact content type, a header, a status the route's default doesn't cover.
Returning a Response object bypasses output and the response model fully. JSONResponse, PlainTextResponse, HTMLResponse and RedirectResponse are all on hand.
The trade is that you've opted out of the machinery. Nothing validates what you sent and nothing docs it. The schema no longer shows the endpoint. Do it for the genuine exceptions. A redirect, a file, a bespoke content type. And let the normal path handle all 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 use. Take checked input, call a thing that does the work, turn the result into a response. Business logic inside a handler can't be tested without making a request, can't be reused by a background job or a CLI, and tends to build up.
The shape that stays healthy is a thin handler calling a plain function:
all HTTP-exact is in the decorator and the signature. all else is a function you could call from anywhere.
Mistakes people make with Your First Endpoint
Declaring a var 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 makes a malformed response. Declare the status and return None.
Using 200 for all. A creation is a 201, a good delete is a 204, an accepted-but-unfinished job is a 202. Clients already know how to act on these. Returning 200 throws that away.
Mutating state in a GET. Browsers prefetch, proxies cache. Monitoring replays. A GET that changes a thing will eventually be called when no one asked.
Losing 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. The 307 between them loses the body in some clients. Pick one rule and hold it.
Where to go after Your First Endpoint
Three modules on where data comes from: the path, the query string and the body. Between them they cover almost all an API accepts. Each has rules worth knowing just.
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)
each line is doing a thing. The path names a set. 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 turns the description. And the body is one call into code that knows nothing about HTTP.
Nothing there's clever, and that is the point. The notable parts are in the models and the service. Here They can be tested without making a request.
Where routes live as an app grows
One file works until it doesn't. The usual progression is a single main.py, then a hundred routes in it, then a rewrite no one enjoys.
APIRouter is the answer and it gets a full module later. It's 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 hands-on 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 when a file changes. This 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 each reload. 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 at times 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 turns JSON. status_code sets the route's default and a 204 must carry nothing. Fixed paths go before var ones. And the docstring you were going to write anyway turns the description in your API docs.
A closing thought
The endpoints in this module are four lines each. That is representative rather than simplified. A well-factored FastAPI handler often is short. All it would else hold has moved one spot better. The check into a model, the shape of the response into another model. The work into a function that knows nothing about HTTP.
If a handler is growing, that is often the signal. Not that the endpoint is complicated. That a thing in it belongs elsewhere.
Questions people ask about Your First Endpoint
What does the decorator actually do? It registers the function in the app's routing table under a path and HTTP method. The function itself is returned unchanged.
Why does my endpoint return JSON when I return a dict? FastAPI serializes the return value with its JSON encoder and sets the content type to application/json.
Why can't I reach a route I declared later in the file? Routes match in registration order and the first match wins. Put fixed paths before paths with parameters that would shadow them.
How do I change the default status code? Pass status_code to the decorator, for example @app.post("/items", status_code=201).
How do I run the app? Run uvicorn main:app --reload, where main is the file name and app is the FastAPI instance. The --reload flag is for development only.
Where does the description in /docs come from? From the function's docstring, unless you pass description to the decorator. Markdown in the docstring is rendered.
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 var one that would shadow it? Does it have a summary and a tag. The docs is navigable? And is the handler thin enough that the logic could be tested without a request?
Six questions, most answerable in seconds. Between them they catch nearly all this module given.
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
Write a GET handler, run the app, call the endpoint, and see how the decorator, the return value and the route ordering rule fit together.
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.