Forms and redirects
GET and POST, validation messages, post/redirect/get, flash messages and CSRF tokens.
Create a free account Try part of it here
Why it matters
Almost everything people do on a website goes through a form: signing up, searching, posting a comment, checking out. Forms are also where most web bugs and attacks start, because the data comes from outside your program. A professional form checks everything on the server, explains each mistake next to the field it belongs to, never saves the same thing twice when someone presses reload, and can't be submitted by another website behind the user's back. This lesson builds all four habits with Flask.
What you'll be able to do
- Read form data and query strings, and choose GET or POST for a form
- Validate input on the server, showing errors next to the fields and keeping what was typed
- Redirect after a successful POST and confirm it with a flash message
- Protect forms from cross-site request forgery with a per-session token
What it covers
- Two ways to send a form
- Validate on the server, and keep what was typed
- Post, redirect, get
- Cross-site request forgery
- Worked example: a form with choices and numbers
Two ways to send a form
An HTML form names where to send its fields (action) and how (method):
<form action="/search" method="get"> <input name="q"> <button>Search</button> </form>
With method="get", the browser puts the fields in the address as a query string: /search?q=fern. Use GET for forms that only look things up, like search and filters, because the result can be bookmarked and shared. With method="post", the fields travel in the request's body instead. Use POST for anything that changes something: signing up, saving, deleting, paying.
Flask hands you both through the request object. request.args holds the query string and request.form holds a POST body. Both behave like dictionaries whose values are always strings:
from flask import Flask, request app = Flask(__name__) TRAILS = ["Fern Hollow", "Red Mesa", "Fernside Creek"] @app.route("/search") def search(): q = request.args.get("q", "").strip().lower() found = [t for t in TRAILS if q in t.lower()] if q else [] return f"{len(found)} result(s): {', '.join(found)}" @app.route("/subscribe", methods=["POST"]) def subscribe(): email = request.form.get("email", "") return f"subscribed {email!r}" client = app.test_client() print(client.get("/search?q=fern").get_data(as_text=True)) print(client.get("/search").get_data(as_text=True)) print(client.post("/subscribe", data={"email": "ada@example.com"}).get_data(as_text=True)) print(client.get("/subscribe").status_code)
.get("q", "") gives a default when the field is missing, so a bare /search doesn't crash. The last line shows Flask refusing a GET to a POST-only route with 405 Method Not Allowed.
from flask import Flask, request app = Flask("demo") @app.route("/n") def n(): return repr(request.args.get("count", "0") * 2) print(app.test_client().get("/n?count=3").get_data(as_text=True))
Show the answer
'33'
Form and query values are always strings: "3" * 2 repeats the text, giving '33'. Convert with int() (and handle the ValueError) before doing arithmetic.
Validate on the server, and keep what was typed
A form usually lives at one address that answers both methods: GET shows the empty form, and POST checks what came back. If anything is wrong, show the same form again with:
- a message beside each field that's wrong, saying how to fix it ("Severity must be a whole number from 1 to 5", not "Invalid input");
- the values the person already typed, so they fix one field instead of retyping everything;
- status 400, so tests, logs and other programs can tell a rejected form from a successful page.
Browser checks like required or type="email" are a convenience for people, not protection: anyone can send a request without your page, with a script or the test client. The server checks everything, every time.
from flask import Flask, request, render_template_string app = Flask(__name__) FORM = """<form method="post"> <input name="name" value="{{ values.name }}"> {% if errors.name %}<b>{{ errors.name }}</b>{% endif %} <input name="age" value="{{ values.age }}"> {% if errors.age %}<b>{{ errors.age }}</b>{% endif %} </form>""" def check_signup(form): errors = {} if not form.get("name", "").strip(): errors["name"] = "Please enter your name." try: age = int(form.get("age", "")) if not 16 <= age <= 120: errors["age"] = "Members must be 16 or older." except ValueError: errors["age"] = "Age must be a whole number, like 34." return errors @app.route("/join", methods=["GET", "POST"]) def join(): if request.method == "POST": errors = check_signup(request.form) if errors: return render_template_string(FORM, values=request.form, errors=errors), 400 return "Welcome!" return render_template_string(FORM, values={}, errors={}) client = app.test_client() bad = client.post("/join", data={"name": "<Ada>", "age": "thirty"}) print(bad.status_code) print(bad.get_data(as_text=True)) print(client.post("/join", data={"name": "Ada", "age": "34"}).get_data(as_text=True))
The checking lives in its own function, check_signup, which takes plain data and returns a dictionary of errors. That makes it easy to test with no web requests at all. Notice also that the name <Ada> came back escaped inside the value attribute: the template keeps even rejected input safe.
Post, redirect, get
Suppose a successful POST answered with a normal page saying "Report saved". If the person presses reload, the browser asks "Send the form again?", and one click saves a duplicate report, or pays twice. The fix is a pattern called post/redirect/get: after a successful POST, answer with a redirect, and let the browser fetch the result page with a fresh GET. Reloading then repeats only the harmless GET.
redirect(url_for("reports"), code=303) sends status 303 See Other with a Location header. A 303 tells the browser to follow it with GET, whatever the first request was. (Flask's default, 302, behaves the same in browsers in practice, but 303 says exactly what you mean.)
How does the result page say "Thanks, your report was saved"? With a flash message: flash("...") stores a message in the session, and the next page calls get_flashed_messages() to show it once and clear it. Sessions are signed cookies, so the app needs a secret_key. In a real app it comes from an environment variable, never from the code.
from flask import Flask, request, redirect, url_for, flash, get_flashed_messages app = Flask(__name__) app.secret_key = "practice-only-key" NOTES = [] @app.route("/notes", methods=["GET", "POST"]) def notes(): if request.method == "POST": NOTES.append(request.form["text"]) flash("Note saved.") return redirect(url_for("notes"), code=303) shown = " | ".join(get_flashed_messages()) return f"[{shown}] {len(NOTES)} note(s)" client = app.test_client() reply = client.post("/notes", data={"text": "Bring water"}) print(reply.status_code, reply.headers["Location"]) print(client.get("/notes").get_data(as_text=True)) print(client.get("/notes").get_data(as_text=True))
The first GET shows the message; the second doesn't, because a flash message is shown once. In tests, client.post(..., follow_redirects=True) follows the redirect for you and returns the final page.
Only the final GET is repeated by a reload, so nothing is saved twice.
Cross-site request forgery
When you're signed in to a site, your browser sends that site's cookie with every request to it, including requests that another site triggers. A page on evil.example can hold a hidden form that posts to https://trailnotes.example/delete-account and submit itself with a line of JavaScript. Your browser sends your cookie along, and the app sees a perfectly normal, signed-in POST. That attack is cross-site request forgery (CSRF).
The standard defense is a CSRF token: a random value the app stores in the session and also puts in a hidden field of every form it serves. On each POST it checks that the field matches the session. The other site can make your browser send the cookie, but it can't read your pages, so it can't know the token.
import secrets from flask import Flask, request, session, abort app = Flask(__name__) app.secret_key = "practice-only-key" def csrf_token(): if "csrf" not in session: session["csrf"] = secrets.token_urlsafe(16) return session["csrf"] def require_csrf(): sent = request.form.get("csrf_token", "") if not secrets.compare_digest(sent, session.get("csrf", "")) or not sent: abort(400) @app.route("/delete-account", methods=["GET", "POST"]) def delete_account(): if request.method == "POST": require_csrf() return "account deleted" return f'<form method="post"><input type="hidden" name="csrf_token" value="{csrf_token()}"><button>Delete</button></form>' client = app.test_client() page = client.get("/delete-account").get_data(as_text=True) token = page.split('value="')[1].split('"')[0] print("forged:", client.post("/delete-account", data={}).status_code) print("guessed:", client.post("/delete-account", data={"csrf_token": "abc"}).status_code) print("real form:", client.post("/delete-account", data={"csrf_token": token}).get_data(as_text=True))
secrets.compare_digest compares in constant time, so an attacker can't learn the token one character at a time from how long each comparison takes. Two more layers help: cookies marked SameSite=Lax (Flask's SESSION_COOKIE_SAMESITE setting) aren't sent on most cross-site POSTs, and changes should never happen on GET. In real projects, the Flask-WTF extension adds tokens and form classes for you; it's worth knowing what it does underneath.
Worked example: a form with choices and numbers
A booking form for guided walks: a choice from a fixed list, a date and a group size. Everything is checked on the server, including the choice, because a request can send any text at all.
from datetime import date from flask import Flask, request, render_template_string WALKS = {"dawn": "Dawn chorus walk", "ridge": "Red Mesa ridge", "creek": "Creek and ferns"} TODAY = date(2025, 6, 1) app = Flask(__name__) FORM = """<select name="walk">{% for key, name in walks.items() %} <option value="{{ key }}"{% if values.walk == key %} selected{% endif %}>{{ name }}</option>{% endfor %}</select> {% for field, message in errors.items() %}<p class="error">{{ field }}: {{ message }}</p>{% endfor %}""" def check_booking(form): errors, clean = {}, {} if form.get("walk") not in WALKS: errors["walk"] = "Choose one of the walks in the list." else: clean["walk"] = form["walk"] try: day = date.fromisoformat(form.get("day", "")) if day <= TODAY: errors["day"] = "Pick a day after today." clean["day"] = day except ValueError: errors["day"] = "Use a date like 2025-06-14." try: size = int(form.get("size", "")) if not 1 <= size <= 12: raise ValueError clean["size"] = size except ValueError: errors["size"] = "Groups are 1 to 12 people." return clean, errors @app.route("/book", methods=["GET", "POST"]) def book(): if request.method == "POST": clean, errors = check_booking(request.form) if errors: return render_template_string(FORM, walks=WALKS, values=request.form, errors=errors), 400 return f"Booked {WALKS[clean['walk']]} on {clean['day']:%d %B} for {clean['size']}." return render_template_string(FORM, walks=WALKS, values={}, errors={}) client = app.test_client() for data in [ {"walk": "ridge", "day": "2025-06-14", "size": "4"}, {"walk": "volcano", "day": "2025-05-01", "size": "40"}, ]: reply = client.post("/book", data=data) print(reply.status_code, reply.get_data(as_text=True).replace("\n", " ")[-170:])
check_booking returns two things: the cleaned values, converted to real types (a date, an int), and the errors. The view only uses cleaned values, never the raw strings, so the rest of the program can trust its data. Checking the walk against WALKS matters even though the page only offers three options: the second request sends "volcano", which no browser would, and gets a clear message instead of a crash. The template marks the chosen option selected, so a corrected form keeps the person's choice.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A search form and a "delete my account" form. Which methods should they use?