Routing & URL Building
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-18) and Werkzeug 3.1.9, checked on 2026-10-07. Flask 3.2 had
not been released on that date, so everything described as "Flask 3.2" is upcoming and may still change. The code uses
Python 3.10+ syntax and was executed on Python 3.13 with app.test_client(). Routing is the part of Flask that turns an
incoming path and HTTP method into a view function, and turns an endpoint name and a few values back into a path.
Flask adds a thin layer of decorators on top, and Werkzeug does the actual work: its werkzeug.routing package owns the
Map, the Rule objects, the converters, the matching state machine and the redirect and error exceptions. This page
covers every part of that machinery that a Flask developer touches, from the first @app.route to custom converters,
subdomains and REST-style URL design. The scenario is the Bookshelf catalogue that the other pages of this section
build on.
How Flask Routing Works
Every Flask application owns one app.url_map, a werkzeug.routing.Map. Each call to @app.route() or
app.add_url_rule() creates a Rule (Flask’s app.url_rule_class) and adds it to that map, and stores the view function
in app.view_functions under the rule’s endpoint name. The two lookups that matter are:
-
Matching (path to endpoint): when a request starts, Flask binds the map to the request (
Map.bind_to_environ, giving aMapAdapter) and callsmatch(). The result is a rule, which becomesrequest.url_rule, plus a dictionary of converted values, which becomesrequest.view_args.request.endpointis the rule’s endpoint name. -
Building (endpoint to path):
url_for()asks the same adapter tobuild()a URL from an endpoint name and values.
A third lookup is hidden inside matching. When the match fails, Werkzeug does not return None; it raises an
exception, and Flask stores it on request.routing_exception instead of raising it straight away:
| Exception | Status | When it happens |
|---|---|---|
|
404 |
No rule matches the path (or a converter rejected the value). |
|
405 |
A rule matches the path but not the method; the |
|
308 |
The path is right but not canonical: a missing or extra trailing slash, repeated slashes, or a URL that duplicates a |
The stored exception is raised later, inside dispatch_request(), which is why the url value preprocessors and the
before_request functions still run for a URL that does not exist (see URL Processors and Language Prefixes).
Source: Werkzeug: URL Routing
The order of the diagram matters in practice: matching happens when the request context is pushed, the preprocessors and
before_request functions run next, and only then does Flask raise a stored routing error or call the view. The
Application and Request Lifecycle page lists every step.
Declaring Routes
There are three equivalent ways to register a rule. All of them end in Flask.add_url_rule():
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Bookshelf"
@app.get("/books/") # shortcut for route("/books/", methods=["GET"])
def list_books():
return "all books"
@app.post("/books/") # same URL, another method, another function
def create_book():
return "created", 201
def about():
return "about"
app.add_url_rule("/about", endpoint="about", view_func=about)
client = app.test_client()
assert client.get("/").data == b"Bookshelf"
assert client.post("/books/").status_code == 201
assert client.get("/about").data == b"about"
assert sorted(app.view_functions) == ["about", "create_book", "home", "list_books", "static"]
Source: Flask: Quickstart, Routing
@app.route() is just a decorator that calls add_url_rule() with the decorated function. Since Flask 2.0 the shortcuts
@app.get(), @app.post(), @app.put(), @app.patch() and @app.delete() fix the methods argument for you, and
they are the form to prefer in new code. The same decorators exist on blueprints. Anything else that Werkzeug’s Rule
accepts is passed straight through as a keyword argument.
The Endpoint
An endpoint is the name of a rule. By default it is the view function’s __name__, and it is what url_for(),
request.endpoint and app.view_functions use. A rule defined on a blueprint gets the blueprint’s name and a dot in
front of it (catalogue.list_books). Two details cause most of the confusion:
-
Registering a different function under an endpoint name that is already taken raises an
AssertionError("View function mapping is overwriting an existing endpoint function"). That is what happens when two views in one module share a function name, or when one function is wrapped by a decorator that forgetsfunctools.wraps. -
Registering the same function twice under two different URLs is fine, and so is giving the same URL to several functions with different methods, as the example above does. Pass
endpoint=to name a rule explicitly.
Rule Options
Everything after the URL and endpoint in route() and add_url_rule() is a keyword argument. Flask handles
provide_automatic_options itself and hands the rest to Rule:
| Option | Meaning |
|---|---|
|
A list of HTTP methods (any case; Flask upper-cases them). A bare string raises a |
|
The rule’s name. Defaults to the view function’s name. |
|
A dictionary of default values for variables that do not appear in the URL (see Default Values and Pagination URLs). |
|
Per-rule override of the trailing-slash behaviour (see Trailing Slashes and Repeated Slashes). |
|
Per-rule override for collapsing repeated slashes in the incoming path. |
|
Match only this subdomain (needs |
|
Match only this host (needs |
|
A URL, or a callable returning one, to redirect to with status 308 instead of calling a view. |
|
The rule is used by |
|
Marks the rule as an alias of another with the same endpoint and defaults; the alias redirects to the canonical rule. |
|
Flask only. Force or disable the automatic |
Source: Flask API: Flask.add_url_rule
redirect_to is handy for a renamed URL that old links still use:
from flask import Flask
app = Flask(__name__)
@app.route("/catalogue/")
def catalogue():
return "catalogue"
# the old URL keeps working: it answers 308 and points at the new one
app.add_url_rule("/catalog/", endpoint="old_catalog", redirect_to="/catalogue/")
client = app.test_client()
response = client.get("/catalog/")
assert response.status_code == 308
assert response.headers["Location"].endswith("/catalogue/")
assert client.get("/catalog/", follow_redirects=True).data == b"catalogue"
Source: Werkzeug: werkzeug.routing.Rule
A rule registered with redirect_to has no view function, so it needs the explicit endpoint= that the example gives it.
Routes must be registered before the application handles its first request. Since Flask 2.3 a late @app.route raises
an AssertionError ("The setup method 'route' can no longer be called on the application"). The same applies to
blueprints after registration. Do all the registration in the application factory or at import time, and see
Application Factory & Project Layout for the places
where routes normally get registered.
Flask 3.2 (unreleased) adds an @app.query() decorator for the HTTP QUERY method, a safe request that carries a body,
and lets a single view force the automatic OPTIONS response with provide_automatic_options=True.
Variable Rules and Built-in Converters
A part of the URL written as <name> or <converter:name> is a variable. The converter decides which text matches the
variable and which Python type the view receives. Without a converter the variable is a string. The built-in converters
live in werkzeug.routing and are registered in app.url_map.converters:
| Converter | Matches | Arguments |
|---|---|---|
|
Any text without a slash, at least one character. Python type |
|
|
Unsigned decimal digits. Type |
|
|
Unsigned floating point with a decimal point ( |
|
|
Like |
none |
|
A UUID in the standard dashed form. Type |
none |
|
One of the fixed strings given as arguments. Type |
the allowed values |
Source: Werkzeug: Built-in Converters
Arguments are written in parentheses after the converter name, with the same syntax as a Python call:
import uuid
from flask import Flask
app = Flask(__name__)
@app.route("/books/<int:book_id>")
def book(book_id):
return f"book {book_id} is a {type(book_id).__name__}"
@app.route("/shelves/<int(min=1, max=99):number>")
def shelf(number):
return f"shelf {number}"
@app.route("/years/<int(fixed_digits=4):year>")
def year(year):
return str(year)
@app.route("/isbn-prefix/<string(length=3):prefix>")
def prefix(prefix):
return prefix
@app.route("/formats/<any(epub, pdf, mobi):fmt>")
def fmt(fmt):
return fmt
@app.route("/downloads/<path:name>")
def download(name):
return name
@app.route("/covers/<uuid:cover_id>")
def cover(cover_id):
return type(cover_id).__name__
client = app.test_client()
assert client.get("/books/7").data == b"book 7 is a int"
assert client.get("/books/-1").status_code == 404 # not signed
assert client.get("/books/seven").status_code == 404
assert client.get("/shelves/5").status_code == 200
assert client.get("/shelves/100").status_code == 404 # above max
assert client.get("/years/0007").status_code == 200
assert client.get("/years/7").status_code == 404 # fixed_digits=4
assert client.get("/isbn-prefix/978").status_code == 200
assert client.get("/isbn-prefix/9781").status_code == 404
assert client.get("/formats/pdf").data == b"pdf"
assert client.get("/formats/txt").status_code == 404
assert client.get("/downloads/a/b/c.txt").data == b"a/b/c.txt"
assert client.get("/covers/" + str(uuid.uuid4())).data == b"UUID"
Source: Werkzeug: Built-in Converters
Three consequences of these rules catch people out:
-
A value that fails conversion is a 404, not a 400.
/books/sevenand/books/-1simply do not exist as far as the router is concerned. Passsigned=Trueif negative numbers must be accepted. -
stringnever contains a slash. Usepathfor file names with directories, and remember that the value is still untrusted: usesend_from_directory()orwerkzeug.utils.safe_join()before touching the file system. -
inthas no upper bound unless you givemax. A value with thousands of digits still converts, so cap identifiers that go into database queries.
Which Rule Wins
The books were written for Werkzeug versions that tried rules in a weighted order. Since Werkzeug 2.2 the matcher is a state machine that walks the path part by part, and it prefers a static part over a converter regardless of the order in which the rules were registered. A rule with a plain segment therefore beats a variable one:
from flask import Flask
app = Flask(__name__)
@app.route("/authors/<name>")
def author(name):
return f"author {name}"
@app.route("/authors/me")
def current_author():
return "the current author"
client = app.test_client()
assert client.get("/authors/me").data == b"the current author"
assert client.get("/authors/ursula").data == b"author ursula"
Source: Werkzeug: URL Routing
Among variables, each converter class has a weight attribute (in Werkzeug 3.1.9: int and float 50, string and
uuid 100, path 200) that ranks the more specific type first. In practice /<int:id> is tried before /<name> for
the same position, so /12 goes to the integer rule and /ab to the string rule. Run flask routes --sort match to see the order Flask will really use.
Default Values and Pagination URLs
The defaults option supplies a value for a variable that is not in the URL, which lets one view serve two URLs. The
classic use is a paginated list whose first page has a short URL:
from flask import Flask, url_for
app = Flask(__name__)
@app.route("/books/", defaults={"page": 1})
@app.route("/books/page/<int:page>")
def books(page):
return f"page {page}"
client = app.test_client()
assert client.get("/books/").data == b"page 1"
assert client.get("/books/page/3").data == b"page 3"
# the duplicate URL redirects to the short one instead of serving the same page twice
duplicate = client.get("/books/page/1")
assert duplicate.status_code == 308
assert duplicate.headers["Location"].endswith("/books/")
with app.test_request_context():
assert url_for("books") == "/books/"
assert url_for("books", page=1) == "/books/"
assert url_for("books", page=2) == "/books/page/2"
Source: Werkzeug: werkzeug.routing.Rule
The redirect from /books/page/1 is the redirect_defaults behaviour of the Map (on by default), which stops search
engines from indexing two URLs for one page. A query-string parameter (/books/?page=2) is the other common design; it
needs no routing at all because the view reads request.args. The path-based form is better for caching and for URLs
that people share, the query form is simpler when several filters combine. Running the database query that does the
slicing is the job of db.paginate() in Flask-SQLAlchemy, covered on the Flask-SQLAlchemy page of this section.
Custom Converters
When the built-in set is not enough, subclass werkzeug.routing.BaseConverter, give it a regular expression, and
register it. A converter has up to four moving parts:
| Member | Role |
|---|---|
|
The pattern the path part must match. It decides whether the rule matches at all, so write it as strictly as the format allows. |
|
Called after a match. Returns the object the view receives. Raise |
|
Called by |
|
|
Source: Werkzeug: Custom Converters
A converter that validates ISBN-10 and ISBN-13 numbers and hands the view a normalized string shows all of them:
from flask import Flask, url_for
from werkzeug.routing import BaseConverter, ValidationError
def isbn13_ok(digits: str) -> bool:
total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits[:12]))
return (10 - total % 10) % 10 == int(digits[12])
def isbn10_ok(digits: str) -> bool:
values = [10 if c == "X" else int(c) for c in digits]
return sum(v * (10 - i) for i, v in enumerate(values)) % 11 == 0
class IsbnConverter(BaseConverter):
# digits and hyphens, optionally ending in X; the check digit is verified in to_python
regex = r"[0-9][0-9-]{8,15}[0-9X]"
def to_python(self, value: str) -> str:
digits = value.replace("-", "")
if len(digits) == 13 and digits.isdigit() and isbn13_ok(digits):
return digits
if len(digits) == 10 and digits[:9].isdigit() and isbn10_ok(digits):
return digits
raise ValidationError() # becomes a 404 for this rule
def to_url(self, value) -> str:
return super().to_url(str(value).replace("-", ""))
app = Flask(__name__)
app.url_map.converters["isbn"] = IsbnConverter
@app.route("/books/<isbn:isbn>")
def book_by_isbn(isbn):
return isbn
client = app.test_client()
assert client.get("/books/978-0-13-468599-1").data == b"9780134685991"
assert client.get("/books/0-306-40615-2").data == b"0306406152"
assert client.get("/books/978-0-13-468599-2").status_code == 404 # wrong check digit
assert client.get("/books/12").status_code == 404 # does not match the regex
with app.test_request_context():
assert url_for("book_by_isbn", isbn="0-306-40615-2") == "/books/0306406152"
Source: Werkzeug: Custom Converters
Registration is app.url_map.converters["isbn"] = IsbnConverter. The dictionary lives on the application’s map, so it
must be updated before the first rule that uses the converter is added, and an application factory should do it before
registering blueprints. A blueprint’s rules may use a converter that the application registers, which is why converters
belong in the factory and not in a blueprint module.
The same mechanism turns one URL part into a Python list. This converter accepts +-separated values, and it is the
usual answer when someone wants /tags/fantasy+classic:
from urllib.parse import quote
from flask import Flask, url_for
from werkzeug.routing import BaseConverter
class ListConverter(BaseConverter):
regex = r"[^/]+(?:\+[^/]+)*" # one or more parts joined by +, never a slash
def to_python(self, value: str) -> list[str]:
return value.split("+")
def to_url(self, values) -> str:
return "+".join(quote(str(v), safe="") for v in values)
app = Flask(__name__)
app.url_map.converters["list"] = ListConverter
@app.route("/tags/<list:tags>")
def by_tags(tags):
return ",".join(tags)
assert app.test_client().get("/tags/fantasy+classic").data == b"fantasy,classic"
with app.test_request_context():
assert url_for("by_tags", tags=["sci fi", "space"]) == "/tags/sci%20fi+space"
Source: Werkzeug: Custom Converters
Things to keep in mind when writing converters:
-
Keep
to_pythoncheap and side-effect free. Looking an object up in the database inside a converter is a popular pattern (a<model:book>converter), but it hides a query in the routing step, runs before anybefore_requestfunction, and runs even for a user who is not allowed to see the object. A converter that only validates the shape is safer. -
An exception other than
ValidationErrorraised into_pythonis an ordinary unhandled error and gives a 500. -
If the pattern must match a slash,
to_pythonreceives the whole slash-containing text, andpart_isolatingmust beFalse. Werkzeug 2.2 changed the router so that a converter that needs to match/has to set this class variable, and since 2.3 that is detected automatically for aregexwritten directly in the class body. -
The regex runs on request paths chosen by the client. A pattern with nested quantifiers can be abused for denial of service, so keep it simple and test it against long inputs.
HTTP Methods, HEAD and OPTIONS
methods=["GET"] is the default. What Flask does beyond that is the part the older books skip:
-
HEADis added automatically to every rule that allowsGET. The view runs as forGETand the body is dropped from the response. -
OPTIONSis added automatically and answered by Flask itself with anAllowheader, unless the rule listsOPTIONSexplicitly or the view setsprovide_automatic_options = False. The application-wide switch is thePROVIDE_AUTOMATIC_OPTIONSconfig key (defaultTrue). -
A path that matches a rule but not the method returns 405 Method Not Allowed with an
Allowheader that lists the methods which would have worked.@app.get("/books/")and@app.post("/books/")on the same URL produce one rule each, and the 405 forDELETElists both.
from flask import Flask, request
app = Flask(__name__)
@app.get("/books/")
def list_books():
return {"books": []}
@app.post("/books/")
def create_book():
return {"created": True}, 201
@app.route("/login", methods=["GET", "POST"]) # one function for both methods
def login():
if request.method == "POST":
return "checking credentials"
return "login form"
client = app.test_client()
head = client.head("/books/")
assert head.status_code == 200 and head.data == b""
options = client.options("/books/")
assert options.status_code == 200
assert set(options.headers["Allow"].replace(" ", "").split(",")) == {"GET", "HEAD", "POST", "OPTIONS"}
not_allowed = client.delete("/books/")
assert not_allowed.status_code == 405
assert "POST" in not_allowed.headers["Allow"]
assert client.post("/login").data == b"checking credentials"
assert client.get("/login").data == b"login form"
Source: Flask: Quickstart, HTTP Methods
Two functions on one URL (as in the first two views) are cleaner than one function that branches on request.method,
because each function has its own decorators, its own status code and its own tests. Use the single-function form for a
form page that shows the form on GET and handles it on POST, where both halves share the template.
Pitfalls that come up with methods:
-
A browser enforces cross-origin rules with a
OPTIONSpreflight request. The automatic response only carriesAllow, not theAccess-Control-*headers; adding those is the job of an extension or anafter_requestfunction, covered on the CORS page of this site. -
Do not let a
GETview change state. Browsers, caches and link prefetchers issueGETfreely, so a delete link that is a plainGETis a vulnerability (CSRF) as well as a design error. UsePOSTorDELETEwith a CSRF token for forms, as the forms page of this section explains. -
methods="POST"(a string) raises aTypeError; writemethods=["POST"]. -
Setting
PROVIDE_AUTOMATIC_OPTIONStoFalseremovesOPTIONSfrom theAllowheader too, as the 405 response of anOPTIONSrequest then shows.
Trailing Slashes and Repeated Slashes
Flask follows the behaviour of a file system: a URL that ends in a slash is a directory and a URL without one is a file. The rule is written once, and Werkzeug decides what to do with the other form:
| Rule | Request | Result | Why |
|---|---|---|---|
|
|
200 |
exact match |
|
|
308 redirect to |
the missing slash is added, the method and body are kept |
|
|
200 |
exact match |
|
|
404 |
a file does not get a slash appended |
any, |
both forms |
200 |
no redirect: both URLs serve the page |
from flask import Flask
app = Flask(__name__)
@app.route("/shelves/")
def shelves():
return "shelves"
@app.route("/about")
def about():
return "about"
@app.route("/contact", strict_slashes=False)
def contact():
return "contact"
client = app.test_client()
redirect = client.get("/shelves")
assert redirect.status_code == 308 and redirect.headers["Location"].endswith("/shelves/")
assert client.get("/about/").status_code == 404
assert client.get("/contact").data == b"contact"
assert client.get("/contact/").data == b"contact" # served directly, no redirect
# a whole application can be made lenient (set it before registering rules)
lenient = Flask("lenient")
lenient.url_map.strict_slashes = False
lenient.add_url_rule("/shelves/", "shelves", lambda: "shelves")
assert lenient.test_client().get("/shelves").data == b"shelves"
Source: Werkzeug: werkzeug.routing.Map
The redirect status deserves a note. Older Werkzeug versions answered with 301, which turns a POST into a GET in many
clients and loses the body. Since Werkzeug 2.1 the status is 308, which tells the client to repeat the same method
with the same body. In debug mode Flask raises a FormDataRoutingRedirect error if a non-308 routing redirect would
discard form data, so that the mistake shows up during development.
The other normalization is merge_slashes (on by default): a request for /a//b/ redirects to /a/b/ instead of
failing. With merge_slashes=False such a request is a plain not found (fixed in Werkzeug 3.0.2), and since Werkzeug
3.1.7 the merge covers any number of consecutive slashes.
Which form should you choose? Use a trailing slash for collections (/books/) and no slash for single items
(/books/42), which is the convention in RESTful URL Design. Whichever you choose, link to the canonical form with
url_for() so that clients never see a redirect, and do not turn on strict_slashes=False for an API: two URLs for one
resource defeat caching and make route-based authorization rules harder to write.
Building URLs with url_for
Hard-coded URLs break when a route moves and when the application is mounted under a prefix. url_for() builds the URL
from the endpoint name instead:
from flask import Flask, url_for
app = Flask(__name__)
@app.route("/")
def home():
return "home"
@app.route("/books/<int:book_id>")
def book(book_id):
return str(book_id)
@app.route("/files/<path:name>")
def files(name):
return name
@app.post("/books/<int:book_id>/borrow")
def borrow(book_id):
return "borrowed"
with app.test_request_context():
# values for variables become path parts, unknown names become the query string
assert url_for("book", book_id=42) == "/books/42"
assert url_for("book", book_id=42, format="json") == "/books/42?format=json"
# a list gives a repeated parameter and None values are dropped
assert url_for("home", tag=["a", "b"], missing=None) == "/?tag=a&tag=b"
# anchors, external URLs and schemes
assert url_for("book", book_id=42, _anchor="reviews") == "/books/42#reviews"
assert url_for("book", book_id=42, _external=True) == "http://localhost/books/42"
assert url_for("book", book_id=42, _scheme="https") == "https://localhost/books/42"
# unsafe characters are percent-encoded
assert url_for("files", name="a b/c d.txt") == "/files/a%20b/c%20d.txt"
# the built-in static endpoint
assert url_for("static", filename="css/site.css") == "/static/css/site.css"
# which method the URL is for matters only when the same endpoint has per-method rules
assert url_for("borrow", book_id=1, _method="POST") == "/books/1/borrow"
Source: Flask API: flask.url_for
The arguments, from the signature of Flask.url_for() (which the module-level flask.url_for() calls since Flask 2.2):
| Argument | Meaning |
|---|---|
|
The rule’s name. A leading dot ( |
|
Appends |
|
Builds the URL of the rule that serves this method. It matters only when several rules share an endpoint name with different methods. |
|
Forces the scheme ( |
|
|
other keyword arguments |
Variables of the rule, or query-string parameters when the rule has no such variable. |
Source: Flask API: Flask.url_for
url_for is also available in every Jinja template (the Jinja page of this section covers url_for calls such as
url_for('static', filename=...) for assets), and as app.url_for() when you hold the application object rather than
use a proxy.
Why Not Hard-code URLs
Three reasons that are specific to Flask. A rule can move without touching its callers. The builder URL-encodes the values,
which prevents broken links and injection of extra path parts. And the URL automatically includes the right prefix when the
application is mounted under APPLICATION_ROOT or behind a prefix-stripping proxy, as the next section shows. A view that
returns redirect("/books/" + str(book_id)) has none of these properties.
Building URLs Outside a Request
Without a request, Flask does not know the host. Inside an application context, url_for() is external by default and
needs three config values:
from flask import Flask, url_for
app = Flask(__name__)
@app.route("/books/<int:book_id>")
def book(book_id):
return str(book_id)
# without SERVER_NAME there is no way to build a URL outside a request
with app.app_context():
try:
url_for("book", book_id=1)
except RuntimeError as error:
assert "SERVER_NAME" in str(error)
app.config.update(
SERVER_NAME="bookshelf.example.test",
APPLICATION_ROOT="/library",
PREFERRED_URL_SCHEME="https",
)
with app.app_context():
assert url_for("book", book_id=1) == "https://bookshelf.example.test/library/books/1"
assert url_for("book", book_id=1, _external=False) == "/library/books/1"
Use this for e-mails, background jobs (Celery tasks, scheduled jobs) and command-line scripts, where there is no request
to take the host from. SERVER_NAME is also what flask run uses by default for host and port, and with
subdomain_matching it is the base of every subdomain (see Subdomains and Host Matching). APPLICATION_ROOT and
PREFERRED_URL_SCHEME are used only when there is no request.
External URLs, Proxies and the Host Header
_external=True inside a request takes the scheme, host and port from the request. That is exactly what you want in a
Location header or a link in an e-mail, and exactly what an attacker can influence through the Host header:
from flask import Flask, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
@app.route("/reset")
def reset():
return url_for("reset", _external=True)
client = app.test_client()
# 1. without protection the URL reflects whatever Host the client sent: link poisoning
assert client.get("/reset", headers={"Host": "evil.test"}).data == b"http://evil.test/reset"
# 2. TRUSTED_HOSTS (Flask 3.1) rejects other hosts with a 400 before any view runs
app.config["TRUSTED_HOSTS"] = ["bookshelf.example.test"]
assert client.get("/reset", base_url="http://evil.test").status_code == 400
assert client.get("/reset", base_url="http://bookshelf.example.test").data == b"http://bookshelf.example.test/reset"
# 3. behind a reverse proxy, trust exactly one proxy hop for scheme and host
proxied = Flask("proxied")
proxied.add_url_rule("/reset", "reset", lambda: url_for("reset", _external=True))
proxied.wsgi_app = ProxyFix(proxied.wsgi_app, x_proto=1, x_host=1)
headers = {"Host": "internal:8000", "X-Forwarded-Proto": "https", "X-Forwarded-Host": "books.example.test"}
assert proxied.test_client().get("/reset", headers=headers).data == b"https://books.example.test/reset"
Four rules follow from the example. Set TRUSTED_HOSTS (Flask 3.1) in production so the host is validated for every
request. Use ProxyFix only when a proxy that you control sets the X-Forwarded-* headers, and set each x_* count to
the number of proxies, since a count that is too high lets a client forge the header. If the application is reached by
several names, or the host does not come from the request at all, build absolute URLs from SERVER_NAME or a dedicated
setting of your own. Finally, never build a password-reset link from request.host_url or from a client header without
one of these protections.
When Building Fails
An endpoint name that does not exist, or values that do not satisfy the rule, raise werkzeug.routing.BuildError, whose
message suggests the closest endpoint name. Appending a function to the app.url_build_error_handlers list or overriding handle_url_build_error is possible but rare; fix the call instead.
from flask import Flask, url_for
from werkzeug.routing import BuildError
app = Flask(__name__)
@app.route("/books/<int:book_id>")
def book(book_id):
return str(book_id)
with app.test_request_context():
for bad in (
lambda: url_for("boook", book_id=1), # misspelt endpoint
lambda: url_for("book"), # missing variable
lambda: url_for("book", book_id=1, _method="POST"), # no rule for that method
):
try:
bad()
except BuildError:
pass
else:
raise AssertionError("expected BuildError")
Source: Werkzeug: MapAdapter.build
Calling url_for() from a template that was rendered outside a request context fails with the same
RuntimeError as above unless SERVER_NAME is set, which is a frequent surprise in e-mail templates.
URL Processors and Language Prefixes
Some URLs carry a value that nearly every view needs and no view wants to declare, the classic case being the language
(/en/books/, /es/books/). Flask provides two hooks that move that value out of the views:
-
@app.url_value_preprocessorreceives(endpoint, values)after matching and beforebefore_request. It can remove entries fromvaluesand put them somewhere else, usuallyg, so the view function never sees them. -
@app.url_defaultsreceives(endpoint, values)wheneverurl_for()builds a URL. It can add the missing value, so callers never have to pass the language.
This is the pattern behind the "language prefix in URLs" recipe of the internationalization chapter of the Flask
Framework Cookbook, which stores the language in g from a before_request function. The two hooks below do the same
thing in a form that also works for URL building. The internationalization page of this section uses the stored
g.lang_code in the Flask-Babel locale_selector.
from flask import Flask, abort, g, url_for
app = Flask(__name__)
LANGUAGES = {"en", "es"}
@app.url_value_preprocessor
def pull_lang_code(endpoint, values):
# values is None when routing failed (404, 405), and the key is absent on rules without a prefix
if values and "lang_code" in values:
g.lang_code = values.pop("lang_code")
@app.url_defaults
def add_lang_code(endpoint, values):
if "lang_code" in values or not hasattr(g, "lang_code"):
return
# only rules that really have a lang_code variable get one; static does not
if app.url_map.is_endpoint_expecting(endpoint, "lang_code"):
values["lang_code"] = g.lang_code
@app.before_request
def check_lang_code():
# runs after the preprocessor, so g.lang_code is already set for prefixed URLs
if hasattr(g, "lang_code") and g.lang_code not in LANGUAGES:
abort(404)
@app.route("/<lang_code>/")
def home():
return url_for("books") # no lang_code needed
@app.route("/<lang_code>/books/")
def books():
return f"books in {g.lang_code}; home is {url_for('home')}; static is {url_for('static', filename='a.css')}"
client = app.test_client()
assert client.get("/es/").data == b"/es/books/"
assert client.get("/es/books/").data == b"books in es; home is /es/; static is /static/a.css"
assert client.get("/fr/books/").status_code == 404
assert client.get("/no/such/page/").status_code == 404 # the preprocessor ran with values=None and did not crash
with app.test_request_context("/en/books/"):
app.preprocess_request() # runs the preprocessors for the matched request
assert url_for("home") == "/en/"
assert url_for("home", lang_code="es") == "/es/"
Source: Flask: URL Processors
Details that matter:
-
url_value_preprocessoris called even when no rule matched. In that case both arguments areNone, and a function that indexesvaluesraisesTypeErrorand turns every 404 into a 500. Guard withif values. -
is_endpoint_expecting(endpoint, *arguments)is the supported way to ask whether a rule has a variable. Without that check the hook would addlang_codetostatic, and to every other rule without the variable, where it becomes a query-string parameter. -
The preprocessor modifies the dictionary in place. If it forgets to remove
lang_code, Flask passes it to the view as a keyword argument and the view fails with aTypeError. -
Registered on a blueprint, both hooks apply to that blueprint’s rules only; registered on the application, they apply to all.
before_app_request-style behaviour for blueprints is described on the blueprints page of this section. -
A language prefix on every URL is a design decision. The alternative is to pick the language from the
Accept-Languageheader or a cookie, which keeps the URLs clean but makes pages impossible to cache or share per language.
Subdomains and Host Matching
Both features match on the Host header in addition to the path. They are useful for a per-tenant application
(acme.bookshelf.example), an admin site, or an API on its own host. They are off by default.
Subdomain Matching
Set SERVER_NAME to the bare domain and construct the application with subdomain_matching=True. A rule may then carry
a subdomain, which may be a fixed name or a variable:
from flask import Flask, url_for
app = Flask(__name__, subdomain_matching=True)
app.config["SERVER_NAME"] = "bookshelf.test"
@app.route("/")
def www():
return "marketing site"
@app.route("/", subdomain="admin")
def admin():
return "admin"
@app.route("/", subdomain="<tenant>")
def tenant_home(tenant):
return f"library of {tenant}"
def get(host):
return app.test_client().get("/", base_url=f"http://{host}/")
assert get("bookshelf.test").data == b"marketing site"
assert get("admin.bookshelf.test").data == b"admin"
assert get("acme.bookshelf.test").data == b"library of acme"
with app.test_request_context(base_url="http://admin.bookshelf.test"):
assert url_for("admin") == "/" # same host: relative
assert url_for("tenant_home", tenant="acme") == "http://acme.bookshelf.test/" # other host: absolute
assert url_for("www") == "http://bookshelf.test/"
The rule <tenant> matches any non-empty subdomain, so it must come after the fixed names in the sense that fixed names
win (the router prefers static parts). Pitfalls:
-
A request whose host is not under
SERVER_NAMEis matched with the subdomain<invalid>, Werkzeug prints a warning, and the wildcard rule happily serves it as the tenant<invalid>. Always validate the tenant in the view or inurl_value_preprocessor, and setTRUSTED_HOSTSso that foreign hosts get a 400. -
A subdomain cannot work on
localhostor on an IP address. In development use a real name that resolves to your machine (an entry in the hosts file, or a local DNS name such asbookshelf.test) and pass--hostand--portaccordingly. -
Since Flask 3.1
SERVER_NAMEno longer restrictsTRUSTED_HOSTS. Since Flask 2.3SESSION_COOKIE_DOMAINno longer falls back toSERVER_NAME, so sharing the session cookie between subdomains needs an explicitSESSION_COOKIE_DOMAIN, with the security implications that a cookie visible to every subdomain brings. -
Without
subdomain_matching=True, settingSERVER_NAMEdoes not turn on subdomain routing (Flask 1.0.1 changed that), and asubdomain=rule then never matches.
Host Matching
host_matching=True (a constructor argument, with an optional static_host for the static files) makes every rule match
a full host. It suits an application that serves unrelated domains, and a CDN or static host separate from the page host:
from flask import Flask, url_for
app = Flask(__name__, host_matching=True, static_host="static.bookshelf.test")
@app.route("/", host="www.bookshelf.test")
def www():
return "www"
@app.route("/", host="<library>.bookshelf.test")
def library_home(library):
return f"library {library}"
def get(host):
return app.test_client().get("/", base_url=f"http://{host}/")
assert get("www.bookshelf.test").data == b"www"
assert get("acme.bookshelf.test").data == b"library acme"
with app.test_request_context(base_url="http://www.bookshelf.test"):
assert url_for("static", filename="site.css") == "http://static.bookshelf.test/static/site.css"
Source: Flask API: flask.Flask
When host_matching is on, each rule needs a host, and the static endpoint needs static_host, otherwise the
default static rule has no host to match. flask routes then shows a Host column (Flask 2.3 and later).
Redirects and Safe Next URLs
flask.redirect(location, code=302) returns a response with a Location header. Combined with url_for() it is the
way to finish a successful form post (the post/redirect/get pattern), so the browser never re-submits the form on reload.
Flask 3.2 (unreleased) changes the default status to 303 See Other, which is what the pattern wants; until then pass
code=303 yourself if you need the GET guarantee for a POST.
The risky part is a next parameter that tells the application where to go after login. A redirect to an attacker-chosen
URL is an open redirect and is used in phishing. Accept only local paths:
from urllib.parse import urlsplit
from flask import Flask, redirect, request, url_for
app = Flask(__name__)
def safe_next(target: str | None, fallback: str) -> str:
"""Return target when it is a local path, otherwise fallback."""
if not target or "\\" in target:
return fallback
parts = urlsplit(target)
if parts.scheme or parts.netloc or not target.startswith("/") or target.startswith("//"):
return fallback
return target
@app.route("/")
def home():
return "home"
@app.post("/login")
def login():
# authentication omitted: this page is about the redirect
return redirect(safe_next(request.args.get("next"), url_for("home")), code=303)
client = app.test_client()
assert client.post("/login?next=/books/").headers["Location"] == "/books/"
for hostile in ("https://evil.test/", "//evil.test/", "/\\evil.test", "javascript:alert(1)"):
response = client.post("/login", query_string={"next": hostile})
assert response.status_code == 303 and response.headers["Location"] == "/"
Better still, do not accept a URL at all: accept an endpoint name from a small allow-list and build the URL with
url_for(). Authentication itself is covered on the authentication page of this section.
Inspecting and Testing Routes
flask routes prints every rule of the application:
flask --app bookshelf routes
flask --app bookshelf routes --all-methods --sort rule
Endpoint Methods Rule
---------- ---------------- -----------------------
list_books GET /books/
one DELETE, GET, PUT /books/<int:book_id>
static GET /static/<path:filename>
Source: Flask: Changes
The options of flask routes:
| Option | Meaning |
|---|---|
|
Sort by |
|
Also list the automatic |
|
Show the help text. |
Source: Flask: Changes
With host_matching or subdomain_matching the output gains a Host or Subdomain column. Inside the process the
same information is available as app.url_map (iterate app.url_map.iter_rules()), app.view_functions, and, during a
request, as request.endpoint, request.url_rule (with .methods and .rule), request.view_args and
request.routing_exception.
A routing table is also easy to test without sending requests. url_map.bind() returns a MapAdapter whose match()
returns (endpoint, values) or raises the same exceptions the dispatcher sees:
import pytest
from flask import Flask, url_for
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.routing import RequestRedirect
app = Flask(__name__)
@app.get("/books/")
def list_books():
return ""
@app.route("/books/<int:book_id>", methods=["GET", "PUT"])
def one_book(book_id):
return ""
adapter = app.url_map.bind("localhost")
assert adapter.match("/books/7", method="GET") == ("one_book", {"book_id": 7})
with pytest.raises(NotFound):
adapter.match("/books/seven")
with pytest.raises(MethodNotAllowed):
adapter.match("/books/7", method="DELETE")
with pytest.raises(RequestRedirect):
adapter.match("/books", method="GET")
# the reverse direction: every endpoint a template or a service links to must be buildable
with app.test_request_context():
assert url_for("one_book", book_id=7) == "/books/7"
The adapter checks the routing table; to test the whole request use app.test_client() as the earlier examples do.
Tests that assert on the exact set of routes ({rule.rule for rule in app.url_map.iter_rules()}) are a cheap guard
against a view accidentally becoming public.
RESTful URL Design
The URL structure of an API is part of its contract. The chapter on REST principles in Microservice APIs (Manning) distils the conventions that Flask applications follow, and they map directly onto the features above:
| Principle | Example | How Flask expresses it |
|---|---|---|
URLs name resources with plural nouns, not actions |
|
One rule per collection and one per item. |
Collection and item |
|
Trailing slash on the collection, an |
Nesting only for ownership |
|
|
The method is the verb |
|
|
Filtering, sorting and paging in the query string |
|
|
A created resource is announced |
|
|
Deleting returns no body |
|
|
Unknown or unsupported |
|
Router and error handlers. |
Versions in the URL |
|
A |
PUT replaces the whole resource and is idempotent; PATCH sends only the fields that change. Flask does not enforce
either meaning, so the view has to. A small, complete Bookshelf API shows the conventions together. It keeps its data in
a dictionary, since storage is the subject of the Flask-SQLAlchemy page:
from flask import Flask, abort, jsonify, request, url_for
app = Flask(__name__)
BOOKS: dict[int, dict] = {1: {"id": 1, "title": "A Wizard of Earthsea", "author": "Ursula K. Le Guin"}}
NEXT_ID = 2
@app.get("/api/v1/books/")
def list_books():
author = request.args.get("author")
items = [b for b in BOOKS.values() if author is None or b["author"] == author]
return jsonify(items=items, href=url_for("list_books", author=author))
@app.post("/api/v1/books/")
def create_book():
global NEXT_ID
payload = request.get_json()
if not isinstance(payload, dict) or "title" not in payload:
abort(422, description="title is required")
book = {"id": NEXT_ID, "title": payload["title"], "author": payload.get("author", "")}
BOOKS[NEXT_ID] = book
NEXT_ID += 1
# 201 and the canonical URL of the new resource
return jsonify(book), 201, {"Location": url_for("get_book", book_id=book["id"])}
@app.get("/api/v1/books/<int:book_id>")
def get_book(book_id):
book = BOOKS.get(book_id)
if book is None:
abort(404)
return jsonify(book)
@app.put("/api/v1/books/<int:book_id>")
def replace_book(book_id):
if book_id not in BOOKS:
abort(404)
payload = request.get_json()
BOOKS[book_id] = {"id": book_id, "title": payload["title"], "author": payload.get("author", "")}
return jsonify(BOOKS[book_id])
@app.patch("/api/v1/books/<int:book_id>")
def update_book(book_id):
book = BOOKS.get(book_id) or abort(404)
book.update({k: v for k, v in request.get_json().items() if k in ("title", "author")})
return jsonify(book)
@app.delete("/api/v1/books/<int:book_id>")
def delete_book(book_id):
if BOOKS.pop(book_id, None) is None:
abort(404)
return "", 204 # authentication and authorization are required here in a real API
client = app.test_client()
created = client.post("/api/v1/books/", json={"title": "Dune", "author": "Frank Herbert"})
assert created.status_code == 201
assert created.headers["Location"] == "/api/v1/books/2"
assert client.get("/api/v1/books/", query_string={"author": "Frank Herbert"}).json["items"][0]["title"] == "Dune"
assert client.patch("/api/v1/books/2", json={"title": "Dune Messiah"}).json["title"] == "Dune Messiah"
assert client.put("/api/v1/books/2", json={"title": "Dune"}).json["author"] == ""
assert client.delete("/api/v1/books/2").status_code == 204
assert client.get("/api/v1/books/2").status_code == 404
assert client.post("/api/v1/books/", json={}).status_code == 422
assert client.delete("/api/v1/books/").status_code == 405
Source: Flask: Quickstart
The same URLs are what an OpenAPI description documents, and the building REST APIs page of this section adds
validation, error payloads and extensions on top. The DELETE view above must be protected by authentication in a real
application: Flask routes do not check who is calling. HATEOAS and the Richardson maturity levels are about linking
resources to each other; url_for() is the tool that produces those links without hard-coded paths, as the href link in
the list response does.
Pitfalls and Security
-
Routing does not authorize. A route that is not linked from a page is still reachable. Protect sensitive views with authentication and authorization checks, and never rely on an obscure URL.
-
Converters are not validation of business rules.
<int:book_id>guarantees a number, not that the book exists or that the caller may read it. Look the object up in the view and return 404 (not 403) when you do not want to confirm it exists. -
Identifier enumeration. Sequential integers in URLs make it trivial to try every id. Where it matters, check ownership in the view or use
uuididentifiers (a UUID is not a secret either, only hard to guess). -
Open redirects and Host header poisoning are covered in Redirects and Safe Next URLs and External URLs, Proxies and the Host Header.
-
A
GETthat changes state is a CSRF and prefetching hazard (see HTTP Methods, HEAD and OPTIONS). -
Routes registered after the first request fail with an
AssertionError; registering inside a view or a late hook is a bug. -
Do not add
<path:...>variables near the root without care:/<path:page>swallows every URL that no other rule matches, including typos, and hides 404 errors that monitoring would otherwise show. -
Function names are endpoint names. Two views called
indexin different modules collide; blueprints solve this by prefixing, andendpoint=solves it elsewhere. -
The development server and the debugger are not for production. Routes are the same, but
flask run --debugexposes the Werkzeug debugger, which allows code execution for anyone with the PIN.
What Changed and Where Older Material Is Outdated
The books and many tutorials were written for Flask 1.x and 2.x. The routing items that differ from current behaviour:
| Older material | Current behaviour | Since |
|---|---|---|
|
|
Flask 2.0 |
Trailing-slash redirect is a 301 and a |
Redirect is 308 and keeps method and body |
Werkzeug 2.1 |
Rules are tried in a weighted order, and the order of registration can matter |
State-machine matcher: static parts beat variables; custom converters that match |
Werkzeug 2.2 (automatic detection 2.3) |
|
|
Flask 2.2 |
|
Enable it with |
Flask 1.0.1 and 2.3 |
Registering routes at any time |
Setup methods after the first request raise an |
Flask 2.3 |
|
Calling setup methods on a blueprint after registration is an error |
Flask 2.3 |
A value named |
|
Flask 3.0 |
|
|
Flask 3.1 |
|
Removed in Werkzeug 1.0; check an explicit header or, better, use a separate API route |
Werkzeug 1.0 |
|
|
Flask-SQLAlchemy 3.0 |
Returning 202 with a body for a synchronous |
|
design guidance, RFC 9110 |
|
Default becomes 303 |
Flask 3.2 (unreleased) |
Automatic |
|
Flask 3.2 (unreleased) |
Source: Flask: Changes
The recipe books also use the legacy Model.query API in their view examples; the Flask-SQLAlchemy page of this section shows the
SQLAlchemy 2.0 style with db.session.get() and db.get_or_404().
Summary
Werkzeug owns routing: Flask is a decorator layer that fills one Map and asks it to match and to build. Write routes with
@app.get() and its siblings, pick converters (and write a custom one when a format has a checksum or a list), choose
a slash convention and stick to it, and always build URLs with url_for(). Know the three hidden behaviours (automatic
HEAD and OPTIONS, the 308 trailing-slash redirect, the 404 for a value a converter rejects), validate the Host header
in production, accept only local next URLs, and keep the routing table inspectable with flask routes and tests.