Path Operations — Decorators, HTTP Methods and Response Models

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(): ...
Note: FastAPI’s 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.
Tip: The 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.
Warning: FastAPI serialises return values via its JSON encoder, which handles common types (datetime → ISO string, UUID → string, Decimal → float). However, it cannot serialise arbitrary Python objects — only basic types, Pydantic models, dicts, lists, and a few others. If you return a SQLAlchemy ORM object without a matching 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

🧠 Test Yourself

You define @app.get("/posts/{post_id}") before @app.get("/posts/featured"). What happens when a client requests GET /posts/featured?