FastAPI Endpoint Inputs: Path, Query, Body, and Dependencies

Stop writing messy API signatures. Master FastAPI parameters: from basic Path & Query to advanced Pydantic models and Dependencies. Learn to build cleaner, reusable, and type-safe endpoints in this comprehensive backend guide.



Even if you have been building backend APIs for years, you eventually run into a moment where a seemingly simple task makes you pause. You know the basics of creating an endpoint, but suddenly you need to combine a path parameter, a complex JSON body, and a few optional query filters all at once.

It is easy to forget the nuances when requirements get specific. That is exactly why I wrote this post: to collect, organize, and clarify the different ways FastAPI handles inputs like Path, Query, Body, and dependencies all in one place.

To demonstrate this, we are going to build the “Hero Guild API”. We will move beyond simple examples and tackle real-world scenarios, showing you exactly how to slice and dice data for our database of adventurers.

Let’s start.

1. The Basics: Path vs. Query Parameters

Let's start with the basics. You need to distinguish between identifying a resource (Path) and modifying how you retrieve it (Query).

Path Parameters

Path parameters are part of the URL itself. They are mandatory. If the URL is a street address, the path parameter is the house number.

from fastapi import FastAPI, HTTPException

app = FastAPI()

FAKE_DB = {
    1: {"name": "Aragorn", "class": "Ranger"},
    2: {"name": "Gandalf", "class": "Wizard"},
}

@app.get("/heroes/{hero_id}")
async def get_hero(hero_id: int):
    """
    FastAPI detects 'hero_id' in the path string and matches it
    to the function argument.
    """
    if hero_id not in FAKE_DB:
        raise HTTPException(status_code=404, detail="Hero not found")
    return FAKE_DB[hero_id]

FastAPI saw hero_id: int. If a user requests /heroes/abc, FastAPI intercepts it, sees that “abc” isn't an integer, and throws a validation error before your code even runs. You don't need if not isinstance(hero_id, int) checks.

Query Parameters

If a function argument is declared but not present in the path string, FastAPI automatically assumes it’s a Query Parameter. These appear after the ? in the URL.

@app.get("/heroes/")
async def search_heroes(name: str):
    """
    URL: /heroes/?name=Aragorn
    """
    return {"message": f"Searching for hero named {name}"}

Here, name is required. If you hit /heroes/, you get a 422 Missing Parameter error. But in the real world, filters are usually optional.

2. Required vs. Optional and The Power of Defaults

To make a parameter optional in FastAPI, you simply provide a default value.

@app.get("/heroes/filter")
async def filter_heroes(
    is_active: bool = True,       # Optional, defaults to True
    min_level: int | None = None  # Optional, defaults to None
):
    """
    URL examples:
    - /heroes/filter (is_active=True, min_level=None)
    - /heroes/filter?is_active=false&min_level=5
    """
    query = "SELECT * FROM heroes WHERE active = :active"
    params = {"active": is_active}

    if min_level is not None:
        query += " AND level >= :min_level"
        params["min_level"] = min_level

    return {"query": query, "params": params}

Notice min_level: int | None = None. This is crucial. If you just wrote min_level: int = 0, you couldn't distinguish between “the user searched for level 0” and “the user didn't search at all”. By using None, you know exactly when to apply the SQL filter logic. A note on booleans. FastAPI is smart. If you pass ?is_active=yes, on, 1, or true, it converts them all to the Python boolean True. You don't have to parse strings manually.

3. The Body: Sending Data with Pydantic

Query params are for small strings/numbers. When you need to create a whole new Hero, you need a Request Body. This is where Pydantic shines. We use Field here to add metadata and validation rules to the schema itself.

from pydantic import BaseModel, Field

class HeroCreate(BaseModel):
    # ... means this field is required
    name: str = Field(..., min_length=2, max_length=50, description="The hero's public name")
    hero_class: str = Field(..., example="Paladin")
    # We set a default, but also enforce rules
    level: int = Field(default=1, ge=1, le=100, description="Level must be 1-100")
    inventory: list[str] = []

@app.post("/heroes/")
async def create_hero(hero: HeroCreate):
    """
    FastAPI reads the JSON body, validates it against HeroCreate,
    and converts it into a Python object
    """
    # At this point, we KNOW level is between 1 and 100
    return {"message": f"Hero {hero.name} created!", "data": hero.model_dump()}

Why use Field? Because frontend developers are human. They will send a level of 105 or an empty name string. Field acts as the bouncer at the club door. ge=1 means “Greater than or Equal to 1”.

4. The “Triple Threat”: Path + Query + Body

Can we mix them? Absolutely. This is a very common pattern for PUT requests. You often need to explicitly tell FastAPI what is what using Path and Query functions. This helps avoid ambiguity.

from fastapi import Query, Path, Body

