CodingNic

REST APIs with FastAPI

Path and query parameters

REST APIs with FastAPI 25 min read

Path and query parameters

Path and query parameters

By the end of Lesson 3 your API could list all tasks, count them, create one, and clear them. But every endpoint was static. There was no way to ask for task 42 specifically, or only the completed tasks, or the first 10 results. Every GET /tasks returned the entire list.

Real APIs need both. Sometimes the client wants a specific resource (give me task 42). Sometimes the client wants to filter, search, or paginate (give me the first 10 completed tasks containing the word “Alice”). FastAPI handles both with the same elegant trick — function parameters with type hints.

This lesson introduces:

  • Path parameters — pieces of the URL itself, like the 42 in /tasks/42.
  • Query parameters — the bits after the ?, like ?status=done&limit=10.

Both arrive in your view function as ordinary Python arguments. FastAPI does the parsing, type conversion, and validation.


When to use which

Both kinds of parameter give the client a way to send input without a request body. The difference is intent:

  • Path parameters identify a specific resource. /tasks/42 clearly points at one thing: the task with ID 42. The ID is part of the resource’s address.
  • Query parameters modify a request. ?status=done&limit=10 doesn’t change which endpoint is being called (it’s still “list tasks”) — it changes how the list is returned (filtered, paginated, sorted).

A useful rule of thumb: if removing the parameter would change which resource is being referenced, it’s a path parameter. If removing it would just change which subset comes back, it’s a query parameter.

You’ll often use both at once. GET /users/42/orders?status=shipped&limit=20 has a path parameter (42 — which user) and query parameters (status, limit — how to filter that user’s orders).


Path parameters

The syntax is small. You declare a dynamic segment in the URL with {name}, then accept a parameter with the same name in the function:

python
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    ...

When a request comes in for /tasks/42, FastAPI pulls 42 out of the URL, converts it to an int (because of the int type hint), and passes it to your function as task_id=42.

Three things make this little snippet powerful:

  1. The name in {...} must match the parameter name in the function. {task_id} in the URL → task_id in the signature.
  2. The type hint is enforced. Hint int, and FastAPI rejects /tasks/abc with a 422 error automatically — your function never runs.
  3. Anything Python can parse can be a path parameter type. int, float, str, bool, UUID. Stick to the simple ones until you need more.

Build it

Create a new folder for this lesson’s demo:

text
fastapi-params/
└── main.py

Start with the path-parameter half of the file:

python
# main.py
from fastapi import FastAPI, HTTPException

app = FastAPI(title="Params demo")

tasks = [
    {"id": 1, "title": "Buy groceries",  "status": "todo",        "priority": 2},
    {"id": 2, "title": "Reply to Alice", "status": "done",        "priority": 1},
    {"id": 3, "title": "Book dentist",   "status": "todo",        "priority": 3},
    {"id": 4, "title": "Polish demo",    "status": "in_progress", "priority": 2},
    {"id": 5, "title": "Pay rent",       "status": "done",        "priority": 1},
]


@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for t in tasks:
        if t["id"] == task_id:
            return t
    raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

A few things to point out:

  • from fastapi import HTTPException — the standard way to return an error response. Raising it short-circuits the function and produces a proper JSON error with the status code you specify. We’re using 404 here for “task doesn’t exist.”
  • The detail message is helpful but doesn’t leak internals. “Task 99 not found” is enough; we’re not exposing database IDs or schemas.

Run it:

bash
uvicorn main:app --reload

Then try:

bash
curl http://127.0.0.1:8000/tasks/1
json
{"id": 1, "title": "Buy groceries", "status": "todo", "priority": 2}

Try an ID that doesn’t exist:

bash
curl -i http://127.0.0.1:8000/tasks/99

You get back 404 Not Found with body {"detail":"Task 99 not found"}.

Now try something that isn’t even a number:

bash
curl -i http://127.0.0.1:8000/tasks/abc

You get 422 Unprocessable Entity — FastAPI rejected the request before your function ran. The response body even tells the client exactly what went wrong:

json
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "task_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

You wrote zero validation code. The int hint did all of that.


Query parameters

