Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Path Operations & Parameters

Phase 1 got an app running: write a typed function, get a validated, documented endpoint. This phase zooms in on the routes themselves - how you say "this function handles GET /books," how you grab the 42 out of /books/42, how you read ?limit=10 off the URL, and how a single int annotation turns into a validation rule that rejects garbage before your code ever runs.

The mental model: a route in FastAPI is just a normal Python function, and its signature is the spec. The decorator says which requests reach the function. The parameters - and their type hints - say what those requests must look like. You don't write parsing or validation; you describe the shape, and FastAPI enforces it.

Path operations - a route is a decorated function

📝 Path operation - FastAPI's name for a route. "Path" is the URL path (/books); "operation" is the HTTP method (GET, POST, ...). Together they're "the function that runs for GET /books." You declare one by decorating a function: the decorator is the method-plus-path.

from fastapi import FastAPI

app = FastAPI()

@app.get("/books")
def list_books():
    return [{"id": 1, "title": "Dune"}]

@app.post("/books")
def create_book():
    return {"message": "a book would be created here"}

What just happened: @app.get("/books") registers list_books as the handler for GET /books. @app.post("/books") registers a different function for POST /books - same path, different method, so it's a separate path operation. The decorator name (get, post, put, delete, patch) maps one-to-one onto the HTTP verb, matching the REST model from REST APIs Explained: the URL names the resource, the method is the verb you apply to it. Whatever you return becomes the JSON response body.

💡 You'll almost always use get (read), post (create), put/patch (update), and delete (remove). There's a decorator per method; pick the one that matches what the request does to the resource.

Path parameters - pulling values out of the URL

A GET /books lists every book. But GET /books/42 should return one book - the one with id 42. That 42 changes per request, so you can't hard-code it into the route string. Mark it as a path parameter with curly braces, then receive it as a function argument.

@app.get("/books/{book_id}")
def get_book(book_id: int):
    return {"id": book_id, "title": "Dune"}

What just happened: {book_id} in the path is a placeholder. FastAPI matches /books/42, pulls out "42", and passes it to your function as the book_id argument. The name in the braces must match the parameter name exactly - that's how FastAPI wires them together.

Look at the annotation: book_id: int. The value in a URL is always text - "42" is a string. That int hint tells FastAPI to convert it to a real integer before handing it over, so inside your function book_id is the number 42, not the string "42". If the conversion fails - say someone requests /books/banana - FastAPI doesn't run your function at all. It returns a 422 Unprocessable Entity with a precise error:

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

What just happened: banana can't be an int, so FastAPI rejected the request before your code saw it. The error names exactly where it went wrong (["path", "book_id"]), what was expected, and what it got - zero validation code written.

💡 This is the Phase 1 idea made concrete: the type hint is doing validation, not just documentation. One word - int - bought you parsing, a guaranteed-correct type inside your function, and an automatic, descriptive 422 for bad input. Change it to str and /books/banana would sail right through. The hint is the rule.

Query parameters - everything after the ?

Path parameters identify which resource. Query parameters tune how you want it - filtering, paging, sorting. They're the ?skip=0&limit=10 part of a URL. The rule for declaring them: any function parameter that isn't in the path becomes a query parameter.

@app.get("/books")
def list_books(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit, "books": ["..."]}

What just happened: skip and limit don't appear in the path string "/books", so FastAPI treats them as query parameters read from the URL's query string. Because they have default values (= 0, = 10), they're optional - a plain GET /books uses the defaults. They're typed int, so they get the same parse-and-validate treatment as path parameters: ?limit=abc earns a 422.

You'd call it like this:

GET /books?skip=20&limit=5 HTTP/1.1
Host: localhost:8000

Or from the terminal:

curl "http://localhost:8000/books?skip=20&limit=5"
{"skip":20,"limit":5,"books":["..."]}

What just happened: FastAPI matched ?skip=20&limit=5 to your two parameters by name, converted both to int, and passed them in. Leave them off (curl http://localhost:8000/books) and you'd get the defaults, skip=0 and limit=10. Default present means optional; default absent means required (and a missing required query param is - you guessed it - a 422).

Optional & constrained parameters - richer rules than "is it an int"

Sometimes a query parameter is genuinely optional with no meaningful default - "filter by author, but only if the caller asked." That's an Optional (or | None) annotation with a default of None:

@app.get("/books")
def list_books(author: str | None = None):
    if author is None:
        return {"books": "all books"}
    return {"books": f"books by {author}"}

What just happened: author: str | None = None says "a string, or nothing." With no ?author=... in the URL, author is None and you return everything; with ?author=Herbert, it's "Herbert". The = None default is what makes it optional - without a default, FastAPI would require it. (str | None is the modern syntax; older code writes Optional[str] from typing - same thing.)

But "it's a string" is often too loose. You might want "no longer than 50 characters," or "an id that must be at least 1." For that, FastAPI gives you Query() and Path() - helpers that attach extra validation rules to a parameter:

from fastapi import FastAPI, Query, Path

app = FastAPI()

@app.get("/books/{book_id}")
def get_book(
    book_id: int = Path(ge=1),
    q: str | None = Query(default=None, max_length=50),
):
    return {"book_id": book_id, "q": q}

What just happened: Path(ge=1) adds the rule "greater than or equal to 1" to the path parameter - so /books/0 is now invalid. Query(default=None, max_length=50) keeps q optional but caps its length at 50 characters. These constraints join the same validation pass: violate one and you get a 422 describing it, exactly like a type mismatch. Now request /books/0:

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["path", "book_id"],
      "msg": "Input should be greater than or equal to 1",
      "input": "0"
    }
  ]
}

