EthicsComputerCourses
Join training +91 90532 10052

Flask Routes & URL Building

Django & Flask · Lesson 5 of 42 Intermediate 6 min read Updated

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:

Python
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.

ConverterMatchesExample URL part
string (default)Any text without /Priya
intPositive whole numbers42
floatPositive decimal numbers4.5
pathText that may include /reports/2026/june.pdf
uuidA UUID string1f0c...

Here is what the server returns for a few addresses:

Output
/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.

Python
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:

Python
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))
Output
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 1

DELETE 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:

Python
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"))
Output
/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.

Python
@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.", 404
Output
GET /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 is def dish(id). Flask raises a TypeError about an unexpected keyword argument.
  • Using the URL instead of the endpoint in url_for(). Write url_for("dish", dish_id=1), not url_for("/dishes/1").
  • Forgetting methods= for forms. The form posts and you get 405 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 as int, float and path check and convert it.
  • Routes answer GET by default; use methods=[...] or @app.get / @app.post for 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
Python
@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))
Output
/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?

2. Which call builds the URL for the view function dish with id 5?

3. A route is defined as @app.route("/areas/"). What happens when someone opens /areas?

4. How do you read ?page=2 as a number with a default of 1?

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