Routes, Parameters and Responses

Path parameters against query parameters, matching the method, and returning 404 instead of crashing on a missing id.

Syntax@app.get("/orders/{order_id}") # path parameter; ?limit=50 is a query parameter

A router is a lookup from (method, path) to a function. Two kinds of input arrive with the request, and conflating them is the usual source of confusing endpoints.

Path against query

A path parameter identifies the resource and is mandatory: /orders/1041 cannot omit the id and still mean anything. A query parameter modifies a request that is already valid without it — filtering, sorting, paging, field selection — so it is optional and should have a default. If a parameter has a sensible default, it belongs in the query string.

Both arrive as strings. Frameworks convert for you if you tell them the type: FastAPI uses the annotation on the function parameter, and in Flask the converter goes in the rule, /orders/<int:order_id>, with string, int, float, path and uuid available. The useful side effect is that /orders/abc never reaches your function — the route simply does not match, and you get a 404 instead of a ValueError turning into a 500.

Method matching

If the path matches and the method does not, the correct answer is 405 Method Not Allowed, not 404; frameworks do this for you once a route declares its methods. A path that matches nothing is 404.

Not found is a response, not an exception

A row that is absent is a normal outcome. Return it deliberately: raise HTTPException(status_code=404, detail="order not found") in FastAPI, abort(404) in Flask. The failure mode to avoid is ORDERS[order_id] raising KeyError, becoming a 500, and paging someone at night over a request that was merely for an id that does not exist.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Designing a read endpoint and deciding what goes in the path and what in the query string.
  • Fixing an endpoint that 500s on a malformed id.
  • Returning 405 so a client learns it used the wrong verb.
  • Adding optional filters to an existing route without breaking old callers.

More examples

FastAPI: the annotation does the conversion

The idiomatic version. Worth knowing that FastAPI answers 422 rather than 404 for /orders/abc, because to it a non-integer id is a validation failure on a parameter it was asked to coerce.

Example · python
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query

app = FastAPI()
ORDERS = {1041: {"id": 1041, "status": "open"}}

# order_id matches the {order_id} in the path, so it is a path parameter.
# limit does not appear in the path, so it is a query parameter with a default.
@app.get("/orders/{order_id}")
async def get_order(
    order_id: int,                                    # /orders/abc -> 422, never your code
    fields: Annotated[str | None, Query()] = None,    # ?fields=id,status
):
    order = ORDERS.get(order_id)
    if order is None:
        # A deliberate 404 with a body, not a KeyError turning into a 500.
        raise HTTPException(status_code=404, detail="order not found")
    if fields:
        return {k: v for k, v in order.items() if k in fields.split(",")}
    return order

Flask: converters in the rule

Reach for request.args.getlist when a parameter can repeat — ?tag=a&tag=b — because .get silently returns only the first value, which is a bug that survives code review easily.

Example · python
from flask import Flask, abort, jsonify, request

app = Flask(__name__)
ORDERS = {1041: {"id": 1041, "status": "open"}}

# <int:order_id> both converts and filters: a non-numeric id does not match
# this rule at all, so Flask answers 404 without entering the function.
@app.route("/orders/<int:order_id>", methods=["GET"])
def get_order(order_id):
    order = ORDERS.get(order_id)
    if order is None:
        abort(404)                       # raises NotFound; Flask renders the 404
    # Query parameters are a MultiDict: .get() for one, .getlist() for repeats.
    if fields := request.args.get("fields"):
        order = {k: v for k, v in order.items() if k in fields.split(",")}
    return jsonify(order)

# Declaring methods=["GET"] is what makes DELETE /orders/1041 a 405 and not a 404.

Why a missing default is a 500 waiting to happen

Keep this contrast in mind when reviewing handlers. A 500 means you have a bug to fix; a 404 means the client asked for something that is not there. Letting the second masquerade as the first destroys the usefulness of your error rate as a signal.

Example · python
# The version that pages someone at 3am.
def get_order_bad(order_id):
    return ORDERS[order_id]            # KeyError -> 500 -> alert -> there was no bug

# The version that answers the question that was asked.
def get_order_good(order_id):
    order = ORDERS.get(order_id)
    if order is None:
        return 404, {"error": "order_not_found", "id": order_id}
    return 200, order

# The same applies to query parameters: int(request.args["limit"]) raises
# KeyError when it is absent and ValueError when it is "abc". Both are 400-class
# problems, so parse them where you can answer with a status.

Discussion

  • Be the first to comment on this lesson.