Flask Routes & URL Building
A real app has more than one page, and many pages depend on a value in the URL: /dishes/2, /hello/Priya, /orders?page=3. Flask's routing system turns these values into function arguments for you, checks their type, and builds links in the other direction so you never hard-code a URL.
In this lesson you will write variable routes with converters, accept different HTTP methods, read the query string, generate URLs with url_for(), and handle missing pages with abort(), redirects and a custom 404 handler.
Variable rules
Put a name in angle brackets inside the route, and Flask passes that part of the URL to your function as an argument with the same name:
from flask import Flask, abort, redirect, url_for
from markupsafe import escape
app = Flask(__name__)
DISHES = {
1: {"name": "Rajma chawal", "price": 120},
2: {"name": "Chole kulche", "price": 110},
3: {"name": "Veg pulao", "price": 100},
}
@app.route("/hello/<name>")
def hello(name):
return f"Namaste, {escape(name)}!"
@app.route("/dishes/<int:dish_id>")
def dish(dish_id):
if dish_id not in DISHES:
abort(404)
item = DISHES[dish_id]
return f"{item['name']}: Rs {item['price']}"<name> matches any text without a slash. Because a returned string is sent as HTML, escape() (from MarkupSafe, which Flask installs) turns characters such as < into safe codes; otherwise a visitor could put a <script> tag in the URL and run it in the page. Templates, which you meet next lesson, do this escaping for you.
<int:dish_id> uses a converter: it only matches whole numbers and gives your function a real int, so DISHES[dish_id] works without calling int() yourself.
| Converter | Matches | Example URL part |
|---|---|---|
string (default) | Any text without / | Priya |
int | Positive whole numbers | 42 |
float | Positive decimal numbers | 4.5 |
path | Text that may include / | reports/2026/june.pdf |
uuid | A UUID string | 1f0c... |
Here is what the server returns for a few addresses:
/hello/Priya -> Namaste, Priya! /dishes/2 -> Chole kulche: Rs 110 /dishes/9 -> 404 (no such dish, abort(404) ran) /dishes/abc -> 404 (the int converter did not match, so the view never ran)
Notice the difference between the last two. For /dishes/9 your code ran and chose to stop with abort(404). For /dishes/abc no route matched at all.
HTTP methods
A route answers only GET (plus the automatic HEAD and OPTIONS) unless you say otherwise. To accept form submissions, list the methods, then check request.method. Flask 2.0 added shortcut decorators, @app.get() and @app.post(), which many people find clearer for separate handlers.
from flask import Flask, request
from markupsafe import escape
app = Flask(__name__)
@app.route("/orders", methods=["GET", "POST"])
def orders():
if request.method == "POST":
return "Order placed", 201
return "Here are today's orders"
@app.post("/feedback")
def feedback_save():
return "Thanks for your feedback!"
@app.get("/search")
def search():
dish = request.args.get("dish", "")
page = request.args.get("page", 1, type=int)
return f"Searching for '{escape(dish)}', page {page}"request.args holds the query string (the part after ?). Use .get() with a default, never request.args["dish"], because a missing key would raise an error and the visitor would get a 400 Bad Request. The type=int argument converts the value and falls back to the default if the conversion fails.
Flask's test client can send fake requests to the app without starting a server. You will learn it properly in Testing; here it quickly shows the behaviour:
from methods import app
client = app.test_client()
checks = [
("GET", "/orders"),
("POST", "/orders"),
("DELETE", "/orders"),
("POST", "/feedback"),
("GET", "/search?dish=poha&page=2"),
("GET", "/search?page=two"),
]
for method, url in checks:
response = client.open(url, method=method)
print(f"{method:6} {url:26} {response.status}")
if response.status_code < 400:
print(" ", response.get_data(as_text=True))GET /orders 200 OK
Here are today's orders
POST /orders 201 CREATED
Order placed
DELETE /orders 405 METHOD NOT ALLOWED
POST /feedback 200 OK
Thanks for your feedback!
GET /search?dish=poha&page=2 200 OK
Searching for 'poha', page 2
GET /search?page=two 200 OK
Searching for '', page 1DELETE got 405 Method Not Allowed because /orders accepts only GET and POST. And page=two could not become an int, so the default 1 was used.
Building URLs with url_for()
Never write links like "/dishes/" + str(id) by hand. If you later rename the route to /menu/<id>, every hand-written link breaks. url_for() builds the URL from the endpoint name, which is the view function's name:
from app import app
from flask import url_for
with app.test_request_context():
print(url_for("hello", name="Meera"))
print(url_for("dish", dish_id=2))
print(url_for("dish", dish_id=2, spicy="yes"))
print(url_for("hello", name="Kabir Khan"))
print(url_for("static", filename="css/site.css"))/hello/Meera /dishes/2 /dishes/2?spicy=yes /hello/Kabir%20Khan /static/css/site.css
Arguments that match a variable in the route fill it in; extra arguments become the query string; spaces and special characters are escaped for you. Inside a view you call url_for() directly. Here we needed app.test_request_context() because url_for() must run while Flask is handling a request.
Redirects, trailing slashes and error pages
redirect() sends the browser to another URL with status 302 Found. Combined with url_for(), old addresses can point to new ones safely. @app.errorhandler(404) replaces Flask's default "Not Found" page with your own.
@app.route("/old-menu")
def old_menu():
return redirect(url_for("dish", dish_id=1))
@app.route("/areas/")
def areas():
return "We deliver in Kothrud, Baner and Aundh"
@app.errorhandler(404)
def not_found(error):
return "Sorry, that page is not on our menu.", 404GET /old-menu -> 302 FOUND, Location: /dishes/1 GET /areas -> 308 PERMANENT REDIRECT, Location: http://127.0.0.1:5000/areas/ GET /dishes/9 -> 404 NOT FOUND, body: Sorry, that page is not on our menu.
The trailing slash rule matters: a route written as /areas/ behaves like a folder, so /areas is redirected to /areas/ with 308. A route written as /areas (no slash) gives a 404 for /areas/. Pick one style and use it everywhere.
Common mistakes
- Argument name does not match. The route has
<int:dish_id>but the function isdef dish(id). Flask raises aTypeErrorabout an unexpected keyword argument. - Using the URL instead of the endpoint in
url_for(). Writeurl_for("dish", dish_id=1), noturl_for("/dishes/1"). - Forgetting
methods=for forms. The form posts and you get405 Method Not Allowed. - Reading query values with
request.args["key"]. A missing key gives a 400 error; use.get()with a default.
Key points
<name>in a route becomes a function argument; converters such asint,floatandpathcheck and convert it.- Routes answer GET by default; use
methods=[...]or@app.get/@app.postfor others. request.args.get("key", default, type=int)reads the query string safely.url_for("endpoint", **values)builds URLs from function names and escapes values.abort(404),redirect()and@app.errorhandler(404)handle missing pages and moved URLs.
Exercise
Add a route /bill/<int:dish_id>/<int:qty> that returns the total price, for example /bill/1/3 returns 3 x Rajma chawal = Rs 360. Return a 404 for unknown dishes and a 400 with the text Quantity must be between 1 and 20 when qty is 0 or more than 20. Also add a route /order-again that redirects to the bill for two plates of dish 2.
Show solution
@app.route("/bill/<int:dish_id>/<int:qty>")
def bill(dish_id, qty):
if dish_id not in DISHES:
abort(404)
if not 1 <= qty <= 20:
return "Quantity must be between 1 and 20", 400
item = DISHES[dish_id]
return f"{qty} x {item['name']} = Rs {qty * item['price']}"
@app.route("/order-again")
def order_again():
return redirect(url_for("bill", dish_id=2, qty=2))/bill/1/3 -> 3 x Rajma chawal = Rs 360 /bill/1/25 -> 400 Quantity must be between 1 and 20 /order-again -> 302 redirect to /bill/2/2 -> 2 x Chole kulche = Rs 220
Both values arrive as int thanks to the converters, so the maths works directly. url_for("bill", ...) fills both variables in the route.
Quick Quiz
1. What does <int:dish_id> do in a route?
The int converter both checks and converts the value; /dishes/abc does not match.
2. Which call builds the URL for the view function dish with id 5?
url_for() takes the endpoint name (the function name) and the route values as keyword arguments.
3. A route is defined as @app.route("/areas/"). What happens when someone opens /areas?
A route with a trailing slash acts like a folder, so Flask redirects the version without the slash.
4. How do you read ?page=2 as a number with a default of 1?
.get() with type=int converts safely and falls back to the default.
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
