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 a MapAdapter) and calls match(). The result is a rule, which becomes request.url_rule, plus a dictionary of converted values, which becomes request.view_args. request.endpoint is the rule’s endpoint name.

  • Building (endpoint to path): url_for() asks the same adapter to build() 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

NotFound

404

No rule matches the path (or a converter rejected the value).

MethodNotAllowed

405

A rule matches the path but not the method; the Allow header lists the methods that would work.

RequestRedirect

308

The path is right but not canonical: a missing or extra trailing slash, repeated slashes, or a URL that duplicates a defaults= rule.

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

flowchart TD req["Request: method and path"] --> bind["Map is bound to the request host (MapAdapter)"] bind --> match{"match()"} match -- "no rule fits" --> nf["NotFound stored on request.routing_exception"] match -- "path fits, method does not" --> mna["MethodNotAllowed with Allow header"] match -- "canonical form differs" --> rr["RequestRedirect, status 308"] match -- "rule and converted values" --> ok["request.url_rule, request.view_args, request.endpoint"] ok --> pre["url_value_preprocessor functions"] nf --> pre mna --> pre rr --> pre pre --> before["before_request functions"] before --> disp["dispatch_request"] disp --> chk{"routing_exception set?"} chk -- yes --> rais["Raise it; error handlers produce the 404, 405 or redirect"] chk -- no --> opt{"OPTIONS and automatic?"} opt -- yes --> auto["Automatic OPTIONS response"] opt -- no --> view["Call the view with view_args as keyword arguments"]
Figure 1. How a request path becomes a view call

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"]

@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 forgets functools.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

methods

A list of HTTP methods (any case; Flask upper-cases them). A bare string raises a TypeError. Default ["GET"].

endpoint

The rule’s name. Defaults to the view function’s name.

defaults

A dictionary of default values for variables that do not appear in the URL (see Default Values and Pagination URLs).

strict_slashes

Per-rule override of the trailing-slash behaviour (see Trailing Slashes and Repeated Slashes).

merge_slashes

Per-rule override for collapsing repeated slashes in the incoming path. True by default on the map.

subdomain

Match only this subdomain (needs subdomain_matching, see Subdomains and Host Matching).

host

Match only this host (needs host_matching=True, see Subdomains and Host Matching).

redirect_to

A URL, or a callable returning one, to redirect to with status 308 instead of calling a view.

build_only

The rule is used by url_for() only and never matches an incoming request.

alias

Marks the rule as an alias of another with the same endpoint and defaults; the alias redirects to the canonical rule.

provide_automatic_options

Flask only. Force or disable the automatic OPTIONS response for this rule (see HTTP Methods, HEAD and OPTIONS).

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"

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

string (default)

Any text without a slash, at least one character. Python type str.

minlength, maxlength, length

int

Unsigned decimal digits. Type int. A negative number does not match.

fixed_digits, min, max, signed

float

Unsigned floating point with a decimal point (1.5 matches, 1 does not). Type float.

min, max, signed

path

Like string but also matches slashes. Type str.

none

uuid

A UUID in the standard dashed form. Type uuid.UUID.

none

any

One of the fixed strings given as arguments. Type str.

the allowed values

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"

Three consequences of these rules catch people out:

  • A value that fails conversion is a 404, not a 400. /books/seven and /books/-1 simply do not exist as far as the router is concerned. Pass signed=True if negative numbers must be accepted.

  • string never contains a slash. Use path for file names with directories, and remember that the value is still untrusted: use send_from_directory() or werkzeug.utils.safe_join() before touching the file system.

  • int has no upper bound unless you give max. 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"

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"

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

regex

The pattern the path part must match. It decides whether the rule matches at all, so write it as strictly as the format allows.

to_python(self, value)

Called after a match. Returns the object the view receives. Raise werkzeug.routing.ValidationError to reject the value, which turns into a 404.

to_url(self, value)

Called by url_for(). Must return a str; BaseConverter.to_url URL-quotes str(value).

part_isolating

True when the regex can never match a slash. Werkzeug 2.3 and later sets it to False automatically if the converter’s own regex contains a /; set it by hand when the pattern matches slashes in a way that is not obvious from the text.

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"

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"

Things to keep in mind when writing converters:

  • Keep to_python cheap 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 any before_request function, 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 ValidationError raised in to_python is an ordinary unhandled error and gives a 500.

  • If the pattern must match a slash, to_python receives the whole slash-containing text, and part_isolating must be False. 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 a regex written 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:

  • HEAD is added automatically to every rule that allows GET. The view runs as for GET and the body is dropped from the response.

  • OPTIONS is added automatically and answered by Flask itself with an Allow header, unless the rule lists OPTIONS explicitly or the view sets provide_automatic_options = False. The application-wide switch is the PROVIDE_AUTOMATIC_OPTIONS config key (default True).

  • A path that matches a rule but not the method returns 405 Method Not Allowed with an Allow header 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 for DELETE lists 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"

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 OPTIONS preflight request. The automatic response only carries Allow, not the Access-Control-* headers; adding those is the job of an extension or an after_request function, covered on the CORS page of this site.

  • Do not let a GET view change state. Browsers, caches and link prefetchers issue GET freely, so a delete link that is a plain GET is a vulnerability (CSRF) as well as a design error. Use POST or DELETE with a CSRF token for forms, as the forms page of this section explains.

  • methods="POST" (a string) raises a TypeError; write methods=["POST"].

  • Setting PROVIDE_AUTOMATIC_OPTIONS to False removes OPTIONS from the Allow header too, as the 405 response of an OPTIONS request 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

/shelves/

/shelves/

200

exact match

/shelves/

