The idea
Requests carry user input in two main places:
- the query string (
/products?category=Coffee&max_price=100000): for GET requests like searching and filtering. The input is visible in the URL, so the result can be bookmarked and shared. - the request body (form fields): for POST requests that change something, like placing an order.
Flask exposes them as request.args and request.form. Both look like dictionaries, and both contain text typed by strangers, so this module is as much about validating input as reading it.
Query strings: request.args
Customers browsing the shop want to filter: only coffee, only things under Rp 100.000. Those choices travel in the URL after a ?, like /products?category=Coffee&max_price=100000. That part is the query string, and Flask gives it to you as request.args:
from flask import Flask, request
app = Flask(__name__)
PRODUCTS = [
{"name": "Arabica Gayo 250g", "category": "Coffee", "price": 95000},
{"name": "Toraja Kalosi 250g", "category": "Coffee", "price": 110000},
{"name": "Ceramic Mug", "category": "Kitchen", "price": 55000},
{"name": "Pour-over Kettle", "category": "Kitchen", "price": 320000},
]
@app.get("/products")
def products():
category = request.args.get("category")
max_price = request.args.get("max_price", type=int)
rows = PRODUCTS
if category:
rows = [p for p in rows if p["category"] == category]
if max_price is not None:
rows = [p for p in rows if p["price"] <= max_price]
return ", ".join(p["name"] for p in rows) or "No products"
client = app.test_client()
print(client.get("/products").get_data(as_text=True))
print(client.get("/products?category=Coffee").get_data(as_text=True))
print(client.get("/products?category=Kitchen&max_price=100000").get_data(as_text=True))
print(client.get("/products?max_price=cheap").get_data(as_text=True))
Arabica Gayo 250g, Toraja Kalosi 250g, Ceramic Mug, Pour-over Kettle
Arabica Gayo 250g, Toraja Kalosi 250g
Ceramic Mug
Arabica Gayo 250g, Toraja Kalosi 250g, Ceramic Mug, Pour-over Kettle
request.args.get("category")returns the value, orNoneif it's absent.type=intconverts the value; if conversion fails (max_price=cheap),getreturns the default (None) instead of crashing. Note that the last request silently ignored the bad value; sometimes you'd rather tell the user (below).- A key can appear several times (
?tag=gift&tag=new):request.args.getlist("tag")returns all of them.
The test client can build the query string for you: client.get("/products", query_string={"category": "Coffee"}).
Forms: request.form
Placing an order is different from filtering. It changes data, so it's sent with POST, and the values come from a form the customer filled in. Each field's name becomes the key your route reads:
<form method="post" action="/orders">
<input name="product">
<input name="quantity" type="number">
<button>Order</button>
</form>
from flask import Flask, request
app = Flask(__name__)
@app.post("/orders")
def create_order():
product = request.form.get("product", "").strip()
quantity = request.form.get("quantity", type=int)
return f"{quantity} × {product}"
r = app.test_client().post("/orders", data={"product": " Ceramic Mug ", "quantity": "2"})
print(r.get_data(as_text=True))
2 × Ceramic Mug
Every form value arrives as text, even from a type="number" input. Converting (type=int) and cleaning (.strip()) are your job.
Validate, then act
A robust handler collects problems first, then either reports them or acts. That's the same pattern you used for Streamlit forms and pandas validation:
from flask import Flask, request
app = Flask(__name__)
PRODUCTS = {"Ceramic Mug": 55000, "Pour-over Kettle": 320000}
def validate_order(form):
"""Return (cleaned_order, problems)."""
problems = []
product = form.get("product", "").strip()
quantity = form.get("quantity", type=int)
if product not in PRODUCTS:
problems.append("Choose a product from the list.")
if quantity is None or not 1 <= quantity <= 20:
problems.append("Quantity must be a whole number from 1 to 20.")
return {"product": product, "quantity": quantity}, problems
@app.post("/orders")
def create_order():
order, problems = validate_order(request.form)
if problems:
return {"errors": problems}, 400
total = PRODUCTS[order["product"]] * order["quantity"]
return {"order": order, "total": total}, 201
client = app.test_client()
print(client.post("/orders", data={"product": "Ceramic Mug", "quantity": "3"}).get_json())
r = client.post("/orders", data={"product": "Teapot", "quantity": "0"})
print(r.status_code, r.get_json())
{'order': {'product': 'Ceramic Mug', 'quantity': 3}, 'total': 165000}
400 {'errors': ['Choose a product from the list.', 'Quantity must be a whole number from 1 to 20.']}
validate_order is a plain function: you can test it without Flask, and reuse it in an API and a web form.
Never trust input. Convert it to the right type, check it against your rules, and report every problem at once with a 400. Only valid data reaches your business logic.
Redisplaying a form with errors
For HTML forms, the friendly response to bad input is the same form again, with messages and with the user's values kept:
from flask import Flask, request, render_template_string
app = Flask(__name__)
FORM = """
{% for e in errors %}<p class="error">{{ e }}</p>{% endfor %}
<form method="post">
<input name="email" value="{{ email }}">
<button>Subscribe</button>
</form>
"""
@app.route("/subscribe", methods=["GET", "POST"])
def subscribe():
email = request.form.get("email", "").strip()
errors = []
if request.method == "POST":
if "@" not in email:
errors.append("That doesn't look like an email address.")
else:
return f"Subscribed {email}"
return render_template_string(FORM, errors=errors, email=email), 400 if errors else 200
r = app.test_client().post("/subscribe", data={"email": "ayu.mail.id"})
print(r.status_code)
print(r.get_data(as_text=True))
400
<p class="error">That doesn't look like an email address.</p>
<form method="post">
<input name="email" value="ayu.mail.id">
<button>Subscribe</button>
</form>
Keeping the user's input (value="{{ email }}") means they fix one typo instead of retyping everything. And because templates escape values, even a malicious "email" can't inject HTML.
Why this matters for clean code
Input handling is where most bugs and security holes enter an application. Clean handlers have a clear shape: read → convert → validate → act, with validation in a plain function that returns problems. That keeps views short, makes the rules testable, and gives users helpful messages instead of 500 errors.
Recap
request.args(query string, GET) andrequest.form(body, POST) behave like dicts of strings..get(name, default, type=int)converts safely;.getlist(name)reads repeated keys.- Values are always text: strip and convert them.
- Validate in a plain function that returns all problems; respond 400 with them.
- For HTML forms, redisplay the form with messages and the user's values.