Query parameters are the parts of a URL after the ?. They look like ?key=value&another=10, and they’re how clients tell your API “filter by this”, “show 20 results”, “start from page 3”.

FastAPI’s trick: any function parameter that isn’t in the URL path is automatically a query parameter.

A first cut:

python
@app.get("/tasks")
def list_tasks(status: str | None = None):
    if status is not None:
        return [t for t in tasks if t["status"] == status]
    return tasks

That’s a complete query-parameter handler. Notice:

  • status: str | None = None — the type hint says “string or None”, the default = None makes it optional. Without a default, FastAPI would treat it as required and reject any GET /tasks request that didn’t include ?status=....
  • The parameter name in the function matches the query key in the URL. ?status=done → status="done" in the function.

Calls:

bash
curl http://127.0.0.1:8000/tasks?status=done

Returns just the two completed tasks. Omit the parameter and you get everything:

bash
curl http://127.0.0.1:8000/tasks

That’s the basic version. Let’s make it useful.

Full version: filter, search, paginate

Replace the stub with a more complete handler:

python
@app.get("/tasks")
def list_tasks(
    status: str | None = None,
    search: str | None = None,
    limit: int = Query(default=10, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
):
    results = tasks

    if status is not None:
        results = [t for t in results if t["status"] == status]

    if search is not None:
        s = search.lower()
        results = [t for t in results if s in t["title"].lower()]

    return {
        "total": len(results),
        "limit": limit,
        "offset": offset,
        "items": results[offset:offset + limit],
    }

Don’t forget to import Query:

python
from fastapi import FastAPI, HTTPException, Query

Four parameters, all query-string, each doing one job:

Parameter Default Purpose
status None Filter to tasks with this status
search None Case-insensitive title substring
limit 10 (1–100) How many items to return
offset 0 (≥0) How many to skip (for pagination)

What Query(...) adds

You can use Query(...) to attach metadata and constraints to a query parameter. The most useful bits:

  • default=10 — the value used when the client doesn’t send the parameter.
  • ge=1 — “greater than or equal to 1”. Reject ?limit=0 or ?limit=-5.
  • le=100 — “less than or equal to 100”. Reject ?limit=10000.

If a client violates these constraints, FastAPI returns a 422 with a clear error message. The handler never runs.

This pattern matters because it catches the most common mistake of pagination: a client requests ?limit=1000000, your server tries to return a million records, your database falls over. With le=100 in place, that request is rejected before it even reaches your code.

The same Query(...) helper supports other constraints: min_length, max_length, pattern (regex), and many more. We’ll see similar machinery for request-body validation in Lesson 6.

The response shape

Notice we’re not returning the bare list any more. The response now looks like this:

json
{
  "total": 5,
  "limit": 10,
  "offset": 0,
  "items": [ ... ]
}

When you paginate, returning just the items is rarely enough. The client needs to know:

  • total — how many items match the filter overall (without pagination). This lets the UI show “showing 1–10 of 47” or render a page-count.
  • limit and offset — what the server actually applied. Useful for the client to confirm and for building “next page” / “previous page” links.
  • items — the actual records for this page.

This is a common pagination response shape. You’ll see slight variations in real APIs (pageInfo, meta, data, etc.) but the principle is the same: include enough context that the client doesn’t have to guess.


Try it out

Run the server and walk through these. Each one teaches a different aspect.

Default — no params:

bash
curl http://127.0.0.1:8000/tasks

Returns all 5 tasks with limit: 10, offset: 0.

Filter:

bash
curl 'http://127.0.0.1:8000/tasks?status=done'

Returns only the two done tasks. total: 2.

Search:

bash
curl 'http://127.0.0.1:8000/tasks?search=ALICE'

Returns one task — “Reply to Alice”. Notice the search is case-insensitive (ALICE matched Alice); that’s because the handler lower-cases both sides.

Combine:

bash
curl 'http://127.0.0.1:8000/tasks?status=done&search=pay'

Status done AND title contains pay → one match: “Pay rent”. Filters compose, in order.

Paginate:

bash
curl 'http://127.0.0.1:8000/tasks?limit=2&offset=0'

First two tasks. Then:

bash
curl 'http://127.0.0.1:8000/tasks?limit=2&offset=2'

Next two. Then offset=4 for the last one.

Bad input:

bash
curl -i 'http://127.0.0.1:8000/tasks?limit=0'

422 — limit must be >= 1.

bash
curl -i 'http://127.0.0.1:8000/tasks?limit=200'

422 — limit must be <= 100.

bash
curl -i 'http://127.0.0.1:8000/tasks?limit=abc'

422 — can’t parse abc as an integer.

In every error case, the client gets a clear JSON description of what went wrong and your handler doesn’t run. You wrote one type hint and three Query(...) constraints; FastAPI did the rest.


Route order, revisited

Remember the warning from Lesson 3? Now it matters.

In our demo, the routes are:

python
@app.get("/tasks/{task_id}")   # path parameter — matches /tasks/anything
def get_task(task_id: int): ...

@app.get("/tasks")              # static — matches only /tasks exactly
def list_tasks(...): ...

The order they appear in your code is the order FastAPI checks them. In this case it doesn’t matter — /tasks/{task_id} requires something after /tasks/, so /tasks alone won’t match it.

But imagine if we’d written:

python
@app.get("/tasks/{task_id}")
def get_task(task_id: int): ...

@app.get("/tasks/count")   # ← danger
def count_tasks(): ...

A request for /tasks/count would try to match /tasks/{task_id} first. FastAPI would attempt to convert "count" to an int, fail, and return a 422 error — even though there’s a perfectly good /tasks/count handler sitting right below it.

The fix is to declare specific routes before generic ones:

python
@app.get("/tasks/count")        # specific first
def count_tasks(): ...

@app.get("/tasks/{task_id}")    # generic after
def get_task(task_id: int): ...

Now /tasks/count matches the literal route, and /tasks/42 falls through to the path-parameter route. Order matters when routes overlap.


Common pitfalls

  • Forgetting the type hint on a path parameter. Without task_id: int, FastAPI passes it as a string. t["id"] == task_id will silently never match because 1 != "1".
  • Making a query parameter required when it should be optional. Omit the default value and the parameter is required. status: str | None = None is optional; status: str is required and will 422 on any request that doesn’t include it.
  • Using ? inside a parameter value. The ? separates the URL from the query string. To include literal special characters in a value, URL-encode them (%3F for ?, %20 for space). Tools like curl and Postman handle most encoding for you.
  • Boolean query parameters and the truthiness trap. FastAPI parses ?completed=true and ?completed=false correctly, but any other string is an error (422), not “falsy”. Don’t try ?completed=yes or ?completed=1 and expect it to work.
  • Confusing path params and query params. Path params are part of the URL (“which thing”); query params come after the ? (“how to filter”). Don’t put ?id=42 in the URL when you mean /42.

What this gives you

You now have an API that handles flexible client input without ever needing a request body. Specifically:

  • Lookups by ID, with proper 404 handling.
  • Filtering by exact match.
  • Searching with case-insensitive substring matching.
  • Pagination with sensible defaults and protective constraints.

All of that came from function arguments, type hints, and the Query(...) helper. No manual request.args.get(...) (which Flask makes you do). No manual type coercion. No manual validation.

The OpenAPI docs reflect every parameter automatically. Open http://127.0.0.1:8000/docs and click on GET /tasks — you’ll see every query parameter, its type, its default, its constraints, and a form to try every combination right there in the browser.


Summary

  • Path parameters identify a specific resource. Declared with {name} in the URL and a matching argument in the function. Type hints enforce conversion.
  • Query parameters modify how a request is handled. Declared as ordinary function arguments with defaults.
  • An argument with a default is optional; without a default it’s required.
  • Query(default=..., ge=..., le=...) attaches metadata and constraints; FastAPI returns 422 if violated.
  • A typical paginated response includes total, limit, offset, and items.
  • Declare specific routes before generic ones to avoid wildcard collisions.

Outcome

Your API now handles dynamic URLs and flexible query strings. Clients can look up specific tasks, filter and search, and paginate through long lists — and they get clear validation errors when they send something wrong. Next lesson, we tackle the other direction: how does a client send data to your API? That’s where request bodies and Pydantic models come in.