Free Ebook cover FastAPI for Beginners: Build a Production-Ready REST API

FastAPI for Beginners: Build a Production-Ready REST API

New course

14 pages

Routing and REST Endpoints in FastAPI

Capítulo 2

Estimated reading time: 8 minutes

+ Exercise

Routing is how FastAPI maps an incoming HTTP request (method + URL path) to a Python function. In FastAPI, those functions are called path operations (also commonly “endpoints”). A REST-style API typically exposes resources (like items) through predictable URLs and HTTP methods: GET to read, POST to create, PUT/PATCH to update, and DELETE to remove.

Path operations: GET/POST/PUT/PATCH/DELETE

A path operation is defined by decorating a function with the HTTP method and path. The function’s parameters become inputs (path params, query params, headers, body) depending on how you declare them.

from fastapi import FastAPI, status

app = FastAPI()

@app.get("/health")
def health_check():
    return {"status": "ok"}

@app.post("/echo", status_code=status.HTTP_201_CREATED)
def echo(payload: dict):
    # payload is parsed from JSON request body
    return {"received": payload}

Key ideas:

  • @app.get(...), @app.post(...), etc. define the method.
  • status_code=... controls the default response status for success.
  • Returning a dict (or list) produces a JSON response automatically.

Path parameters

Path parameters are variables embedded in the URL. They are required and are strongly typed by your function annotations. If a value can’t be converted to the declared type, FastAPI returns a 422 validation error.

from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def get_item(item_id: int):
    return {"item_id": item_id}

Try requests like:

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

  • GET /items/123{"item_id": 123}
  • GET /items/abc → validation error (because abc is not an int)

Query parameters (pagination and filtering patterns)

Query parameters come after ? in the URL and are typically optional. In FastAPI, any function parameter that is not part of the path is treated as a query parameter by default (unless it’s a body model or explicitly declared otherwise).

Pagination with limit/offset

A common REST pattern is limit (page size) and offset (how many records to skip). You can give defaults and types to get validation for free.

from fastapi import FastAPI

app = FastAPI()

FAKE_ITEMS = [
    {"id": 1, "name": "Keyboard"},
    {"id": 2, "name": "Mouse"},
    {"id": 3, "name": "Monitor"},
    {"id": 4, "name": "Laptop"},
]

@app.get("/items")
def list_items(limit: int = 20, offset: int = 0):
    return {
        "limit": limit,
        "offset": offset,
        "data": FAKE_ITEMS[offset : offset + limit],
        "total": len(FAKE_ITEMS),
    }

Example calls:

  • GET /items (defaults: limit=20, offset=0)
  • GET /items?limit=2&offset=2 returns items 3–4

Filtering with optional query parameters

Filtering is often implemented by accepting optional query parameters (e.g., q for search, min_price, in_stock). If a query parameter is omitted, it remains None and you can skip that filter.

from typing import Optional
from fastapi import FastAPI

app = FastAPI()

ITEMS = [
    {"id": 1, "name": "Keyboard", "price": 50, "in_stock": True},
    {"id": 2, "name": "Mouse", "price": 25, "in_stock": True},
    {"id": 3, "name": "Monitor", "price": 200, "in_stock": False},
]

@app.get("/catalog")
def catalog(q: Optional[str] = None, min_price: Optional[int] = None, in_stock: Optional[bool] = None):
    results = ITEMS
    if q:
        q_lower = q.lower()
        results = [i for i in results if q_lower in i["name"].lower()]
    if min_price is not None:
        results = [i for i in results if i["price"] >= min_price]
    if in_stock is not None:
        results = [i for i in results if i["in_stock"] == in_stock]
    return {"count": len(results), "data": results}

Example calls:

  • GET /catalog?q=mouse
  • GET /catalog?min_price=100
  • GET /catalog?in_stock=true&q=key

Grouping routes with APIRouter (prefixes and tags)

As your API grows, putting every endpoint in a single file becomes hard to maintain. APIRouter lets you group endpoints by feature (items, users, orders) and then include them into the main app. This also enables consistent URL prefixes (like /api or /items) and documentation grouping via tags.

Conceptually:

  • Create a router per feature (e.g., items/router.py).
  • Use prefix to avoid repeating path segments.
  • Use tags to group endpoints in the OpenAPI docs.
  • Include the router in the main application with app.include_router(...).
from fastapi import FastAPI
from items.router import router as items_router

app = FastAPI()

app.include_router(items_router, prefix="/api", tags=["items"])

With this pattern, if the router defines @router.get("/"), the final path becomes /api/ (plus any router-level prefix you set inside the router). You can apply prefixes either at include-time, router creation-time, or both.

Returning JSON and controlling status codes

FastAPI will serialize common Python types to JSON automatically (dicts, lists, strings, numbers). You control status codes in two main ways:

  • Set a default success status on the decorator: @router.post(..., status_code=201).
  • Return a custom response with a specific status code (useful for non-standard cases).
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse

router = APIRouter()

@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
    # If deletion succeeds, return nothing for 204
    return

@router.get("/teapot")
def teapot():
    return JSONResponse(status_code=418, content={"detail": "I am a teapot"})

