The query string is the first gate into your API: ?page=abc, ?per_page=10000, or ?sort=sensitive_column can each make the API return a 500, send back too much data, or sort by a column you never intended. The cleanest fix is to validate right when you read the parameters, using request.args.get(type=...) with sensible defaults, a valid range, and a whitelist for the sort field — invalid input gets a 400 with a clear message. This pattern fits any list endpoint with pagination, search, or sorting.
The problem
Many APIs grab parameters directly and cast them with int(): when a client sends page=abc, int() raises ValueError and Flask returns a 500 instead of a 400. Worse, if you take sort and feed it straight into the sort expression, the client can ask to sort by an internal column or break the query. This bug is hard to spot because with correct parameters everything works fine — the API only “explodes” when a client sends something wrong.
@app.get("/products")def list_products_bad(): page = int(request.args["page"]) # KeyError nếu thiếu, ValueError nếu sai sort = request.args.get("sort", "name") items = sorted(PRODUCTS, key=lambda p: p[sort]) # KeyError nếu sort lạ return {"items": items[(page - 1) * 10:page * 10]}
The solution
request.args is a Werkzeug MultiDict, and its get() accepts a type argument for casting. But there is a trap: when the cast fails, it silently returns the default instead of reporting an error — page=abc quietly becomes page=1. So before reading the value, check first: if the parameter is present but casting it returns None, that is an invalid value, so return a 400 right away. Only then check the valid range and the whitelist for sort.
from flask import Flask, abort, requestapp = Flask(__name__)PRODUCTS = [ {"name": "Bàn phím", "price": 850000}, {"name": "Chuột", "price": 450000}, {"name": "Tai nghe", "price": 1200000},]ALLOWED_SORTS = {"name", "price"}def parse_list_params(): for name in ("page", "per_page"): if name in request.args and request.args.get(name, type=int) is None: abort(400, f"{name} phải là số nguyên") page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 10, type=int) sort = request.args.get("sort", "name") if page < 1 or not 1 <= per_page <= 100: abort(400, "page >= 1 và 1 <= per_page <= 100") if sort not in ALLOWED_SORTS: abort(400, f"sort chỉ nhận: {', '.join(sorted(ALLOWED_SORTS))}") return page, per_page, sort@app.get("/products")def list_products(): page, per_page, sort = parse_list_params() q = request.args.get("q", "").strip().lower() items = [p for p in PRODUCTS if q in p["name"].lower()] items = sorted(items, key=lambda p: p[sort]) start = (page - 1) * per_page return {"page": page, "per_page": per_page, "total": len(items), "items": items[start:start + per_page]}
Note two things: first, type=int only handles casting errors — range checks still have to be written by hand, since page=-5 casts fine but is meaningless. Second, sort must never go straight into a SQL query or an unchecked getattr; the whitelist is mandatory. Extracting parse_list_params() into its own function lets you reuse it for every list endpoint.
Applying it to your project
Review the list endpoints in your project and apply the same pattern: set sensible defaults for page and per_page, cap per_page so a single request cannot “suck up” the whole table, and whitelist every parameter that controls behavior, such as sort or order. When the API returns a clear 400 for bad parameters, the frontend and integrators can fix it themselves without you reading logs on their behalf.
Related posts
- Flask: Read and Validate the JSON Body the Right Way — No More HTML Error Pages from request.get_json() in 5 Minutes
- Flask: Return Custom Responses with make_response — Status, Headers, and Cookies in 5 Minutes
- Flask: Return datetime and Decimal in the Right JSON Format with a Custom JSON Provider in 5 Minutes
FAQ
How is request.args.get(type=int) different from int(request.args[“page”])?
request.args["page"] raises KeyError when the parameter is missing, and int() raises ValueError for a bad format. get("page", 1, type=int) returns the default 1 in both cases — convenient, but it hides the error, which is why you need to check the raw string as shown in this post.
Why does page=-5 still need a manual check even with type=int?
type=int only guarantees the value is an integer, not that it makes business sense. -5 is an integer but not a valid page number, so you still need page >= 1 and a cap on per_page.
Should I use abort(400) or return jsonify with a 400 status myself?
Both work, but abort(400) is shorter and consistent with a centralized error handler if you already have one. If you need a custom JSON error format, register an error handler for 400.
Is a sort whitelist necessary when I only sort an in-memory Python list?
Yes. Even without touching SQL, a strange sort key still causes a KeyError and a 500. A whitelist turns that into a 400, and it also stops clients from probing internal field names.
💬 Have you tried this in your own project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will fold good ideas into the post.
Sources
- Flask — Accessing Request Data: https://flask.palletsprojects.com/en/stable/quickstart/#accessing-request-data
- Werkzeug — MultiDict.get: https://werkzeug.palletsprojects.com/en/stable/datastructures/#werkzeug.datastructures.MultiDict.get
