The Art of Routing in Flask

With great flexibility comes great responsibility. Build smarter Flask routes using this in-depth guide.

The Art of Routing in Flask

    It isn't often you find somebody sad or miserable enough to detail the inglorious features of web frameworks, such as routing. This is understandable: flexible route creation is an expected part of any framework, and isn't exactly the first thing that comes to mind when thinking about sexy new tools.

    My personal frustrations flared with the official release of Flask v1.0. Version 1 of Flask resulted in many fundamental changes to the framework functions, which is usually fine with the proper documentation. As it turns out, coming off the heels of a breaking release is a bad time to seek "proper documentation" for any piece of software… not only were there holes in the new docs, but Google search results clung to serving up the older, irrelevant docs as a result of having accrued years of web traffic. Even Kite, the self-proclaimed "smart copilot for programmers," returned blank pages of documentation akin to the blank stare of a clueless Golden Retriever. What's a new Flask dev to do?

    As it turns out, I am apparently both and miserable enough to be the kind of person who writes tutorials about routing. Today we're covering the finer details of how to define and build smarter routes. Because routing is such a fundamental part of MVC frameworks, almost no prior knowledge of Flask is needed to keep up.

    Defining Routes

    Route definition is the simple act of assigning URLs to functions containing view logic. The first logical place to start would be with setting static URLs, something we’re already familiar with:

    from flask import Flask
    app = Flask(__name__)
    
    @app.route("/")
    def home():
        return "Hello World!"
    

    The first line in our route is calling Python decorator. A decorator is a way to extend the functionality of a Python function with another function by “wrapping” said function with logic. Flask contains a built-in wrapper for generating routes in the form of @app.route('/'), where @app is the name of the object containing our Flask app. With this decorator present, Flask knows that the next line (sharing the same level of indentation) will be a function containing route logic.

    The name of our route function is important. It is a best practice to refer to routes by name as opposed to URL. Therefore, a route with a function named home() with henceforth be known as  home, as opposed to “/“.

    Static route functions don’t accept parameters, as they’re triggered by the act of attempting to access a URL. Flask sidesteps this constraint with the presence of “global” objects, such as request() or g. More on these later.

    HTTP Methods

    In addition to accepting the URL of a route as a parameter, Route decorators can accept a second parameter: a list of accepted HTTP Methods. By default, a Flask route accepts all methods on a route (GET, POST, etc). Providing a list of accepted methods is a good way to build constraints into the route for a REST API endpoint which only makes sense in specific contexts.

    from flask import Flask
    app = Flask(__name__)
    
    @app.route("/api/v1/users/", methods=['GET', 'POST', 'PUT'])
    def users():
        ...
    

    Route Variable Rules

    Static route URLs can only get us so far, as modern day web applications are rarely straightforward. Let's say we want to create a profile page for every user that creates an account within our app or dynamically generate article URLs based on the publication date. This is where variable rules come in.

    @app.route('/user/<username>')
    def profile(username):
        ...
        
    @app.route('/<int:year>/<int:month>/<title>')
    def article(year, month, title):
        ...
    

    When defining our route, values within carrot brackets <> indicate a variable; this enables routes to be dynamically generated. Variables can be type-checked by adding a colon followed by the data type constraint. Routes can accept the following variable types:

    • string: Accepts any text without a slash (the default).
    • int: Accepts integers.
    • float: Accepts numerical values containing decimal points.
    • path: Similar to a string, but accepts slashes.

    Unlike static routes, routes created with variable rules do accept parameters, with those parameters being the route variables themselves.

    Types of Route Responses

    Now that we're industry-leading experts in defining route URLs, we'll turn our attention to something a bit more involved: route logic. The first thing we should recap are the types of responses routes can end with. The top 3 common ways a route will conclude will be with generating a page template, providing a response, or redirecting the user somewhere else (we briefly looked over these in part 1).

    Remember: routes conclude with a return statement. Whenever we encounter a return statement in a route, we're telling the function to serve whatever we're returning to the user.

    Rendering a Page Template

    To render a Jinja page template in Flask, we first must import render_template as such: from flask import render_template. We also need to be sure our app has a templates folder, which we set when we create our app:

    from flask import Flask, render_template
    
    app = Flask(__name__,
               template_folder="templates")
    

    With the above, our app knows that calling render_template() in a Flask route will look in our app's /templates folder for the template we pass in. In full, such a route looks like this:

    from flask import Flask, render_template
    app = Flask(__name__,
                template_folder="templates")
    
    @app.route("/")
    def home():
        """Serve homepage template."""
        return render_template("index.html")
    

    render_template() accepts 1 positional argument, which is the name of the template found in our templates folder (in this case, index.html). In addition, we can pass values to our template as keyword arguments. For example, if we want to set the title and content of our template manually, we can do so:

    from flask import Flask, render_template
    app = Flask(__name__,
                template_folder="templates")
    
    @app.route("/")
    def home():
        """Serve homepage template."""
        return render_template('index.html',
                               title='Flask-Login Tutorial.',
                               body="You are now logged in!")
    

    For a more in-depth look into how rendering templates work in Flask, check out our piece about creating Jinja templates.

    Making a Response Object

    If we're building an endpoint intended to respond with information to be used programmatically, serving page templates isn't what we need. Instead, we should look to make_response().

    make_response() allows us to serve up information while also providing a status code (such as 200 or 500), and also allows us to attach headers to said response. In fact, we can even use make_response() in tandem with render_template() if we want to serve up templates with specific headers! Most of the time, make_response() is used to provide information in the form of JSON objects:

    from flask import Flask, make_response
    app = Flask(__name__)
    
    @app.route("/api/v2/test_response")
    def users():
        headers = {"Content-Type": "application/json"}
        return make_response('Test worked!',
                             200,
                             headers=headers)
    

    There are 3 arguments we can pass to make_response(). The first is the body of our response: usually a JSON object or message. Next is a 3-digit integer with the response code we provide to the requester. Finally, we can pass headers.

    Redirecting Users

    The last of our big 3 route resolutions is redirect(). Redirect accepts a string, which will be the path to redirect the user to. This can be a relative path, absolute path, or even an external URL:

    from flask import Flask, redirect
    app = Flask(__name__)
    
    @app.route("/login")
    def login():
        return redirect('/dashboard.html')
    

    But wait! Remember we said its best practice to refer to routes by their names, and not by their URL? This is where we use url_for()! url_for() accepts the name of a route function and will output the URL of the route for us: this way, changing route URLs won't break our code. Below is the correct way to achieve what we did above:

    from flask import Flask, redirect, url_for
    app = Flask(__name__)
    
    @app.route("/login")
    def login():
        return redirect(url_for('dashboard'))
    

    Building Smarter Routes

    Building respectable routes requires us to excel at both the soft-skills of working with web frameworks as well as the hard-skills of simply knowing the tools available to us.

    The basic "soft-skill" of building a route is conceptually easy, but difficult in practice for many newcomers. I'm referring to the basics of MVC: the concept that routes should only contain logic which helps to resolve the response of a route, without the burdens of business logic mixed in. It's a skill that comes from habit and example: a bit out of this post's scope but bears repeating regardless.

    Luckily, the "hard-skills" are a bit more straightforward. Here are a couple of tools essential to building elegant routes.

    The Request Object

    request() is one of the "global" objects we mentioned earlier. It's available to every route and contains all the context of a request made to the said route. Take a look at what things are attached to request which we can access in a route:

    • request.method: Contains the method used to access a route, such as GET or POST. request.method is absolutely essential for building smart routes: we can use this logic to have one route serve multiple different responses depending on what method was used to call said route. This is how REST APIs provide different results on a GET request versus a POST request ( if request.method == 'POST': can open a block only pertaining to POST requests in our route).
    • request.args: Contains the query-string parameters of a request that hit our route. If we're building an endpoint that accepts a url parameter, for example, we can get this from the request as request.args.get('url’).
    • request.data: Returns the body of an object posted to a route.
    • request.form: If a user hits this route as a result of form submission, request.form is our way of accessing the information the form posted. For example, to fetch the provided username of a submitted form, request.form['username'] is used.
    • request.headers: Contains the headers of a request.

    Here's an example I took from Flask-Login which utilizes most of these things in a single route. It's not important to memorize all these things, but it's good to see how powerful and versatile we can make a single route simply by using the properties of request():

    ...
    
    @app.route('/signup', methods=['GET', 'POST'])
    def signup_page():
        """User sign-up page."""
        signup_form = SignupForm(request.form)
        # POST: Sign user in
        if request.method == 'POST':
            if signup_form.validate():
                # Get Form Fields
                name = request.form.get('name')
                email = request.form.get('email')
                password = request.form.get('password')
                website = request.form.get('website')
                existing_user = User.query.filter_by(email=email).first()
                if existing_user is None:
                    user = User(name=name,
                                email=email,
                                password=generate_password_hash(password, method='sha256'),
                                website=website)
                    db.session.add(user)
                    db.session.commit()
                    login_user(user)
                    return redirect(url_for('main_bp.dashboard'))
                flash('A user already exists with that email address.')
                return redirect(url_for('auth_bp.signup_page'))
        # GET: Serve Sign-up page
        return render_template('/signup.html',
                               title='Create an Account | Flask-Login Tutorial.',
                               form=SignupForm(),
                               template='signup-page',
                               body="Sign up for a user account.")
    

    The g Object

    Let's say we want a route to access a value which isn't contained in our request() object. We already know we can't pass parameters to routes traditionally: this is where we can use Flask's g. "G" stands for "global," which isn't a great name since we're restricted by the application context, but that's neither here nor there. The gist is that g is an object we can attach values to.

    We assign values to g as such:

    from flask import g
    
    
    def get_test_value():
        if 'test_value' not in g:
            g.test_value = 'This is a value'
        return g.test_value
    

    Once set, accessing g.test_value will give us 'This is a value', even inside a route.

    The preferred way of purging values from g is by using .pop():

    from flask import g
    
    
    @app.teardown_testvalue
    def remove_test_value():
       test_value = g.pop('test_value', None)
    

    It's best not to dwell on g for too long. It is useful in some situations, but can quickly become confusing and unnecessary: most of what we need can be handled by the request() object.

    Error Routes

    We can customize error routes in Flask by overriding the routes associated with error codes. Here's an example of a custom 404 page:

    ...
    
    @app.errorhandler(404)
    def notfound():
        """Serve 404 template."""
        return make_response(render_template("404.html"), 404)
    

    Aha! See how we used make_response() in conjunction with render_template()? Not only did we serve up a custom template, but we also provided the correct error message to the browser. We're coming full-circle!

    Extensions with Route Decorators

    Lastly, I can't let you go without a least mentioning some of the awesome decorators provided by Flask's powerful plugins. These are a testament to how powerful Flask routes can become with the addition of custom decorators:

    • Flask-Login & @login_required: Slap this before any route to immediately protect it from being accessed from logged-out users. If the user is logged in, @login_required lets them in accordingly. More on Flask-Login here.
    • Flask-Admin & @expose: Allows views to be created for a custom admin panel.
    • Flask-Cache & @cache: Cache routes for a set period of time: @cache.cached(timeout=50).

    There's a lot we can do with routes in Flask. With the tools outlined here, anybody should have all that they need to start building sexy and clean route logic.

    Todd Birchard's' avatar
    New York City Website
    Product manager turned engineer with an ongoing identity crisis. Breaks everything before learning best practices. Completely normal and emotionally stable.

    Product manager turned engineer with an ongoing identity crisis. Breaks everything before learning best practices. Completely normal and emotionally stable.