Path Parameters and Search
Path parameters put data in the URL itself (/notes/3 fetches note 3). Query parameters add filters after a ? (/notes/search?q=python). FastAPI converts both into function arguments automatically.
{note_id} in the route path becomes a typed function parameter. FastAPI validates the type (422 if /notes/abc and the param is int). Query params via function defaults: required if no default, optional if = None. Route ordering matters because FastAPI matches top-down.
In Lesson 3, you built the POST route for creating notes and the GET route for listing them. But listing all notes is only useful when you have a few. Once you have dozens, you need two things: a way to fetch one specific note by ID, and a way to search across notes by keyword. Both involve passing data through the URL.
Path Parameters: Fetching One Resource
To fetch a single note by ID, put the ID directly in the URL path:
@app.get("/notes/{note_id}", response_model=NoteResponse)
async def get_note(note_id: int) -> dict[str, object]:
for note in _notes:
if note["id"] == note_id:
return note
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
The {note_id} in the URL becomes a function parameter. FastAPI converts it to int automatically (because of the type annotation). If someone visits /notes/abc, FastAPI returns a 422 before your function even runs.
Test it:
- Create a note via POST (if you have not already)
- Visit
http://localhost:8000/notes/1
Output:
{
"id": 1,
"title": "Python Tips",
"body": "FastAPI validates input automatically",
"word_count": 4,
"tags": []
}
- Visit
http://localhost:8000/notes/99
Output (404):
{"detail": "Note 99 not found"}
The HTTPException with status_code=404 returns the correct status code and error message. This is the standard pattern for handling missing resources in any REST API.
Query Parameters: Searching and Filtering
Search needs a different approach. Instead of putting the search term in the URL path, use a query parameter:
@app.get("/notes/search", response_model=list[NoteResponse])
async def search_notes(q: str, tag: str | None = None) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
for note in _notes:
title: str = str(note["title"])
body: str = str(note["body"])
if q.lower() in title.lower() or q.lower() in body.lower():
if tag is None or tag in note.get("tags", []):
results.append(note)
return results
Query parameters come from the URL after the ?:
http://localhost:8000/notes/search?q=python
http://localhost:8000/notes/search?q=python&tag=tips
| Parameter | Type | Required | How it is passed |
|---|---|---|---|
q | str | Yes (no default) | ?q=python |
tag | str | None | No (default None) | &tag=tips |
FastAPI knows q is required because it has no default value. If someone visits /notes/search without ?q=something, they get a 422. The tag parameter is optional because it defaults to None.
Route Ordering: Why It Matters
FastAPI matches routes in the order you define them. If /notes/{note_id} is defined before /notes/search, a request to /notes/search will match the path parameter route. FastAPI will try to convert the string "search" to an integer, fail, and return a 422 error.
Always define fixed-path routes before parameterized routes:
# CORRECT order
@app.get("/notes/search", ...) # Fixed path: matched first
async def search_notes(...): ...
@app.get("/notes/{note_id}", ...) # Parameterized: matched second
async def get_note(...): ...
# WRONG order - /notes/search will never be reached
@app.get("/notes/{note_id}", ...) # This catches EVERYTHING after /notes/
async def get_note(...): ...
@app.get("/notes/search", ...) # Never reached
async def search_notes(...): ...
This is not a FastAPI bug. It is how pattern matching works: specific patterns must come before general patterns. The same principle applies in if/elif chains (specific conditions first) and in argparse subcommands (exact matches before wildcards).
Path vs Query: When to Use Each
| Use case | Approach | Example |
|---|---|---|
| Identify a specific resource | Path parameter | /notes/5 (always note 5) |
| Filter a collection | Query parameter | /notes?tag=python (all notes with tag) |
| Search across resources | Query parameter | /notes/search?q=fastapi |
| Required identifier | Path parameter | /users/{user_id}/notes |
| Optional modifier | Query parameter | /notes?sort=newest&limit=10 |
The rule of thumb: path parameters name a specific thing, query parameters modify or filter the result.
PRIMM-AI+ Practice: Route Behavior
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
You have these three routes defined in this order:
@app.get("/notes/search")
async def search_notes(q: str): ...
@app.get("/notes/export")
async def export_notes(): ...
@app.get("/notes/{note_id}")
async def get_note(note_id: int): ...
Predict what happens for each request:
GET /notes/search?q=pythonGET /notes/exportGET /notes/42GET /notes/hello
Write down the expected status code for each (200, 404, or 422) and which function handles it.
Run
Press Shift+Tab to exit Plan Mode.
Add all three routes to your smartnotes/api.py (if you have not already). Start the server and test each URL in your browser. Compare the actual status codes to your predictions.
Investigate
Run /investigate @smartnotes/api.py in Claude Code and ask: "If I move the /notes/{note_id} route to the TOP of my file (before /notes/search), what breaks? Show me the exact error a user would see when visiting /notes/search."
Modify
Add a limit query parameter to the search route: GET /notes/search?q=python&limit=5. The parameter should default to 10 and cap the number of results returned. Predict what happens if someone sends limit=abc (a string instead of an integer) before testing.
Make [Mastery Gate]
Add a GET /tags route that returns all unique tags across all notes as a sorted list. Requirements:
response_model=list[str]- Returns an empty list when no notes have tags
- Returns sorted, deduplicated tags when notes have tags
Use the /tdg cycle:
- Write the stub with type annotations
- Create two notes with different tags using
/docs - Visit
http://localhost:8000/tagsand verify the output
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Path vs Query Design
My API has these two routes:
GET /notes/{note_id} - fetch one note by ID
GET /notes/search?q=... - search notes by keyword
When should I use a path parameter ({note_id}) versus
a query parameter (?q=...)? Give me a rule of thumb
and three examples where each is the right choice.
What you're learning: Path parameters identify a specific resource (/notes/5 is always note 5). Query parameters filter or modify the result (/notes?tag=python filters the list). This distinction is a REST convention, not a technical requirement, but following it makes your API predictable to other developers.
Prompt 2: Route Ordering Dangers
My FastAPI app has these routes in this order:
@app.get("/notes/{note_id}")
async def get_note(note_id: int): ...
@app.get("/notes/search")
async def search_notes(q: str): ...
@app.get("/notes/export")
async def export_notes(): ...
What is wrong with this ordering? Show me exactly what
happens when a user visits /notes/search and /notes/export.
Then show me the correct order and explain the rule.
What you're learning: Route ordering bugs are subtle because they do not produce Python errors. The code runs fine, but users get confusing 422 responses. Understanding the "specific before general" rule prevents this entire category of bugs.
Prompt 3: Design a Multi-Filter Search
My search route currently supports q (keyword) and tag
(single tag filter). I want to extend it to support:
- Multiple tags (notes matching ANY of the given tags)
- A date range (notes created after a certain date)
- Sorting (by title, by date, by word count)
Show me how to add these as query parameters. Which
should be required and which optional? How do I handle
multiple values for the same parameter (like multiple tags)?
What you're learning: Real search endpoints grow complex quickly. FastAPI handles multiple values for the same parameter using list[str] type annotations (e.g., tags: list[str] = Query(default=[])). Designing search parameters upfront prevents painful API changes later when you need to maintain backwards compatibility.
James visits /notes/1 and gets the note. He visits /notes/99 and gets a 404. He visits /notes/search?q=python and gets matching results. "Three different URLs, three different behaviors. But they all come from the same _notes list."
"That is the resource model," Emma says. "A note with ID 1 is a specific resource, so it lives at a specific URL. A search is a query against the collection, so it uses query parameters. The URL structure tells users what to expect before they even see the response."
She pulls up the code. "You have one trap to remember. Where did you define the search route?"
James scrolls. "Before the {note_id} route."
"Good. If you ever move it after, the search route breaks silently. No Python error, no crash. Just a confusing 422 that looks like a validation bug. FastAPI matches top-down. Specific routes first, parameterized routes last."
She closes the browser. "Your routes work. Your models validate. But you are testing everything by clicking buttons in /docs. What if you change the search logic tomorrow and break the tag filter? You would not know until someone complains."
"I need automated tests."
"You need TestClient. It is like subprocess.run from Ch 65, but for HTTP. Instead of running a CLI command and checking stdout, you send an HTTP request and check the response. Same TDG cycle, different interface."