The idea
A shop with 15 products shouldn't need 15 routes. Instead, one route handles the pattern /products/<id>, and the part in angle brackets arrives in your function as an argument. That's how every real site works: /products/3, /customers/ayu-lestari, /orders/2024/06.
Variables in the path
Toko Kita has many products, and each one needs its own page: /products/1, /products/2 and so on. Writing a separate route for every product would be absurd. Instead, you write one route with a placeholder for the id, and Flask passes whatever is in that spot to your function:
from flask import Flask
app = Flask(__name__)
PRODUCTS = {
1: {"name": "French Press 600ml", "price": 185000},
2: {"name": "Pour-over Kettle", "price": 320000},
3: {"name": "Arabica Gayo 250g", "price": 95000},
}
@app.route("/products/<int:product_id>")
def product(product_id):
item = PRODUCTS.get(product_id)
if item is None:
return f"No product {product_id}", 404
return f"<h1>{item['name']}</h1><p>Rp {item['price']:,}</p>"
client = app.test_client()
print(client.get("/products/2").get_data(as_text=True))
print(client.get("/products/99").status_code)
print(client.get("/products/abc").status_code)
<h1>Pour-over Kettle</h1><p>Rp 320,000</p>
404
404
<int:product_id>matches a whole number in that position and passes it asproduct_id, already converted to anint.- An unknown id returns 404 with a message, not a crash.
/products/abcdoesn't match<int:...>at all, so Flask returns 404 before your function even runs.
Converters
| Converter | Matches | Example |
|---|---|---|
<name> or <string:name> | any text without / (the default) | /customers/ayu |
<int:name> | whole numbers | /orders/41 |
<float:name> | decimals | /discount/0.15 |
<path:name> | text including / | /files/reports/2024/june.csv |
Converters are free validation: with <int:...>, your function never receives "abc".
Several parameters
A URL can carry more than one value. A monthly report needs both a year and a month, like /reports/2024/7, and each placeholder becomes its own argument:
from flask import Flask
app = Flask(__name__)
@app.route("/reports/<int:year>/<int:month>")
def monthly_report(year, month):
if not 1 <= month <= 12:
return "Month must be 1 to 12", 404
return f"Report for {year}-{month:02d}"
client = app.test_client()
print(client.get("/reports/2024/6").get_data(as_text=True))
print(client.get("/reports/2024/13").status_code)
Report for 2024-06
404
The parameter names in the route must match the function's parameter names.
abort: stop with an error status
Returning "...", 404 works, but abort(404) reads better and stops the function immediately, wherever you are:
from flask import Flask, abort
app = Flask(__name__)
CUSTOMERS = {"ayu-lestari": "Ayu Lestari", "budi-santoso": "Budi Santoso"}
@app.route("/customers/<slug>")
def customer(slug):
name = CUSTOMERS.get(slug) or abort(404)
return f"<h1>{name}</h1>"
client = app.test_client()
print(client.get("/customers/ayu-lestari").get_data(as_text=True))
print(client.get("/customers/nobody").status_code)
<h1>Ayu Lestari</h1>
404
The text in the URL (ayu-lestari) is called a slug: a readable, URL-safe version of a name. Slugs make nicer URLs than ids, but they must be unique.
url_for: never hard-code URLs
Links between pages should be built with url_for, from the function name and its parameters:
from flask import Flask, url_for
app = Flask(__name__)
@app.route("/products/<int:product_id>")
def product(product_id):
return f"Product {product_id}"
@app.route("/")
def home():
link = url_for("product", product_id=3)
return f'<a href="{link}">Our best coffee</a>'
client = app.test_client()
print(client.get("/").get_data(as_text=True))
<a href="/products/3">Our best coffee</a>
If you later change the route to /shop/items/<int:product_id>, every url_for("product", ...) link updates automatically. Hard-coded "/products/3" strings would all break.
Routes describe URL patterns; view functions receive the variable parts as typed arguments; url_for builds URLs from function names. Your Python code talks in functions and ids, never in hand-built URL strings.
Trailing slashes
@app.route("/products/") (with a slash) also answers /products by redirecting to the slash version. @app.route("/about") (without) returns 404 for /about/. Pick one style and stick to it: folders-like collections with a slash, single pages without.
Why this matters for clean code
Dynamic routes keep one function per kind of page, not per page, which is the "don't repeat yourself" principle at the URL level. Converters validate input before your code runs, abort keeps the happy path flat, and url_for removes a whole class of broken-link bugs.
Recap
@app.route("/products/<int:product_id>")passes the URL part as a typed argument.- Converters:
string(default),int,float,path. - Return
..., 404or callabort(404)for unknown items. - Build links with
url_for("function_name", param=value), not hand-written strings.