What just happened: 0 parsed fine as an int but failed the ge=1 rule, so FastAPI rejected it with a 422 that names the broken constraint. ⚠️ Don't confuse the helper's argument with its job: Query(default=None, ...) sets the default, while max_length/ge/le/min_length/pattern set the rules. The default decides "required or optional"; the rules decide "what counts as valid."

How it works + the ordering gotcha

Notice what you never did: parse a string, write an if not isinstance(...), hand-build an error response. You described each parameter with a type and maybe a constraint, and FastAPI did the rest.

💡 Here's the machinery. At startup, FastAPI inspects every path-operation function's signature - reading the parameter names, their type hints, and any Query/Path rules - and builds a schema for the route. That one schema powers three things at once: parsing the incoming request, validating it (generating the 422 when it fails), and documenting it in the auto-generated interactive docs from Phase 1. The signature is the single source of truth, so the docs are always in sync with the actual behavior - built from the same description.

⚠️ The ordering gotcha. FastAPI matches routes in the order you declare them, top to bottom, and stops at the first match. So a fixed path that looks like it could be a path parameter must come first. Imagine you want GET /books/featured to return a curated list:

@app.get("/books/{book_id}")
def get_book(book_id: int):
    return {"id": book_id}

@app.get("/books/featured")          # unreachable!
def featured_books():
    return {"books": "the good ones"}

What just happened: This is broken. A request for /books/featured hits @app.get("/books/{book_id}") first, because it's declared first and /books/featured matches the {book_id} pattern. FastAPI then tries to parse "featured" as an int, fails, and returns a 422 - featured_books never runs. Fix it by declaring the literal route before the dynamic one:

@app.get("/books/featured")          # specific path first
def featured_books():
    return {"books": "the good ones"}

@app.get("/books/{book_id}")         # dynamic catch-all second
def get_book(book_id: int):
    return {"id": book_id}

What just happened: Now /books/featured matches the literal route and stops there, while /books/42 falls through to the parameterized one. Rule of thumb: specific before general.

So far every endpoint reads data from the URL. But creating a book needs a whole payload - title, author, year, price - which doesn't belong in the path or query string. That's the request body, where Pydantic models take over. Next phase.

Recap

  1. A path operation is a route: the decorator (@app.get, @app.post, ...) sets the HTTP method and the path, and the decorated function handles those requests.
  2. Path parameters (/books/{book_id} + book_id: int) pull a value out of the URL; the type hint parses it and validates it, returning an automatic 422 for bad input.
  3. Query parameters are any function parameter not in the path. A default value makes them optional (limit: int = 10); no default makes them required.
  4. For optional-with-no-default use str | None = None; for richer rules use Query(...) / Path(...) with constraints like max_length and ge, which produce constraint-specific 422s.
  5. 💡 FastAPI reads each function's signature at startup and builds one schema that drives parsing, validation, and the auto docs - so the docs can never drift from the behavior.
  6. ⚠️ Routes match in declaration order: put literal paths (/books/featured) before dynamic ones (/books/{book_id}), or the dynamic route swallows them.

You can now route requests and validate everything that arrives in the URL. Next: data that arrives in the request body - with Pydantic models doing the same type-driven validation, for whole objects.

Quick check

Make sure the core idea stuck - that the signature drives everything:

[
  {
    "q": "In `@app.get(\"/books/{book_id}\")` with `def get_book(book_id: int):`, what happens when a client requests `/books/banana`?",
    "choices": [
      "FastAPI returns a 422 error and never runs get_book, because \"banana\" can't be parsed as an int",
      "get_book runs with book_id set to the string \"banana\"",
      "FastAPI silently converts it to 0 and runs the function",
      "The server crashes with an unhandled exception"
    ],
    "answer": 0,
    "explain": "The `int` hint is a validation rule. \"banana\" can't become an int, so FastAPI rejects the request with a descriptive 422 before your function is ever called."
  },
  {
    "q": "How do you make a function parameter a *query* parameter rather than a path parameter?",
    "choices": [
      "Include it as a function argument but NOT in the path string - anything not in the path becomes a query parameter",
      "Wrap it in curly braces in the path string",
      "Decorate it with @query above the function",
      "Give it the type hint `Query` instead of `int`"
    ],
    "answer": 0,
    "explain": "Path parameters appear in the path with `{braces}`. Any other function parameter is read from the query string. A default value (like `limit: int = 10`) makes it optional."
  },
  {
    "q": "You declare `@app.get(\"/books/{book_id}\")` first and `@app.get(\"/books/featured\")` second. What goes wrong?",
    "choices": [
      "`/books/featured` matches the {book_id} route first, FastAPI tries to parse \"featured\" as an int, and returns a 422 - the featured route never runs",
      "Both routes work fine; FastAPI picks the more specific one automatically",
      "FastAPI refuses to start because of the conflict",
      "`/books/featured` returns featured books, but `/books/42` breaks"
    ],
    "answer": 0,
    "explain": "Routes match in declaration order. The dynamic `/books/{book_id}` is declared first, so it captures `/books/featured` before the literal route is ever considered. Declare literal paths before dynamic ones."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. In `@app.get("/books/{book_id}")` with `def get_book(book_id: int):`, what happens when a client requests `/books/banana`?

2. How do you make a function parameter a *query* parameter rather than a path parameter?

3. You declare `@app.get("/books/{book_id}")` first and `@app.get("/books/featured")` second. What goes wrong?