In the previous article, we covered why FastAPI was created. Now it's time to break down what actually makes up a FastAPI application. Think of it like getting familiar with the ingredients before you start cooking — so that once we move into writing our first endpoint, the terminology that comes up won't be confusing anymore.
Path Operation — The Basic Unit of an Endpoint
Path operation is FastAPI's term for "one API endpoint": a combination of a path (the URL, e.g. /api/tasks) and an operation (the HTTP method, e.g. GET or POST). In FastAPI, a path operation is defined with a decorator:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health_check():
return {"status": "ok"}
A few things worth understanding from the example above:
@app.get("/health") — a decorator that registers the function below it as the path operation for the GET method at path /health.health_check function is called a path operation function — this is what actually runs when a request comes in.dict here) is automatically converted by FastAPI into a JSON response. No manual json.dumps() needed.HTTP Methods — Mapping CRUD Operations
FastAPI supports every standard HTTP method through a like-named decorator: @app.get, @app.post, @app.put, @app.patch, @app.delete. The convention follows REST semantics:
| Method | Used for | Example |
|---|---|---|
| GET | Fetch data, no side effects | GET /api/tasks — get all tasks |
| POST | Create new data | POST /api/tasks — create a new task |
| PUT | Replace an entire existing record | PUT /api/tasks/1 — replace every field of task #1 |
| PATCH | Modify part of an existing record | PATCH /api/tasks/1 — change only certain fields |
| DELETE | Remove data | DELETE /api/tasks/1 — delete task #1 |
Three Sources of Data: Path, Query, and Body
This is one of the most important concepts to understand before writing any endpoint, because FastAPI tells the three apart automatically based on how we declare parameters on the function.
Path Parameters
Part of the URL itself, declared with curly braces in the path and as a function argument:
@app.get("/api/tasks/{task_id}")
def get_task(task_id: int):
...
task_id here is REQUIRED on every request — it's part of the resource's address. FastAPI automatically converts it to int per the type hint, and rejects the request with a validation error if what's sent isn't a number.
Query Parameters
A parameter written on the function but that does NOT appear in the path is automatically treated by FastAPI as a query parameter — appearing in the URL after ?, like /api/tasks?done=true:
@app.get("/api/tasks")
def list_tasks(done: bool | None = None):
...
Because it has a default value (None), the done parameter is optional. If it's not sent, its value is None. If ?done=true is sent, FastAPI automatically converts the string "true" into the boolean True.
Request Body
For more complex data (usually sent via POST/PUT/PATCH), we use a Pydantic model as the parameter type — covered in the next section.
The simple rule: if the name matches a placeholder in the path → path parameter. If the type is a Pydantic model → body. Anything else → query parameter. No need for different decorators like some other frameworks; FastAPI infers it from the function signature.
Pydantic Models — Data Schemas as Plain Python Code
For data shaped like an object (e.g. a "new task" with a title and priority), we define its schema as a class that inherits from Pydantic's BaseModel:
from pydantic import BaseModel
class TaskCreate(BaseModel):
title: str
priority: str = "medium"
Once this model is used as a parameter type on a path operation function:
@app.post("/api/tasks")
def create_task(payload: TaskCreate):
return {"title": payload.title, "priority": payload.priority}
FastAPI automatically: reads the request body as JSON, validates its fields against TaskCreate (correct types, required fields present), then hands our function a validated TaskCreate object. If the body sent doesn't match the schema — say title is a number, or missing entirely — FastAPI automatically responds with 422 Unprocessable Entity, complete with details on which field is wrong, without us writing a single line of validation code.
Status Codes — Explicit, Not a Guessing Game
By default, a successful response uses status 200 OK. But we can (and should) explicitly set a more accurate status code:
from fastapi import status
@app.post("/api/tasks", status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate):
...
Using constants from fastapi.status (instead of raw numbers like 201) makes code more readable and reduces typos. This is a pattern we'll keep using on POST (201 Created) and DELETE (204 No Content) endpoints in the hands-on sessions ahead.
Automatic Documentation — /docs and /redoc
This is one of the most immediately useful features. Without any extra configuration, every FastAPI app automatically ships with two interactive documentation pages:
/docs — Swagger UI, an interactive view where we can see every endpoint, request/response schemas, and even send requests straight from the browser to try the API./redoc — ReDoc, a cleaner, read-only documentation view (no "try it out" feature).Both pages are generated from an OpenAPI schema (/openapi.json) that FastAPI automatically builds from the path operations, Pydantic models, and type hints we write. Because the source is exactly the same code that's actually running, this documentation can never go stale — the moment we change the code, the docs change with it once the app restarts.
async def vs. def — When to Use Which
FastAPI supports two kinds of path operation function: regular (def) and asynchronous (async def). The simple rule:
async def when the function calls I/O operations that also support async/await — for example, querying a database with an async driver, or calling an external API with an async HTTP client. This lets FastAPI handle other requests while waiting for that I/O to finish.def when the function calls operations that are blocking (synchronous) — for example, a database library that doesn't yet support async. FastAPI automatically runs a regular def function in a separate thread pool, so it doesn't block the main event loop.What to avoid: writing async def but calling synchronous blocking code directly inside it (without await). This actually blocks the entire event loop and makes every other request wait — the opposite of what async is for. We'll dig into this further in the best practices article at the end of the series.
A Glimpse of Dependency Injection
One more concept that will come up often: Depends(). This is how FastAPI manages a dependency — something a path operation function needs in order to run, such as a database connection. For now, it's enough to know it exists and that its purpose is to separate "how to get a resource" from "the endpoint's own logic." We'll cover it in depth once we get to the database integration session.
Summary
@app.get(...).?), request body (via a Pydantic model)./docs, /redoc) is generated straight from the code, always in sync.async def for async I/O operations, plain def for blocking/synchronous ones.