Forms & Request Data
A website becomes useful when visitors can send data back: an order, a feedback message, a payment screenshot. In Flask all of that arrives on the request object, but where it arrives depends on how it was sent. Form fields, query strings, JSON and files each have their own place.
In this lesson you will build an order form for Pune Tiffin, read and validate its fields, show helpful error messages, redirect after a successful submit, accept JSON and file uploads safely, and see how the Flask-WTF extension adds validation classes and CSRF protection.
Where request data lives
| Data sent as | Read it with | Example |
|---|---|---|
Query string ?day=monday | request.args | Search, filters, page number |
HTML form with method="post" | request.form | Order form, login form |
| JSON body | request.get_json() | Mobile apps, JavaScript fetch() |
| Uploaded files | request.files | Photos, PDFs |
| Headers and cookies | request.headers, request.cookies | API keys, preferences |
request.args and request.form behave like dictionaries. Every value is a string, even if the form field had type="number", so you always convert and check it yourself.
An order form with validation
The template shows the form, the old values (so the user does not retype everything after a mistake) and any error messages:
<!-- templates/order.html -->
<form method="post" action="{{ url_for('order') }}">
<label>Name <input name="name" value="{{ form.get('name', '') }}"></label>
{% if errors.name %}<p class="error">{{ errors.name }}</p>{% endif %}
<label>Phone <input name="phone" value="{{ form.get('phone', '') }}"></label>
{% if errors.phone %}<p class="error">{{ errors.phone }}</p>{% endif %}
<label>Plates <input name="plates" type="number" min="1" max="20" value="{{ form.get('plates', 1) }}"></label>
{% if errors.plates %}<p class="error">{{ errors.plates }}</p>{% endif %}
<label><input type="checkbox" name="veg_only"> Veg only</label>
<button>Place order</button>
</form>The view handles both the empty form (GET) and the submitted form (POST) in one function:
# app.py
from flask import Flask, redirect, render_template, request, url_for
from markupsafe import escape
app = Flask(__name__)
ORDERS = []
def validate_order(form):
errors = {}
name = form.get("name", "").strip()
phone = form.get("phone", "").strip()
plates = form.get("plates", "")
if len(name) < 2:
errors["name"] = "Please enter your name."
if not (phone.isdigit() and len(phone) == 10):
errors["phone"] = "Phone must be 10 digits."
if not plates.isdigit() or not 1 <= int(plates) <= 20:
errors["plates"] = "Order between 1 and 20 plates."
return errors
@app.route("/order", methods=["GET", "POST"])
def order():
errors = {}
if request.method == "POST":
errors = validate_order(request.form)
if not errors:
ORDERS.append({
"name": request.form["name"].strip(),
"phone": request.form["phone"].strip(),
"plates": int(request.form["plates"]),
"veg_only": "veg_only" in request.form,
})
return redirect(url_for("thanks", name=request.form["name"].strip()))
return render_template("order.html", errors=errors, form=request.form)
@app.get("/thanks")
def thanks():
name = request.args.get("name", "friend")
return f"Thank you, {escape(name)}! Your tiffin is on its way."Two details are worth noticing. A checkbox sends its value only when it is ticked, so "veg_only" in request.form gives True or False. And the thanks view escapes the name from the URL, because a returned string is treated as HTML.
Let us send one bad and one good submission with the test client:
from app import app, ORDERS
client = app.test_client()
bad = client.post("/order", data={"name": "R", "phone": "98765", "plates": "0"})
print(bad.status)
for line in bad.get_data(as_text=True).splitlines():
if "error" in line:
print(line.strip())
good = client.post("/order", data={"name": "Rohan", "phone": "9876543210",
"plates": "2", "veg_only": "on"})
print(good.status, good.headers["Location"])
print(ORDERS)200 OK
<p class="error">Please enter your name.</p>
<p class="error">Phone must be 10 digits.</p>
<p class="error">Order between 1 and 20 plates.</p>
302 FOUND /thanks?name=Rohan
[{'name': 'Rohan', 'phone': '9876543210', 'plates': 2, 'veg_only': True}]The bad form is shown again with all three messages. The good form saves the order and redirects.
Post/Redirect/Get
Why redirect instead of returning "Thank you" directly from the POST? If the response to a POST is a page and the user presses Refresh, the browser offers to send the form again, and Rohan gets two tiffins. Redirecting after a successful POST makes the browser load a fresh GET page, so Refresh is harmless. This pattern is called Post/Redirect/Get (PRG), and both Flask and Django apps use it everywhere.
JSON bodies and file uploads
JavaScript and mobile apps usually send JSON. request.get_json() parses it; with silent=True it returns None instead of raising an error when the body is not valid JSON. For uploads, the form needs enctype="multipart/form-data", and files arrive in request.files.
from pathlib import Path
from flask import Flask, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 # 2 MB limit
UPLOAD_DIR = Path(app.root_path) / "uploads"
ALLOWED = {".jpg", ".jpeg", ".png", ".pdf"}
@app.post("/payment-proof")
def payment_proof():
file = request.files.get("screenshot")
if file is None or file.filename == "":
return "Choose a file first", 400
filename = secure_filename(file.filename)
if Path(filename).suffix.lower() not in ALLOWED:
return "Only JPG, PNG or PDF files", 400
file.save(UPLOAD_DIR / filename)
return f"Saved {filename}", 201
@app.post("/api/orders")
def api_order():
data = request.get_json(silent=True)
if data is None:
return {"error": "Send JSON"}, 400
return {"received": data, "plates": data.get("plates", 1)}, 201Sending a file named ../../UPI receipt.png, then an .exe, then a 3 MB file, then JSON and then plain text gives:
201 CREATED Saved UPI_receipt.png
400 BAD REQUEST Only JPG, PNG or PDF files
413 REQUEST ENTITY TOO LARGE
201 CREATED {'plates': 3, 'received': {'name': 'Isha', 'plates': 3}}
400 BAD REQUEST {'error': 'Send JSON'}secure_filename() removed the ../../ that could have saved the file outside the uploads folder, and replaced the space. MAX_CONTENT_LENGTH made Flask reject the large file with 413 before your code even ran.
Flask-WTF: forms as classes
Writing validate_order() by hand teaches you what validation is, but bigger apps use the Flask-WTF extension (pip install flask-wtf). It describes each form as a class with validators, and adds CSRF protection, which plain Flask does not have. (CSRF is an attack in which another site tricks a logged-in user's browser into submitting your form; you will learn it in Security.)
from flask_wtf import FlaskForm
from wtforms import BooleanField, IntegerField, StringField
from wtforms.validators import DataRequired, Length, NumberRange, Regexp
app.config["SECRET_KEY"] = "change-me" # needed for the CSRF token
class OrderForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=50)])
phone = StringField("Phone", validators=[Regexp(r"^\d{10}$", message="Phone must be 10 digits.")])
plates = IntegerField("Plates", validators=[NumberRange(min=1, max=20)])
veg_only = BooleanField("Veg only")
@app.route("/order", methods=["GET", "POST"])
def order():
form = OrderForm()
if form.validate_on_submit(): # POST and every validator passed
... # save, then redirect
return render_template("order.html", form=form)In the template, {{ form.csrf_token }} adds a hidden token field and {{ form.name() }} renders the input. form.name.errors holds that field's messages. Django's forms, which you will meet in Forms & ModelForms, follow the same idea and have CSRF protection built in.
Common mistakes
request.form["plates"] + 1. Form values are strings, so this raises aTypeError. Convert withint()after checking.- Forgetting
method="post"on the<form>. The browser then sends a GET, and the data appears inrequest.argsand in the address bar. - Missing
enctype="multipart/form-data". Without it,request.filesis empty. - Saving uploads with the user's file name. Always pass it through
secure_filename()and check the extension. - Returning a page after a successful POST. Redirect instead (Post/Redirect/Get).
Key points
- Query strings are in
request.args, form fields inrequest.form, JSON inrequest.get_json(), files inrequest.files. - All form and query values are strings; convert and validate them on the server.
- Re-show the form with old values and error messages when validation fails.
- After a successful POST, redirect (Post/Redirect/Get) so a refresh does not resubmit.
- Use
secure_filename()andMAX_CONTENT_LENGTHfor uploads; Flask-WTF adds form classes and CSRF protection.
Exercise
Add a /feedback page to Pune Tiffin with a form for name, rating (1 to 5) and comment (at most 200 characters). On POST, validate all three, show errors next to the fields if something is wrong, and on success store the feedback in a list and redirect to /feedback?sent=1, where the page shows "Thanks for the feedback!" above the empty form.
Show solution
FEEDBACK = []
@app.route("/feedback", methods=["GET", "POST"])
def feedback():
errors = {}
form = request.form
if request.method == "POST":
name = form.get("name", "").strip()
rating = form.get("rating", "")
comment = form.get("comment", "").strip()
if not name:
errors["name"] = "Name is required."
if rating not in {"1", "2", "3", "4", "5"}:
errors["rating"] = "Choose a rating from 1 to 5."
if len(comment) > 200:
errors["comment"] = "Keep it under 200 characters."
if not errors:
FEEDBACK.append({"name": name, "rating": int(rating), "comment": comment})
return redirect(url_for("feedback", sent=1))
sent = request.args.get("sent") == "1"
return render_template("feedback.html", errors=errors, form=form, sent=sent)POST /feedback (rating=9) -> 200 OK, form shown with "Choose a rating from 1 to 5." POST /feedback (valid) -> 302 FOUND, Location: /feedback?sent=1 GET /feedback?sent=1 -> 200 OK, "Thanks for the feedback!" shown
Checking rating against a set of allowed strings validates and limits it in one step. The sent=1 flag in the redirect URL lets the GET page show the thank-you line; the next lesson shows a cleaner way to do this with flash messages.
Quick Quiz
1. A form is sent with method="post". Where are its fields in Flask?
POSTed form fields are parsed into request.form; the query string stays in request.args.
2. A checkbox named veg_only is not ticked. What is "veg_only" in request.form?
Browsers only send a checkbox's value when it is ticked.
3. Why redirect after a successful POST?
This is the Post/Redirect/Get pattern; the browser ends on a harmless GET page.
4. What does secure_filename("../../UPI receipt.png") protect against?
It strips path parts and unsafe characters, giving UPI_receipt.png.
Learn Django & Flask with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