/shelves

308 redirect to /shelves/

the missing slash is added, the method and body are kept

/about

/about

200

exact match

/about

/about/

404

a file does not get a slash appended

any, strict_slashes=False

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"

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"

The arguments, from the signature of Flask.url_for() (which the module-level flask.url_for() calls since Flask 2.2):

Argument Meaning

endpoint

The rule’s name. A leading dot (".list_books") means "in the blueprint that is handling this request" and is the way to link inside a blueprint without repeating its name.

_anchor

Appends #anchor to the URL.

_method

Builds the URL of the rule that serves this method. It matters only when several rules share an endpoint name with different methods.

_scheme

Forces the scheme (https). If _external is not given, a scheme makes the URL external.

_external

True makes an absolute URL with scheme and host; False makes a path even outside a request. Default: relative inside a request, absolute outside one.

other keyword arguments

Variables of the rule, or query-string parameters when the rule has no such variable.

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")

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_preprocessor receives (endpoint, values) after matching and before before_request. It can remove entries from values and put them somewhere else, usually g, so the view function never sees them.

  • @app.url_defaults receives (endpoint, values) whenever url_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/"

Details that matter:

  • url_value_preprocessor is called even when no rule matched. In that case both arguments are None, and a function that indexes values raises TypeError and turns every 404 into a 500. Guard with if values.

  • is_endpoint_expecting(endpoint, *arguments) is the supported way to ask whether a rule has a variable. Without that check the hook would add lang_code to static, 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 a TypeError.

  • 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-Language header 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_NAME is 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 in url_value_preprocessor, and set TRUSTED_HOSTS so that foreign hosts get a 400.

  • A subdomain cannot work on localhost or 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 as bookshelf.test) and pass --host and --port accordingly.

  • Since Flask 3.1 SERVER_NAME no longer restricts TRUSTED_HOSTS. Since Flask 2.3 SESSION_COOKIE_DOMAIN no longer falls back to SERVER_NAME, so sharing the session cookie between subdomains needs an explicit SESSION_COOKIE_DOMAIN, with the security implications that a cookie visible to every subdomain brings.

  • Without subdomain_matching=True, setting SERVER_NAME does not turn on subdomain routing (Flask 1.0.1 changed that), and a subdomain= 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"

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

-s, --sort

Sort by endpoint (default), methods, domain, rule or match. match is the order in which Flask tries the rules when dispatching a request.

--all-methods

Also list the automatic HEAD and OPTIONS methods.

--help

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

/books/, not /getBooks or /books/delete

One rule per collection and one per item.

Collection and item

/books/ and /books/42

Trailing slash on the collection, an int converter on the item. See Trailing Slashes and Repeated Slashes.

Nesting only for ownership

/authors/7/books/

<int:author_id> in the rule; keep nesting to two levels.

The method is the verb

GET reads, POST creates, PUT replaces, PATCH changes parts, DELETE removes

@app.get, @app.post, @app.put, @app.patch, @app.delete.

Filtering, sorting and paging in the query string

/books/?author=7&sort=title&page=2

request.args, no routing involved.

A created resource is announced

201 Created with a Location header

url_for("get_book", book_id=...).

Deleting returns no body

204 No Content; 404 when it is gone

return "", 204. The books vary here: one returns 202 with a body for a synchronous delete, which is wrong.

Unknown or unsupported

404, 405 with Allow, 422 for a body that parses but is invalid

Router and error handlers.

Versions in the URL

/api/v1/books/

A url_prefix on a blueprint, which the blueprints page covers.

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

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 uuid identifiers (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 GET that 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 index in different modules collide; blueprints solve this by prefixing, and endpoint= solves it elsewhere.

  • The development server and the debugger are not for production. Routes are the same, but flask run --debug exposes 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

@app.route("/x", methods=["GET"]) for every verb

@app.get, @app.post, @app.put, @app.patch, @app.delete shortcuts

Flask 2.0

Trailing-slash redirect is a 301 and a POST to the wrong form loses its body

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 / must set part_isolating = False (detected automatically for a regex in the class body)

Werkzeug 2.2 (automatic detection 2.3)

from flask import url_for is a separate implementation

flask.url_for calls app.url_for, which can also be called directly

Flask 2.2

SERVER_NAME implicitly enables subdomain routing; SESSION_COOKIE_DOMAIN falls back to it

Enable it with subdomain_matching=True; the cookie domain has no fallback

Flask 1.0.1 and 2.3

Registering routes at any time

Setup methods after the first request raise an AssertionError

Flask 2.3

@app.route handlers can be registered in blueprint modules after registration

Calling setup methods on a blueprint after registration is an error

Flask 2.3

A value named self could not be passed to url_for

url_for takes its first argument positionally, so any name is allowed

Flask 3.0

SERVER_NAME restricts the accepted hosts

TRUSTED_HOSTS is the host check; SERVER_NAME no longer restricts it

Flask 3.1

request.is_xhr to detect AJAX calls (the Flask Framework Cookbook)

Removed in Werkzeug 1.0; check an explicit header or, better, use a separate API route

Werkzeug 1.0

Query.paginate(page, 10) with positional arguments (the Flask Framework Cookbook pagination recipe)

db.paginate() with keyword arguments in Flask-SQLAlchemy 3

Flask-SQLAlchemy 3.0

Returning 202 with a body for a synchronous DELETE (Full-Stack Flask and React)

204 No Content, or 200 with a representation

design guidance, RFC 9110

redirect() returns 302

Default becomes 303

Flask 3.2 (unreleased)

Automatic OPTIONS can only be disabled

provide_automatic_options=True forces it per view; app.query adds the QUERY method

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.