@app.put("/heroes/{hero_id}")
async def update_hero(
    # Explicitly mark this as a Path param
    hero_id: int = Path(..., title="The ID of the hero to update", ge=1),

    # The body payload
    hero_data: HeroCreate = Body(...),

    # A query param to toggle behavior
    notify_guild: bool = Query(default=False, description="Send an email to the guild?")
):
    """
    Path: hero_id
    Body: hero_data (JSON)
    Query: notify_guild
    """
    return {
        "id": hero_id,
        "updated_data": hero_data,
        "notification_sent": notify_guild
    }

Why explicit Path and Query? While FastAPI usually infers this correctly, being explicit serves as documentation. It allows you to add extra validation like ge=1 for the ID without creating a Pydantic model for a single integer.

5. The List Dilemma: Query Parameters with Multiple Values

This is where beginners often get stuck. You want to filter heroes by multiple classes like /heroes/search?tag=fire&tag=ice ? If you just define tags: list[str] = [], FastAPI might get confused and think you are expecting a JSON array in the Body. To tell FastAPI “Check the query string for multiple values”, you must use Query.

@app.get("/heroes/search_by_tags")
async def search_by_tags(
    tags: list[str] = Query(default=[], alias="tag")
):
    """
    URL: /heroes/search_by_tags?tag=fire&tag=ice
    """
    if not tags:
        return {"message": "No tags provided"}

    return {"filtering_by": tags}

Notice alias="tag". Python variable names usually imply plural like tags, but in a URL, it often looks better singular like ?tag=a&tag=b. The alias parameter bridges this gap. Your Python code uses tags as a list, but the URL expects tag.

6. Common Logic with Pydantic

Imagine your search endpoint is getting out of hand. You have pagination, sorting, filtering by level, class, name, active status... Your function signature looks like a disaster:

# Hello Spaghetti! Avoid to do this
@app.get("/heroes/messy")
def messy_search(
    q: str | None = None,
    page: int = 1,
    limit: int = 20,
    sort_by: str = "name",
    min_level: int = 1,
    max_level: int = 100
):
    pass

This is ugly, hard to test, and hard to reuse. We can bundle these into a Pydantic model, but use it for Query Parameters.

from fastapi import Depends

class HeroFilterParams(BaseModel):
    q: str | None = None
    page: int = Field(default=1, ge=1)
    limit: int = Field(default=20, le=100)
    sort_by: str = "name"
    
    # Optional: Throw error if user sends unknown params
    model_config = ConfigDict(extra="forbid")

@app.get("/heroes/clean")
async def clean_search(params: HeroFilterParams = Depends()):
    """
    FastAPI treats the Pydantic fields as Query parameters
    because we used Depends()
    """
    return {"filter": params.q, "page": params.page}

Why is this amazing?

  1. Reusability: You can use HeroFilterParams in 5 different endpoints.
  2. Clean Code: Your endpoint logic focuses on business logic, not unpacking 10 different variables.

7. Common Parameters with Class-Based Dependencies

Let's take it a step further. Sometimes you don't want a Pydantic model which implies data validation, but just a reusable logic block. Every single list endpoint in your API needs Pagination like skip and limit. Writing skip: int = 0, limit: int = 100 in every function violates DRY (Don't Repeat Yourself). Let's create a reusable Pagination class.

class Pagination:
    def __init__(
        self,
        skip: int = Query(default=0, ge=0, description="Items to skip"),
        limit: int = Query(default=10, ge=1, le=100, description="Max items")
    ):
        self.skip = skip
        self.limit = limit

@app.get("/heroes/paginated")
async def list_heroes(pagination: Pagination = Depends()):
    # We access pagination.skip and pagination.limit directly
    return {
        "data": FAKE_DB, # pretend we sliced this
        "meta": {"skip": pagination.skip, "limit": pagination.limit}
    }

@app.get("/items/paginated")
async def list_items(pagination: Pagination = Depends()):
    # Look! We reused the exact same logic!
    return {"items": [], "meta": {"limit": pagination.limit}}

This is powerful because if you decide tomorrow that the maximum limit should be 50 instead of 100, you change it in one place, Pagination class, and it updates your entire API documentation and validation logic instantly.

Unlike the Pydantic example above, this is a standard Python class. Depends inspects the __init__ method and injects the query parameters automatically.

Quick Look Table

Parameter Type Where it lives Code Example Note
Path URL /users/{id} id: int Mandatory identifier.
Query URL ?sort=desc sort: str = "asc" Optional filtering/sorting.
Body HTTP Payload data: HeroModel Uses Pydantic for JSON data.
List URL ?tag=a&tag=b tags: list[str] = Query() Needs Query to denote list source.
Dependency Shared Logic params: Class = Depends() Groups common params together.

 

Final Thoughts

FastAPI's parameter handling is deceptively simple. You can write stringly-typed messy code if you want, but the framework begs you to use types.

  1. Use Path for IDs.
  2. Use Query for simple filters.
  3. Use Pydantic Models for Bodies.
  4. Use Depends() with Classes to group common parameters like Pagination together so you don't repeat yourself.

Treat your endpoint signatures like a contract. The stricter and cleaner they are, the less error-handling code you have to write inside the function.

Related Posts