FastAPI: Path Params, Query, Body and HTTPException

Lesson goals
  • 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:

InputHow FastAPI knows
Path ParameterA name in curly brackets in the path: "/students/{student_id}"
Query ParameterA function parameter that does not appear in the path
BodyA 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:

  1. Conversion — the string "7" arrives from the URL, and FastAPI converts it to 7, because we asked for int.
  2. Validation — if "abc" arrived, FastAPI does not call the function, but returns a 422 with an explanation.
  3. Documentation — /docs shows 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):
DefinitionMeaning
min_age: int = 0Optional, with a default of 0.
limit: int (without =)Required. Without it → 422.
city: str | None = NoneOptional, 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__! BaseModel builds 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.
Analogy: a form with required fields

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 regular raise (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 fields

First 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_student

Running: uvicorn students_memory:app --reload (notice: the file’s name changed!).

Breaking down the code line by line

CodeExplanation
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 ...: continueFiltering 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: intA 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.ageAccessing the fields of an object (the OOP topic).
max(s["id"] for s in students) + 1A 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.)

RequestCodeResponse
GET /students200All three
GET /students?city=Haifa200Dana and Noa
GET /students?min_age=23&limit=1200[{"id": 1, "name": "Dana", ...}]
GET /students/2200{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"}
GET /students/42404{"detail": "student 42 not found"}
GET /students/abc422See below
GET /students?min_age=old422See below
POST /students + {"name": "Tom", "age": 27}201{"id": 4, "name": "Tom", "age": 27, "city": "Unknown"}
POST /students + {"name": "Rina", "age": "twenty"}422See 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/except around int().

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".

  1. 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 Hint student_id: int makes 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 result
  • GET /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_student
  • POST /students with {"name": "Noa", "age": 22, "city": "Eilat"} → 201 with id: 2.
  • Age 12 → 400. A body without age → 422 — Pydantic rejects a wrong structure before the function runs.

Exercise 4: 422 versus 400

  1. What is the difference between 422 and 400 in FastAPI? Who returns each one?
  2. 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
  1. 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.
  2. 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 = 0 is optional. x: str | None = None is 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=201 for 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.

Official documentation