Routes, Parameters and Responses
Path parameters against query parameters, matching the method, and returning 404 instead of crashing on a missing id.
@app.get("/orders/{order_id}") # path parameter; ?limit=50 is a query parameterA 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
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.
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 orderFlask: 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.
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.
# 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