FastAPI: Path Params, Query, Body and HTTPException
- Understand Type Hints and how FastAPI uses them for conversion and validation
- Define a Path Parameter and Query Parameters (required, with a default and optional)
- Define a Body with a Pydantic model (BaseModel) and receive it in a POST
- Return the right codes — 201 on creation, 404 with HTTPException — and understand the automatic 422
What will we learn?
In the previous lesson the functions received no input at all. Now they will receive the three kinds we learned:
| Input | How FastAPI knows |
|---|---|
| Path Parameter | A name in curly brackets in the path: "/students/{student_id}" |
| Query Parameter | A function parameter that does not appear in the path |
| Body | A parameter whose type is a Pydantic model |
And in addition: Type Hints, status_code, HTTPException, and the automatic 422 error.
Why do we need this?
Without input, a server can only always return the same thing. With input, it can return a specific student, filter, and create new students. And a huge bonus: FastAPI checks and translates the input for us. A hand-written calculator server crashed on "hello". Here, wrong input will automatically get a 422 with an exact explanation, and the server will keep working.
Explanation from the basics
Type Hints
Type Hint = a note in the code that says what the expected type of a parameter is:
def get_student(student_id: int):student_id: int = “this parameter is supposed to be an int”. In regular Python, Type Hints are not enforced: they are only documentation, which helps VS Code and developers. But FastAPI reads them and uses them:
- Conversion — the string
"7"arrives from the URL, and FastAPI converts it to7, because we asked forint. - Validation — if
"abc"arrived, FastAPI does not call the function, but returns a 422 with an explanation. - Documentation —
/docsshows the type of every parameter.
Path Parameter
@app.get("/students/{student_id}")
def get_student(student_id: int):{student_id} in the path = “this part changes”. FastAPI passes it to the parameter with exactly the same name.
Query Parameters
Every function parameter that does not appear in the path (and is not a model) = a Query Parameter.
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):| Definition | Meaning |
|---|---|
min_age: int = 0 | Optional, with a default of 0. |
limit: int (without =) | Required. Without it → 422. |
city: str | None = None | Optional, without a default value: “either a string, or None”. None = “it was not sent, so we don’t filter”. |
str | None is read “str or None”. It is a Type Hint that says two types are allowed.
Body with Pydantic
Pydantic is a library (installed together with FastAPI) that defines a structure of data with types. You create a Class that inherits from BaseModel (Inheritance, the OOP topic!):
from pydantic import BaseModel
class StudentIn(BaseModel):
name: str
age: int
city: str = "Unknown"- Every line = a field, with a type. A field with
=is optional, with a default. - There is no
__init__!BaseModelbuilds it by itself from the fields. - When a function’s parameter is of this type, FastAPI understands that it is a Body: it reads the JSON, checks that all the fields exist and are of the right type, and builds an object. You access the fields with a dot:
student.name.
A Pydantic model is a printed form: “Name (required, text). Age (required, a number). City (optional)”. The clerk (FastAPI) does not pass on for handling (to your function) a form that is missing a required field, or that has “twenty” written in the age field. They immediately return it to the person who submitted it (422) with the problematic field marked. Your function receives only valid forms.
status_code and HTTPException
@app.post("/students", status_code=201)— the success code of this Endpoint is 201 Created, and not 200.raise HTTPException(status_code=404, detail="...")— stops the function and returns an error code to the Client with the JSON{"detail": "..."}. It is a regularraise(remember the exceptions topic!), and FastAPI catches it and turns it into an HTTP response.
Syntax
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class ItemIn(BaseModel): # body model
name: str
price: float
in_stock: bool = True
@app.get("/items/{item_id}") # path parameter
def get_item(item_id: int):
...
raise HTTPException(status_code=404, detail="item not found")
@app.get("/items") # query parameters
def list_items(max_price: float | None = None, limit: int = 10):
...
@app.post("/items", status_code=201) # body
def create_item(item: ItemIn):
item.name, item.price # use the validated fieldsFirst code example
A students API in memory (in RAM. There is no Database yet, it will come in the databases topic). week-4/hello-api/students_memory.py:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Students (in memory)")
students = [
{"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
{"id": 3, "name": "Noa", "age": 22, "city": "Haifa"},
]
class StudentIn(BaseModel):
name: str
age: int
city: str = "Unknown"
@app.get("/students")
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):
result = []
for student in students:
if city is not None and student["city"] != city:
continue
if student["age"] < min_age:
continue
result.append(student)
return result[:limit]
@app.get("/students/{student_id}")
def get_student(student_id: int):
for student in students:
if student["id"] == student_id:
return student
raise HTTPException(status_code=404, detail="student " + str(student_id) + " not found")
@app.post("/students", status_code=201)
def create_student(student: StudentIn):
new_id = max(s["id"] for s in students) + 1
new_student = {"id": new_id, "name": student.name, "age": student.age, "city": student.city}
students.append(new_student)
return new_studentRunning: uvicorn students_memory:app --reload (notice: the file’s name changed!).
Breaking down the code line by line
| Code | Explanation |
|---|---|
students = [...] | A temporary “Database” in RAM: a list of dicts. It is deleted on every restart (remember RAM from the computer topic!). In the databases topic we will move to a real Database. |
class StudentIn(BaseModel): | The Body model: what the Client sends on creation. There is no id in it, because the server sets the id. |
city: str = "Unknown" | An optional field in the Body. |
list_students(city: str | None = None, min_age: int = 0, limit: int = 10) | Three Query Parameters, all optional. /students?city=Haifa&min_age=23. |
if city is not None and ...: continue | Filtering only if city was sent. continue skips to the next student. |
return result[:limit] | Slicing: at most limit results. |
@app.get("/students/{student_id}") + student_id: int | A Path Parameter, with conversion to int. |
raise HTTPException(status_code=404, ...) | Not found, so a 404 with a message. |
@app.post(..., status_code=201) | Creating returns 201. |
create_student(student: StudentIn) | The type is a model, so it is a Body. FastAPI decodes and checks it. |
student.name, student.age | Accessing the fields of an object (the OOP topic). |
max(s["id"] for s in students) + 1 | A new identifier: the largest + 1. |
Another example
A full test of all the kinds of input, and the real responses. (You can also test in /docs, or with curl.)
| Request | Code | Response |
|---|---|---|
GET /students | 200 | All three |
GET /students?city=Haifa | 200 | Dana and Noa |
GET /students?min_age=23&limit=1 | 200 | [{"id": 1, "name": "Dana", ...}] |
GET /students/2 | 200 | {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"} |
GET /students/42 | 404 | {"detail": "student 42 not found"} |
GET /students/abc | 422 | See below |
GET /students?min_age=old | 422 | See below |
POST /students + {"name": "Tom", "age": 27} | 201 | {"id": 4, "name": "Tom", "age": 27, "city": "Unknown"} |
POST /students + {"name": "Rina", "age": "twenty"} | 422 | See below |
The 422 error — what it looks like (GET /students/abc):
{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "student_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "abc"
}
]
}Reading a 422:
loc(Location) — where the problem is:["path", "student_id"]. In the Query it would be["query", "min_age"], and in the Body["body", "age"].msg— what the problem is.input— what was actually received.- Our function did not run at all. FastAPI stopped the request before that. There is no need for
try/exceptaroundint().
And after Tom’s POST: GET /students?city=Unknown returns Tom, because the city got the default. But stop the server and start it again: Tom disappeared. The data was in RAM. That is exactly the reason for a Database (coming next!).
Common mistake
Mistake 1: a name that does not match between the path and the parameter.
@app.get("/students/{student_id}")
def get_student(id: int):FastAPI does not find a parameter named student_id, and treats id as a required Query Parameter. Every request to /students/7 will return a 422 ("loc": ["query", "id"], missing). The name in the curly brackets must be identical to the parameter’s name.
Mistake 2: a Path Parameter without a Type Hint.
def get_student(student_id):
for student in students:
if student["id"] == student_id:Without : int, the parameter arrives as the string "2", and 2 == "2" is False. The result: always 404, without any error. A silent Logical Error.
Mistake 3: the order of Routes with a fixed path and a path with a parameter.
@app.get("/students/{student_id}")
def get_student(student_id: int): ...
@app.get("/students/top")
def top_students(): ...GET /students/top → 422! FastAPI checks Routes in order, and /students/{student_id} “catches” top as the student_id, and fails to convert it to an int. Fixed paths before paths with parameters.
Mistake 4: return instead of raise for an HTTPException.
return HTTPException(status_code=404, detail="not found")Returns 200, with a strange object inside the JSON. An error is raised (raise), not returned.
Mistake 5: including id in the Body’s model. If StudentIn includes id: int, the Client must send an id, and can send an id that already exists. The server sets the identifier. The Body’s model contains only what the Client is supposed to send.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
Exercise 1: A Path Parameter with a Type Hint
Write an API with a list of students in memory (dictionaries with id, name, age, city), and a Route GET /students/{student_id} that returns the student, or 404 with a detail of "student not found".
- What will happen on the request
GET /students/abc?
Show solution
from fastapi import FastAPI, HTTPException
app = FastAPI(title="Students API")
students = [
{"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]
@app.get("/students/{student_id}")
def get_student(student_id: int):
for student in students:
if student["id"] == student_id:
return student
raise HTTPException(status_code=404, detail="student not found")GET /students/2→ 200 with Avi.GET /students/9→ 404{"detail": "student not found"}.GET /students/abc→ 422: the Type Hintstudent_id: intmakes FastAPI check and convert by itself. The function does not run at all.
Exercise 2: Query Parameters
Add a Route GET /students that receives an optional Query Parameter city (default None), and returns only the students from that city — or all of them if no city was sent.
Show solution
from fastapi import FastAPI
app = FastAPI(title="Students API")
students = [
{"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]
@app.get("/students")
def list_students(city: str = None):
if city is None:
return students
result = []
for student in students:
if student["city"] == city:
result.append(student)
return resultGET /students→ both students.GET /students?city=Haifa→ only Dana.
A function parameter that is not part of the path automatically becomes a Query Parameter. A default value makes it optional.
Exercise 3: POST with a Body
Add a Pydantic model named StudentIn (name, age, city) and a Route POST /students that returns 201, gives the student a new id and adds them to the list. An age below 16 → 400.
Show solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Students API")
MIN_AGE = 16
students = [
{"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
]
class StudentIn(BaseModel):
name: str
age: int
city: str
@app.post("/students", status_code=201)
def create_student(student: StudentIn):
if student.age < MIN_AGE:
raise HTTPException(status_code=400, detail="age must be at least 16")
new_student = {"id": len(students) + 1, "name": student.name,
"age": student.age, "city": student.city}
students.append(new_student)
return new_studentPOST /studentswith{"name": "Noa", "age": 22, "city": "Eilat"}→ 201 withid: 2.- Age 12 → 400. A body without
age→ 422 — Pydantic rejects a wrong structure before the function runs.
Exercise 4: 422 versus 400
- What is the difference between 422 and 400 in FastAPI? Who returns each one?
- For each request — which code will come back?
POST /students {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students {"name": "Noa", "city": "Eilat"}Show solution
- 422 — FastAPI and Pydantic return it automatically when the structure or the type is wrong. 400 — our code returns it when the value is valid in terms of type, but does not meet a business rule.
- The answers:
"age": "abc"→ 422 (the age is not a number)."age": 10→ 400 (a valid number, but below the minimum age — our rule).- Without
age→ 422 (a required field is missing).
Exercise 5: Statistics
Add a Route GET /students/stats that returns the number of students and the average age (rounded to one digit). Where should it be defined relative to GET /students/{student_id}, and why?
Show solution
from fastapi import FastAPI, HTTPException
app = FastAPI(title="Students API")
students = [
{"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]
@app.get("/students/stats")
def students_stats():
total_age = 0
for student in students:
total_age = total_age + student["age"]
return {"count": len(students), "average_age": round(total_age / len(students), 1)}
@app.get("/students/{student_id}")
def get_student(student_id: int):
for student in students:
if student["id"] == student_id:
return student
raise HTTPException(status_code=404, detail="student not found")GET /students/stats → {"count": 2, "average_age": 27.5}.
Define /students/stats before /students/{student_id}. FastAPI checks Routes in order — if {student_id} came first, stats would be treated as an id, and fail with 422 because it is not a number.
Summary
- Type Hints (
x: int) — FastAPI converts and checks according to them. Invalid input gets a 422 automatically, and the function does not run. - Path Parameter:
"/students/{student_id}"+student_id: int, with exactly the same name. - Query: every parameter that is not in the path.
x: int = 0is optional.x: str | None = Noneis optional without a default. Without=, required. - Body: a Class that inherits from
BaseModel(Pydantic). A parameter of this type = the JSON from the request, after validation. status_code=201for creating.raise HTTPException(404, ...)for not found, and 400 for a business rule.- Fixed paths before paths with a parameter. And data in RAM is deleted on restart, which is why you need a Database.