Practical guidance:

  • Use 201 Created for successful creation.
  • Use 204 No Content for successful deletion when you don’t return a body.
  • Use consistent error shapes (often {"detail": ...}) for client-friendly APIs.

Step-by-step: build an items feature module with a router

This section demonstrates a small but realistic router with multiple REST endpoints, including pagination, filtering, and status code control. To keep the focus on routing, we’ll use an in-memory “database” (a list) and simple ID generation.

1) Create the router and in-memory store

from typing import Optional
from fastapi import APIRouter, HTTPException, status

router = APIRouter(prefix="/items")

# In-memory store (replace with a real database later)
ITEMS = [
    {"id": 1, "name": "Keyboard", "price": 50, "in_stock": True},
    {"id": 2, "name": "Mouse", "price": 25, "in_stock": True},
    {"id": 3, "name": "Monitor", "price": 200, "in_stock": False},
]
NEXT_ID = 4

2) List items (GET) with pagination and filtering

@router.get("/")
def list_items(
    limit: int = 20,
    offset: int = 0,
    q: Optional[str] = None,
    in_stock: Optional[bool] = None,
):
    results = ITEMS

    if q:
        q_lower = q.lower()
        results = [i for i in results if q_lower in i["name"].lower()]

    if in_stock is not None:
        results = [i for i in results if i["in_stock"] == in_stock]

    paged = results[offset : offset + limit]

    return {
        "limit": limit,
        "offset": offset,
        "count": len(paged),
        "total": len(results),
        "data": paged,
    }

Try:

  • GET /api/items?limit=2&offset=0
  • GET /api/items?q=mo
  • GET /api/items?in_stock=true

3) Retrieve one item by ID (GET with path parameter)

@router.get("/{item_id}")
def get_item(item_id: int):
    for item in ITEMS:
        if item["id"] == item_id:
            return item
    raise HTTPException(status_code=404, detail="Item not found")

4) Create an item (POST) and return 201

Here we accept a JSON body as a plain dict to keep the example focused on routing. (In a production API, you will typically use Pydantic models for request/response schemas.)

@router.post("/", status_code=status.HTTP_201_CREATED)
def create_item(payload: dict):
    global NEXT_ID

    name = payload.get("name")
    if not name:
        raise HTTPException(status_code=422, detail="'name' is required")

    price = payload.get("price", 0)
    in_stock = payload.get("in_stock", True)

    item = {"id": NEXT_ID, "name": name, "price": price, "in_stock": in_stock}
    NEXT_ID += 1
    ITEMS.append(item)

    return item

Example request body:

{"name": "Webcam", "price": 80, "in_stock": true}

5) Replace an item (PUT)

PUT is commonly used to replace the full resource. If you want to enforce “full replacement,” validate that required fields are present.

@router.put("/{item_id}")
def replace_item(item_id: int, payload: dict):
    name = payload.get("name")
    if not name:
        raise HTTPException(status_code=422, detail="'name' is required")

    price = payload.get("price")
    in_stock = payload.get("in_stock")
    if price is None or in_stock is None:
        raise HTTPException(status_code=422, detail="'price' and 'in_stock' are required")

    for idx, item in enumerate(ITEMS):
        if item["id"] == item_id:
            updated = {"id": item_id, "name": name, "price": price, "in_stock": in_stock}
            ITEMS[idx] = updated
            return updated

    raise HTTPException(status_code=404, detail="Item not found")

6) Partially update an item (PATCH)

PATCH updates only provided fields. A typical pattern is: read existing resource, merge changes, then save.

@router.patch("/{item_id}")
def update_item(item_id: int, payload: dict):
    for idx, item in enumerate(ITEMS):
        if item["id"] == item_id:
            updated = item.copy()
            if "name" in payload:
                updated["name"] = payload["name"]
            if "price" in payload:
                updated["price"] = payload["price"]
            if "in_stock" in payload:
                updated["in_stock"] = payload["in_stock"]

            ITEMS[idx] = updated
            return updated

    raise HTTPException(status_code=404, detail="Item not found")

7) Delete an item (DELETE) with 204 No Content

@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
    for idx, item in enumerate(ITEMS):
        if item["id"] == item_id:
            del ITEMS[idx]
            return
    raise HTTPException(status_code=404, detail="Item not found")

8) Include the router in your app with prefix and tags

In your main application module, include the router. You can apply an API-wide prefix (like /api) and documentation tags.

from fastapi import FastAPI
from items.router import router as items_router

app = FastAPI()

app.include_router(items_router, prefix="/api", tags=["items"])

Resulting endpoints:

MethodPathPurpose
GET/api/itemsList items (pagination/filtering)
GET/api/items/{item_id}Retrieve one item
POST/api/itemsCreate item (201)
PUT/api/items/{item_id}Replace item
PATCH/api/items/{item_id}Partial update
DELETE/api/items/{item_id}Delete item (204)

Now answer the exercise about the content:

In a REST-style FastAPI endpoint for updating an item, what is the key difference between using PUT and PATCH?

You are right! Congratulations, now go to the next page

You missed! Try again.

PUT is commonly used for full replacement, so you validate required fields are present. PATCH is for partial updates, merging only the provided fields into the existing item.

Next chapter

Request and Response Models with Pydantic in FastAPI

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.