Session 5: Forms and POST requests
Phase 8 — Flask · Session 5 of 13
What we’re learning today
So far the user reads from your server. Today
they send data to it. Forms in HTML, processed
in Python via request.form, with the standard
GET-then-POST pattern: show the form on GET,
process it on POST, redirect to a result page. By
the end you’ll have a guestbook where any visitor
can leave a message.
This is the first time the user changes what your server holds.
You’ll need to remember from last time
- Templates with
render_template. - Base templates with
extends/block. - Phase 7 Session 7 — HTML forms (
<form>,<input>,<button>). - Lists in Python — for the guestbook.
Part A: GET vs POST
Two kinds of requests
Browsers send two main kinds of requests:
- GET — “give me this page.” The default for every link click and URL typed in the bar. No body — just URL.
- POST — “here’s some data; do something with
it.” Used by forms with
method="post". Has a body — the form data.
So far, every route you wrote handled GET. To handle POST, you tell Flask explicitly:
@app.route("/submit", methods=["GET", "POST"])
def submit():
...
By default routes only handle GET. Listing
["GET", "POST"] means “this route handles both.”
request.method — which one?
Inside the route, check which method the browser used:
from flask import request
@app.route("/submit", methods=["GET", "POST"])
def submit():
if request.method == "POST":
# Process the form data
return "Got your data!"
else:
# Show the form
return "<form method='post'><input name='x'><button>Send</button></form>"
The same URL can show a form (on GET) and process the form (on POST). Standard pattern.
request.form — read submitted data
When a form is submitted, the data is in
request.form — a dict-like object keyed by input
name attributes:
<input type="text" name="message">
text = request.form.get("message")
request.form.get("name") — same .get() pattern
as Python dicts. Returns None if missing.
request.form["name"] also works but throws an
error on missing keys.
Build it — a simple form
Project structure:
form_app/
├── app.py
├── templates/
│ ├── base.html
│ └── form.html
└── static/
└── style.css
templates/form.html:
{% extends "base.html" %}
{% block title %}Send a message{% endblock %}
{% block content %}
<h1>Send me a message</h1>
<form method="post">
<label for="name">Your name:</label>
<input type="text" id="name" name="name" required>
<label for="message">Your message:</label>
<textarea id="message" name="message" rows="4" required></textarea>
<button type="submit">Send</button>
</form>
{% endblock %}
Note method="post" on the form — this is what
makes the submit a POST.
app.py:
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route("/", methods=["GET", "POST"])
def home():
if request.method == "POST":
name = request.form.get("name")
message = request.form.get("message")
return f"<h1>Thanks, {name}!</h1><p>You said: {message}</p>"
return render_template("form.html")
if __name__ == "__main__":
app.run(debug=True)
Add templates/base.html and static/style.css
from previous sessions.
Save. Run. Visit /. See the form. Type a name
and message. Submit. You see your data echoed
back.
You just received data from the user. First time. The web is now interactive.
The redirect-after-POST pattern
There’s a problem with the above. After submit,
the URL is still / but the page shows the
result. Refresh the page — the browser asks
“resubmit?” because it tries to repeat the POST.
Annoying.
The fix: after processing a POST, redirect to a different URL (or back to the same one, as a GET). This is the POST-Redirect-GET pattern.
Update app.py:
from flask import Flask, render_template, request, redirect, url_for
app = Flask(__name__)
# In-memory storage — resets on server restart
messages = []
@app.route("/", methods=["GET", "POST"])
def home():
if request.method == "POST":
name = request.form.get("name")
message = request.form.get("message")
messages.append({"name": name, "message": message})
return redirect(url_for("home")) # GET back to home
return render_template("form.html", messages=messages)
Update templates/form.html to show messages:
{% extends "base.html" %}
{% block title %}Guestbook{% endblock %}
{% block content %}
<h1>Guestbook</h1>
<form method="post">
<label for="name">Your name:</label>
<input type="text" id="name" name="name" required>
<label for="message">Your message:</label>
<textarea id="message" name="message" rows="4" required></textarea>
<button type="submit">Sign the guestbook</button>
</form>
<h2>Messages</h2>
{% if messages %}
{% for m in messages %}
<div class="message">
<strong>{{ m.name }}:</strong>
<p>{{ m.message }}</p>
</div>
{% endfor %}
{% else %}
<p>No messages yet. Be the first!</p>
{% endif %}
{% endblock %}
Save. Submit a few messages. Each one appears in the list. Refresh — no resubmit warning. Real guestbook.
Pattern recap:
- GET
/→ show the form + existing messages. - POST
/→ process, store, redirect back to GET/. - The redirect = a fresh GET, no resubmit warning.
Memorize this pattern. Every form-processing route uses it.
Note on the messages list
The messages = [] lives in your Python module.
It’s stored in memory — when you stop the
server, the list is gone. Sessions 7-8 add a
database so the data really persists.
For now: in-memory is fine for learning the form pattern.
Checkpoint: Your guestbook accepts messages, stores them, and shows them on refresh without a resubmit warning. This is the natural stop point if class is cut short.
Part B: Validation and feedback
Server-side validation
Browser-side required attributes catch some
issues but can be bypassed. Server-side
validation is the safety net.
@app.route("/", methods=["GET", "POST"])
def home():
error = None
if request.method == "POST":
name = request.form.get("name", "").strip()
message = request.form.get("message", "").strip()
if not name or not message:
error = "Name and message are required."
elif len(message) > 500:
error = "Message must be under 500 characters."
else:
messages.append({"name": name, "message": message})
return redirect(url_for("home"))
return render_template("form.html", messages=messages, error=error)
Show the error in the template:
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
Add to static/style.css:
.error {
background-color: #ffe5e5;
color: #c0392b;
padding: 12px;
border-left: 4px solid #c0392b;
margin-bottom: 16px;
}
Try submitting empty (after removing required from
the input): error message appears. Try a 600-
character message: error.
Stretch — preserve form state on error
When the error shows, the form is empty again. Bad UX. Fix by passing the previous values back:
return render_template("form.html",
messages=messages,
error=error,
name=name,
message=message)
In the template:
<input type="text" id="name" name="name" value="{{ name|default('') }}" required>
<textarea id="message" name="message" rows="4" required>{{ message|default('') }}</textarea>
Now after an error, the form keeps what the user typed. Real UX practice.
Stretch — different forms, different routes
You can have separate routes for showing vs. processing:
@app.route("/submit-form")
def show_form():
return render_template("form.html")
@app.route("/submit", methods=["POST"])
def process_form():
name = request.form.get("name")
# process...
return redirect(url_for("show_form"))
The form’s action="/submit" posts to the second
route.
The single-route GET-or-POST is more common — keep both behaviors together. But knowing the multi-route variant exists is useful.
Stretch — checkboxes and selects
<input type="checkbox" name="newsletter" id="newsletter">
<label for="newsletter">Subscribe to newsletter</label>
<label for="topic">Topic:</label>
<select name="topic" id="topic">
<option value="general">General</option>
<option value="bug">Bug report</option>
</select>
newsletter = request.form.get("newsletter") # "on" if checked, None if not
topic = request.form.get("topic") # "general" or "bug"
For checkboxes, the value is "on" (or whatever
value= you set) when checked, None (missing)
when unchecked. Use if newsletter: to check.
Extension — multiple values
For multi-select or multiple checkboxes with the
same name, use request.form.getlist:
<input type="checkbox" name="hobbies" value="reading">
<input type="checkbox" name="hobbies" value="coding">
<input type="checkbox" name="hobbies" value="music">
hobbies = request.form.getlist("hobbies") # ["reading", "coding"]
Returns a list of all values for that name.
Extension — file uploads
<form method="post" enctype="multipart/form-data">
<input type="file" name="upload">
<button type="submit">Upload</button>
</form>
file = request.files.get("upload")
if file:
file.save(f"static/uploads/{file.filename}")
The enctype is required for file uploads. Beyond
that — pretty straightforward. (Real apps need to
sanitize filenames and check types.)
Wrap-up
Before we leave, share with the room:
- For everyone — show your guestbook. How many messages?
- Did the GET-then-POST-then-redirect pattern make sense?
- For the kids who added validation — what error did you trigger?
- Anyone notice messages disappear when the server restarts? That’s why we need a database (Session 7).
Today you learned:
- GET vs POST — read vs send data.
methods=["GET", "POST"]to handle both.request.methodto check which.request.form.get("name")to read form data.- POST-Redirect-GET pattern — redirect after processing to avoid resubmit warnings.
redirect(url_for("route_name"))for redirects.- Server-side validation — never trust the client.
request.form.getlistfor multi-value fields.
The first time the user changes data on your server. The web is no longer read-only.
Next week: sessions and flash messages — remember things across requests, show one-time notifications.
If you missed this session
Open Thonny.
-
Build a simple form template with name and message inputs.
-
Set up
app.pywith a single route handling both GET and POST. -
Use
request.form.get(...)to read the data. -
Implement the POST-Redirect-GET pattern with
redirect(url_for(...)). -
(Stretch) Add server-side validation.
About 45-60 minutes. By the end you should have a working guestbook.
Stretch and extension ideas
- Server-side validation (above).
- Preserve form state on error.
- Checkboxes, selects, multi-value fields.
- File uploads (advanced).
- CSRF protection with Flask-WTF (advanced — out of curriculum scope).
- Form deletion — delete-message buttons (uses
POST to
/delete/<id>). - Edit messages — edit form on GET, save on POST.
What’s next
Next week: sessions and flash messages — the server remembers visitors across requests (session cookies), and you can flash one-time messages like “Message sent!” that show on the next page load.