FastAPI · lesson 3 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Path and Query Parameters
How FastAPI converts path and query parameters from type hints: defaults, optional values, bool parsing, Enum, Query and Path limits, and real 422 bodies.
By SkillByExample · Published
Two Places to Put Data in a URL
A URL can carry values in two places. In http://127.0.0.1:8000/items/42?limit=5&upper=true, the path is /items/42 and the query string is limit=5&upper=true.
A path parameter is a segment of the path itself, like 42. A query parameter is a name=value pair after the ?, separated by &. In FastAPI you declare both as ordinary function arguments, and the type hint on each argument decides how its text is converted. All outputs come from FastAPI 0.143.0 and Pydantic 2.14.1 on Python 3.12; run each file with uvicorn <file>:app.
Path Parameters Are Converted, or Rejected
Everything in a URL is text. Save as path.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"item_id": item_id, "type": type(item_id).__name__}
@app.get("/files/{name}")
def get_file(name: str):
return {"name": name}
$ curl -i http://127.0.0.1:8000/items/42
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:56:01 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"item_id":42,"type":"int"}
$ curl -i http://127.0.0.1:8000/items/abc
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 06:56:01 GMT
server: uvicorn
content-length: 152
content-type: application/json
{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
The URL held the text "42", and the function received the integer 42, because of item_id: int. When the text can’t be converted, FastAPI answers 422 Unprocessable Entity without calling your function. The body is a list of errors, each with four parts worth reading:
type: a machine-readable error code, hereint_parsing.loc: where the bad value was, here thepathparameteritem_id.msg: a human-readable message.input: exactly what the client sent.
The conversion is less strict than you might guess:
$ curl -s http://127.0.0.1:8000/items/4.0
{"item_id":4,"type":"int"}
$ curl -s http://127.0.0.1:8000/items/4.5
{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"4.5"}]}
$ curl -s http://127.0.0.1:8000/items/-7
{"item_id":-7,"type":"int"}
"4.0" is accepted as 4 because no information is lost; "4.5" would lose the .5, so it’s rejected. -7 is a perfectly valid int. If negative ids make no sense, you have to say so, which the Path(ge=1) example below does.
A plain path parameter matches one segment only, so a slash ends it:
$ curl -s http://127.0.0.1:8000/files/report.pdf
{"name":"report.pdf"}
$ curl -s http://127.0.0.1:8000/files/2024/report.pdf
{"detail":"Not Found"}
/files/2024/report.pdf has two segments after /files, so no route matches and it’s a 404, not a 422. Practice task 2 shows how to capture a whole sub-path.
Route Order Decides Who Wins
Save as order.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{username}")
def get_user(username: str):
return {"user": username}
@app.get("/users/me")
def get_me():
return {"user": "the logged-in user"}
$ curl -s http://127.0.0.1:8000/users/alice
{"user":"alice"}
$ curl -s http://127.0.0.1:8000/users/me
{"user":"me"}
get_me never runs. FastAPI tries routes in the order they were declared, and /users/{username} matches /users/me with username="me" before /users/me gets a chance. Fixed paths must come before parameterised ones that could swallow them.
Query Parameters: Anything Not in the Path
Any function argument that isn’t named in the route’s path is read from the query string. Save as query.py:
from fastapi import FastAPI
app = FastAPI()
BOOKS = ["Dune", "Emma", "Ulysses", "Beloved", "Middlemarch"]
@app.get("/books")
def list_books(skip: int = 0, limit: int = 2, upper: bool = False):
page = BOOKS[skip : skip + limit]
return [b.upper() for b in page] if upper else page
@app.get("/search")
def search(q: str, max_results: int | None = None):
hits = [b for b in BOOKS if q.lower() in b.lower()]
return hits[:max_results]
Quote URLs containing ? or & in the shell, or & will start a background job:
$ curl -s http://127.0.0.1:8000/books
["Dune","Emma"]
$ curl -s 'http://127.0.0.1:8000/books?skip=2&limit=3'
["Ulysses","Beloved","Middlemarch"]
$ curl -s 'http://127.0.0.1:8000/books?limit=ten'
{"detail":[{"type":"int_parsing","loc":["query","limit"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"ten"}]}
$ curl -s 'http://127.0.0.1:8000/books?colour=red'
["Dune","Emma"]
A default value makes a query parameter optional, and the defaults applied when none were sent. The 422 for limit=ten has the same shape as before, but loc now starts with query. An unknown parameter like colour=red is silently ignored, not an error. A typo such as ?limt=3 won’t warn you; it just uses the default.
Booleans accept more than true and false:
$ curl -s 'http://127.0.0.1:8000/books?upper=yes'
["DUNE","EMMA"]
$ curl -s 'http://127.0.0.1:8000/books?upper=1'
["DUNE","EMMA"]
$ curl -s 'http://127.0.0.1:8000/books?upper=maybe'
{"detail":[{"type":"bool_parsing","loc":["query","upper"],"msg":"Input should be a valid boolean, unable to interpret input","input":"maybe"}]}
true/false, 1/0, yes/no and on/off all work. maybe is rejected instead of quietly counting as false.
search has no default for q, so q is required:
$ curl -i http://127.0.0.1:8000/search
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 06:56:10 GMT
server: uvicorn
content-length: 87
content-type: application/json
{"detail":[{"type":"missing","loc":["query","q"],"msg":"Field required","input":null}]}
$ curl -s 'http://127.0.0.1:8000/search?q=e'
["Dune","Emma","Ulysses","Beloved","Middlemarch"]
$ curl -s 'http://127.0.0.1:8000/search?q=e&max_results=2'
["Dune","Emma"]
int | None = None is how you say “optional, and no value at all if absent”. hits[:None] slices the whole list, so leaving max_results out returns every hit.
Limits, Fixed Choices, and Repeated Values
Type hints check types. To check values, use an Enum for a fixed set of choices, and Path() / Query() for limits. Save as validate.py:
from enum import Enum
from fastapi import FastAPI, Path, Query
app = FastAPI()
class Size(str, Enum):
small = "small"
medium = "medium"
large = "large"
@app.get("/coffee/{size}")
def order_coffee(size: Size):
return {"size": size, "is_large": size is Size.large}
@app.get("/pages/{page}")
def get_page(
page: int = Path(ge=1),
per_page: int = Query(default=10, ge=1, le=50),
tag: list[str] = Query(default=[]),
):
return {"page": page, "per_page": per_page, "tags": tag}
$ curl -s http://127.0.0.1:8000/coffee/large
{"size":"large","is_large":true}
$ curl -s http://127.0.0.1:8000/coffee/huge
{"detail":[{"type":"enum","loc":["path","size"],"msg":"Input should be 'small', 'medium' or 'large'","input":"huge","ctx":{"expected":"'small', 'medium' or 'large'"}}]}
$ curl -s http://127.0.0.1:8000/coffee/Large
{"detail":[{"type":"enum","loc":["path","size"],"msg":"Input should be 'small', 'medium' or 'large'","input":"Large","ctx":{"expected":"'small', 'medium' or 'large'"}}]}
The function receives a real Size member, so size is Size.large works. The error lists the allowed values in ctx.expected, and the match is case-sensitive: Large fails. /docs also shows the three choices as a dropdown.
$ curl -s 'http://127.0.0.1:8000/pages/3?tag=python&tag=web'
{"page":3,"per_page":10,"tags":["python","web"]}
$ curl -s 'http://127.0.0.1:8000/pages/3?tag=python,web'
{"page":3,"per_page":10,"tags":["python,web"]}
$ curl -i 'http://127.0.0.1:8000/pages/0?per_page=500'
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 06:56:17 GMT
server: uvicorn
content-length: 275
content-type: application/json
{"detail":[{"type":"greater_than_equal","loc":["path","page"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}},{"type":"less_than_equal","loc":["query","per_page"],"msg":"Input should be less than or equal to 50","input":"500","ctx":{"le":50}}]}
A list[str] query parameter collects every repetition of ?tag=. It does not split on commas: tag=python,web is one tag with a comma in it. It needs Query() explicitly, because without it FastAPI would expect a list to come from the request body.
The last request broke two rules, and the 422 reports both at once, one entry per problem. ge means greater than or equal, le less than or equal; gt and lt are the strict versions. ctx carries the limit that was broken, so a client can build its own message from it.
Practice
- Fix
order.pyso/users/mereturns the logged-in user while/users/alicestill works.
Solution
Move the fixed route above the parameterised one:
@app.get("/users/me")
def get_me():
return {"user": "the logged-in user"}
@app.get("/users/{username}")
def get_user(username: str):
return {"user": username}
$ curl -s http://127.0.0.1:8000/users/me
{"user":"the logged-in user"}
$ curl -s http://127.0.0.1:8000/users/alice
{"user":"alice"}
- Make a route that captures
2024/report.pdffrom/files/2024/report.pdf, slashes included. Hint: Starlette has a:pathconverter.
Solution
@app.get("/files/{file_path:path}")
def get_file(file_path: str):
return {"file_path": file_path}
$ curl -s http://127.0.0.1:8000/files/2024/report.pdf
{"file_path":"2024/report.pdf"}
{file_path:path} matches the rest of the URL, slashes and all. Never pass such a value straight to open(): a client could send ../ segments to escape the folder you meant.
/search?q=ematches every book. Requireqto be at least 2 characters long, and find the errortypeFastAPI uses.
Solution
@app.get("/search")
def search(q: str = Query(min_length=2)):
return [b for b in BOOKS if q.lower() in b.lower()]
$ curl -s 'http://127.0.0.1:8000/search?q=e'
{"detail":[{"type":"string_too_short","loc":["query","q"],"msg":"String should have at least 2 characters","input":"e","ctx":{"min_length":2}}]}
$ curl -s 'http://127.0.0.1:8000/search?q=em'
["Emma","Middlemarch"]
Query(min_length=2) with no default keeps q required. max_length and pattern (a regular expression) work the same way.
- Predict what
/books?skip=-2returns fromquery.py, then run it. Make negative values a 422.
Solution
$ curl -s 'http://127.0.0.1:8000/books?skip=-2'
[]
An empty list, with 200 OK. BOOKS[-2:0] is an empty slice in Python, so a nonsense value silently produced a wrong answer. Reject it at the edge:
@app.get("/books")
def list_books(skip: int = Query(default=0, ge=0), limit: int = 2):
return BOOKS[skip : skip + limit]
$ curl -s 'http://127.0.0.1:8000/books?skip=-2'
{"detail":[{"type":"greater_than_equal","loc":["query","skip"],"msg":"Input should be greater than or equal to 0","input":"-2","ctx":{"ge":0}}]}