Skip to main content
FastAPI · lesson 3 of 19
FastAPI beginner Lesson 3 of 19

FastAPI 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

fastapi python query-parameters validation api

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, here int_parsing.
  • loc: where the bad value was, here the path parameter item_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

  1. Fix order.py so /users/me returns the logged-in user while /users/alice still 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"}
  1. Make a route that captures 2024/report.pdf from /files/2024/report.pdf, slashes included. Hint: Starlette has a :path converter.
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.

  1. /search?q=e matches every book. Require q to be at least 2 characters long, and find the error type FastAPI 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.

  1. Predict what /books?skip=-2 returns from query.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}}]}

Frequently Asked Questions

What is the difference between path and query parameters in FastAPI?
A path parameter is part of the URL path and is declared with braces in the route, as in @app.get('/items/{item_id}'). A query parameter comes after the ? in the URL, as in /books?limit=5, and is any function argument that isn't in the path. Path parameters are always required; query parameters are optional when you give them a default value.
Why does FastAPI return 422 for a path or query parameter?
422 Unprocessable Entity means the value couldn't be converted to the type you declared, or broke a constraint such as ge=1. FastAPI checks every parameter before calling your function, so the function never runs. The JSON body lists each problem with a type such as int_parsing, a loc such as ['query', 'limit'], a message, and the input that was rejected.
How do I make a query parameter optional in FastAPI?
Give the function argument a default value. limit: int = 10 makes limit optional with 10 as the fallback, and max_results: int | None = None makes it optional with no value at all. An argument without a default, such as q: str, is required, and leaving it out returns a 422 with type 'missing'.
How does FastAPI parse boolean query parameters?
Pydantic accepts true, 1, yes, on and their opposites false, 0, no, off, in any letter case. So ?upper=yes and ?upper=1 both give True. Anything else, such as ?upper=maybe, is rejected with a 422 of type bool_parsing rather than being treated as false.
Why does /users/me match my /users/{username} route in FastAPI?
FastAPI checks routes in the order you declare them and uses the first one that matches. If /users/{username} comes first, the path /users/me matches it with username set to 'me'. Declare fixed paths such as /users/me before parameterised ones.