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.
Let's start with the basics. You need to distinguish between identifying a resource (Path) and modifying how you retrieve it (Query).
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.
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.
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.
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”.
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.
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.
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?
HeroFilterParams in 5 different endpoints.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.
| 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. |
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.
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.