A path operation is FastAPI’s term for a route — a function that handles HTTP requests to a specific URL path and method. The decorator tells FastAPI which HTTP method and path to listen on; the function body defines what to do and what to return. FastAPI automatically handles JSON serialisation of the return value, lets you set HTTP status codes, and uses the response_model parameter to filter the return data — ensuring sensitive fields like passwords are never accidentally included in responses even if they appear in the returned object.
HTTP Method Decorators
from fastapi import FastAPI, status
app = FastAPI()
# ── The five core HTTP methods ─────────────────────────────────────────────────
@app.get("/posts") # Read a list
def list_posts(): ...
@app.get("/posts/{post_id}") # Read one
def get_post(post_id: int): ...
@app.post("/posts", # Create
status_code=status.HTTP_201_CREATED)
def create_post(): ...
@app.put("/posts/{post_id}") # Full update (replace all fields)
def update_post(post_id: int): ...
@app.patch("/posts/{post_id}") # Partial update (some fields)
def patch_post(post_id: int): ...
@app.delete("/posts/{post_id}", # Delete
status_code=status.HTTP_204_NO_CONTENT)
def delete_post(post_id: int): ...
# ── Less common methods ────────────────────────────────────────────────────────
@app.head("/posts") # Like GET but no response body (for caching/existence checks)
def head_posts(): ...
@app.options("/posts") # Returns allowed methods (CORS preflight)
def options_posts(): ...
status module (from Starlette) provides named constants for all HTTP status codes — status.HTTP_201_CREATED, status.HTTP_204_NO_CONTENT, status.HTTP_404_NOT_FOUND. Using named constants instead of bare integers makes code self-documenting and prevents typos. The default status code is 200; for create operations use 201, for delete operations with no body use 204.response_model parameter is the cleanest way to control what data is returned. Define a PostResponse Pydantic model that includes only the fields a client should see (no password_hash, no internal flags), set it as the response_model, and FastAPI will filter the returned object through that model before serialising. Even if your function returns a full SQLAlchemy object with 20 attributes, only the fields in response_model appear in the response.response_model, FastAPI will raise a serialisation error at runtime. Always use response_model or explicitly convert to a dict/Pydantic model before returning.response_model — Controlling Output
from fastapi import FastAPI, status
from pydantic import BaseModel
from datetime import datetime
app = FastAPI()
# Input schema — what the client sends
class PostCreate(BaseModel):
title: str
body: str
# Output schema — what the client receives (subset of what's in the DB)
class PostResponse(BaseModel):
id: int
title: str
body: str
created_at: datetime
model_config = {"from_attributes": True} # allow building from ORM objects
# A hypothetical "full" internal object
class PostFull(BaseModel):
id: int
title: str
body: str
created_at: datetime
password_hash: str # should NEVER be sent to client!
internal_flag: bool # internal, not for API consumers
@app.get(
"/posts/{post_id}",
response_model=PostResponse, # only these fields go to client
response_model_exclude_none=True, # omit None fields from response
)
def get_post(post_id: int) -> PostFull:
# Even though we return PostFull (with password_hash),
# FastAPI filters it through PostResponse — password_hash is stripped
return PostFull(
id=post_id, title="Hello", body="World",
created_at=datetime.now(),
password_hash="secret_hash", # ← filtered out!
internal_flag=True, # ← filtered out!
)
Path Parameters and Route Order
from fastapi import FastAPI
app = FastAPI()
# ROUTE ORDER MATTERS — more specific routes must come first
@app.get("/posts/latest") # ← must be BEFORE /posts/{post_id}
def get_latest_post():
return {"message": "latest post"}
@app.get("/posts/{post_id}") # ← would match /posts/latest if defined first!
def get_post(post_id: int):
return {"id": post_id}
# FastAPI matches routes in the order they are defined
# /posts/latest would match /posts/{post_id} with post_id="latest"
# which then fails Pydantic validation (str != int) → 422 error
# Optional path suffix
@app.get("/items/{item_id}/reviews")
def get_item_reviews(item_id: int): ...
# Multiple path parameters
@app.get("/users/{user_id}/posts/{post_id}")
def get_user_post(user_id: int, post_id: int):
return {"user": user_id, "post": post_id}
Common Mistakes
Mistake 1 — Wrong route order (specific after generic)
❌ Wrong — /posts/latest never reached:
@app.get("/posts/{post_id}") # matches EVERYTHING including /posts/latest
@app.get("/posts/latest") # never reached!
✅ Correct — specific routes first:
@app.get("/posts/latest") # ✓ checked first
@app.get("/posts/{post_id}")
Mistake 2 — Not using response_model (sensitive data leak)
❌ Wrong — returning full ORM object with sensitive fields:
@app.get("/users/{id}")
def get_user(id: int):
return db.query(User).get(id) # includes password_hash in response!
✅ Correct — filter with response_model:
@app.get("/users/{id}", response_model=UserResponse)
def get_user(id: int): ...
Mistake 3 — Using wrong HTTP method for the operation
❌ Wrong — using GET for a state-changing operation:
@app.get("/posts/{id}/publish") # GET should not change state!
✅ Correct — use POST or PATCH for state changes:
@app.post("/posts/{id}/publish", status_code=200) # ✓
Quick Reference
| Method | Decorator | Status Code | Use For |
|---|---|---|---|
| GET | @app.get |
200 | Read |
| POST | @app.post |
201 | Create |
| PUT | @app.put |
200 | Full replace |
| PATCH | @app.patch |
200 | Partial update |
| DELETE | @app.delete |
204 | Delete |