Python: Building a Web App with Flask (Routes, Templates, Forms)

Your Python script prints to a terminal only you can see. Put Flask in front of it and the same code starts answering real browsers on real URLs. This Python Flask tutorial takes you from an empty folder to a multi-page app: routes, Jinja2 templates, a form that saves what visitors type, and a project structure worth copying into your next build.

“Flask is a micro framework, not a micro application.”

Armin Ronacher, Flask creator

Last Updated: July 2026 | Tested on: Python 3.14.6, Flask 3.1.3, Jinja2 3.1.6 | Difficulty: Intermediate | Reading Time: 16 minutes

Say you want to put a page on the internet. The browser speaks HTTP (HyperText Transfer Protocol), you speak Python, and in between sits a pile of plumbing nobody enjoys writing: parsing the raw request, matching the URL to the right chunk of code, turning your data into HTML, setting the right headers on the way back. A web framework writes that plumbing for you. Flask is the smallest popular one for Python, and this python flask tutorial gets you from an empty folder to a working multi-page app.

Think of it like ordering at a restaurant. You do not walk into the kitchen, light the stove, and wash the dishes. You tell the waiter what you want and the food shows up. Flask is that waiter. You say “when someone visits /about, run this function” and Flask handles every step between the browser and your code.

Flask calls itself a “micro” framework. That does not mean it makes tiny apps. It means it ships with just the essentials (routing, templates, and a development server) and stays out of your way for everything else. Database access, login systems, form validation: you bolt those on with extensions only when you actually need them. This is the opposite of Django, which arrives fully loaded with a database layer, an admin panel, and auth already wired up. Flask hands you a blank canvas. Django hands you a furnished apartment. Neither one is better. It comes down to whether you want to pick your own furniture or move in today.

A student from one of my workshops, Niranjan, built his first Flask app over a weekend and had it running on a cheap VPS (Virtual Private Server) by Monday. The learning curve really is that gentle. By the end of this post you will have built multiple pages, an HTML form that saves what people type, shared layout through template inheritance, and a project structure you can copy into your next app.

Flask Applicationrender_template()Browser RequestGET /dashboardWSGI Server(Werkzeug / Gunicorn)URL Router@app.route(‘/dashboard’)View Functiondashboard()Jinja2 Templatedashboard.htmlHTTP ResponseHTML pageBrowserrenders pagePython Flask: How a Request Flows from Browser to WSGI, Router, View, and Jinja2

The diagram traces one Flask request from start to finish. The browser sends an HTTP request, the Web Server Gateway Interface (WSGI) server (Werkzeug in development, Gunicorn in production) receives it, Flask’s URL router matches it to a view function, that function pulls together some data and renders a Jinja2 template, and the finished HTML travels back to the browser. Every box in this pipeline is a piece you control: routes live in your Python file, templates live in the templates/ folder, and static assets live in static/. Knowing the order of these stages is also how you debug. When a page breaks, you walk down the pipeline and find the box where it went wrong.

Prerequisites

You should be comfortable with Functions, Dictionaries, and Virtual Environments. A little HTML helps too, since you will be writing some by hand. You need Python 3.14.6 installed and one terminal command to add Flask, which the next section covers.

Install & Hello World

📄 Terminal: create a project folder and install Flask

mkdir flask_app && cd flask_app
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install flask

📄 app.py: the smallest Flask app that does something

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "

Hello from Flask!

Rahul's first web app.

" @app.route("/about") def about(): return "

About

Built with Flask and Python 3.14.6.

" if __name__ == "__main__": app.run(debug=True)

📄 Terminal: run the app

python app.py
# then open http://127.0.0.1:5000 in your browser

▶ Output

 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 119-281-807

What happened here: A route is like the nameplate on a doorbell panel: press the button labelled “/”, and the flat behind it answers. @app.route("/") tells Flask that when a browser asks for the root URL, it should run the home() function and send back whatever that function returns as the HTTP response. The @app.route("/about") line wires up a second page the same way. Because we passed debug=True, Flask also starts the auto-reloader: change your code, hit save, and the server restarts on its own.

That is the meaning of the * Restarting with stat line. The big WARNING is Flask reminding you that this built-in server is for your laptop only, never for real traffic. That Debugger PIN will be different on your machine, and the exact 127.0.0.1:5000 address is just localhost on port 5000.

Templates: Jinja2 HTML Rendering

Stuffing whole HTML pages into Python strings gets ugly fast. Imagine building a table of 50 students by gluing tags together with +. No thanks. This is where Jinja2 comes in. Jinja2 is the template engine that ships with Flask, and a template is just an HTML file with two extra bits of syntax: {{ }} drops a Python value into the page, and {% %} runs logic like loops and if-statements. Templates are the point where a Python Flask project stops feeling like a script and starts feeling like a website.

Think of it like a fill-in-the-blanks form letter. The HTML is the printed letter, and the {{ }} slots are the blanks Flask fills in with real data before mailing it to the browser. In the example below, the data is a scores list for three students named Viraj, Anvi, and Aditi, and the template turns each one into a table row.

📄 app.py: passing data into a template

from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def home():
    students = [
        {"name": "Viraj Patil", "score": 95},
        {"name": "Anvi", "score": 88},
        {"name": "Aditi", "score": 92},
    ]
    return render_template("index.html", title="Dashboard", students=students)

if __name__ == "__main__":
    app.run(debug=True)

📄 templates/index.html: the Jinja2 template it renders




    {{ title }}


    

{{ title }}

{% for student in students %} {% endfor %}
NameScore
{{ student.name }} {{ student.score }}

Total students: {{ students | length }}

▶ Output (the <body> Jinja2 renders for /)

<body>
    <h1>Dashboard</h1>
    <table border="1">
        <tr><th>Name</th><th>Score</th></tr>

        <tr>
            <td>Viraj Patil</td>
            <td>95</td>
        </tr>

        <tr>
            <td>Anvi</td>
            <td>88</td>
        </tr>

        <tr>
            <td>Aditi</td>
            <td>92</td>
        </tr>

    </table>
    <p>Total students: 3</p>
</body>

What happened here: render_template loaded index.html, ran it through Jinja2, and handed the finished HTML to the browser. Three pieces did the work. {{ title }} dropped the string “Dashboard” into the page. The {% for student in students %} loop ran once per student and printed a table row for each. {{ students | length }} piped the list through Jinja2’s built-in length filter and got back 3. Notice the data lives in Python and the layout lives in HTML, with Jinja2 stitching them together. Templates live in a folder named templates/ by default, so Flask knows where to look without you telling it.

Handling Forms with GET and POST

So far the data has only flowed one way: from your Python code out to the browser. Forms reverse that. The visitor types something, hits submit, and now the browser sends data back to you. The same page often does double duty here. When you visit it with a normal click (a GET request), Flask shows you the empty form. When you submit that form (a POST request), Flask reads what you typed and saves it. One URL, two behaviors, decided by the HTTP method.

Think of a comment card at a restaurant: a blank card sitting on the table is the GET, and dropping the filled-in card in the box is the POST. Nearly every form you write in a Python Flask app follows this same two-step rhythm.

📄 app.py: a guestbook form that accepts user input

from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)
messages = []  # In production, use a database

@app.route("/")
def home():
    return render_template("guestbook.html", messages=messages)

@app.route("/sign", methods=["GET", "POST"])
def sign():
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        message = request.form.get("message", "").strip()
        if name and message:
            messages.append({"name": name, "message": message})
            return redirect(url_for("home"))
    return render_template("sign.html")

if __name__ == "__main__":
    app.run(debug=True)

📄 templates/sign.html: the HTML form




    

Sign the Guestbook



Back to guestbook

📄 templates/guestbook.html: the home page that lists the messages




    

Guestbook

    {% for m in messages %}
  • {{ m.name }}: {{ m.message }}
  • {% endfor %}
Sign the guestbook

To prove the round trip without spinning up a live server, Flask gives you app.test_client(), a fake browser you can drive from Python. Here we sign the guestbook as Niranjan, the workshop student you met earlier, check the response, then load the home page to see the saved entry.

▶ Output (driving the app with app.test_client())

POST /sign -> 302 Location: /
GET / -> 200
<!DOCTYPE html>
<html>
<body>
    <h1>Guestbook</h1>
    <ul>

        <li><strong>Niranjan</strong>: First post!</li>

    </ul>
    <a href="/sign">Sign the guestbook</a>
</body>
</html>

What happened here: request.method tells you whether the form was submitted (POST) or just displayed (GET), so one view handles both. request.form is a dictionary of what the user typed, and .get("name", "") reads a field safely even when it is missing. After saving, redirect(url_for("home")) sends a 302 response that tells the browser to go load the home page. That is why the output shows 302 Location: / first, then a fresh 200 for the guestbook with Niranjan’s message already in it.

This bounce is the Post/Redirect/Get pattern, and it earns its keep. Without it, the form result stays on the POST page, so if the user hits refresh the browser resubmits the form and you get a duplicate entry. Redirecting after a POST means refresh just reloads a plain GET page, nothing gets posted twice. Notice url_for("home") builds the URL from the function name rather than a hardcoded "/", so if you change the route later, every link still points to the right place.

Template Inheritance for DRY HTML

Every page on your site shares the same skeleton: the same navigation bar, the same footer, the same stylesheet link. Copy that skeleton into ten templates and you have signed up to edit ten files every time the menu changes. Template inheritance fixes this. You write the shared layout once in a parent template, leave a few labelled holes in it, and each page fills in only its own holes.

It is the same idea as a company letterhead. The logo, address, and footer are printed once, and each letter just types its own paragraph into the middle. In the example below, a developer named Prathamesh signs the footer once in the base layout, and every page that extends it carries his credit automatically.

📄 templates/base.html: the shared layout, with two empty blocks




    
    {% block title %}My App{% endblock %}
    


    
    
{% block content %}{% endblock %}
Built by Prathamesh with Flask

📄 templates/index.html: extends the base and fills the blocks

{% extends "base.html" %}

{% block title %}Dashboard{% endblock %}

{% block content %}

Student Scores

    {% for s in students %}
  • {{ s.name }}: {{ s.score }}
  • {% endfor %}
{% endblock %}

▶ Output (the page Flask renders for /, blocks filled in)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Dashboard</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <nav>
        <a href="/">Home</a> |
        <a href="/about">About</a>
    </nav>
    <main>

<h1>Student Scores</h1>
<ul>

    <li>Viraj Patil: 95</li>

    <li>Anvi: 88</li>

    <li>Aditi: 92</li>

</ul>

    </main>
    <footer>Built by Prathamesh with Flask</footer>
</body>
</html>

What happened here: {% extends "base.html" %} tells Jinja2 to start from the parent layout, then the child only overrides the named blocks. {% block title %}Dashboard{% endblock %} replaced the parent’s default title, and {% block content %} dropped the student list into the middle. Look at the output: the <nav> and <footer> came straight from base.html even though the child never mentions them. Notice too that url_for('home') turned into / and url_for('static', filename='style.css') turned into /static/style.css. Jinja2 built those links for you. Change the navigation once in base.html and every page that extends it updates at the same time. That is the whole payoff: write the boilerplate once, never copy and paste a footer again.

Static Files & Project Structure

📄 Recommended Flask project structure

flask_app/
    app.py
    templates/
        base.html
        index.html
        sign.html
    static/
        style.css
        logo.png
    venv/

This is the layout my colleague Vinay reaches for on every new Python Flask project, and it is worth memorizing because Flask expects it. Templates that you render with Jinja2 live in templates/. Files the browser downloads as-is (CSS, JavaScript, images) live in static/. Flask looks in those two folders by name, so you do not configure paths, you just put files in the right place. Reference a static file in a template with {{ url_for('static', filename='style.css') }}, which prints /static/style.css like you saw in the inheritance output above.

Why not just hardcode /static/style.css yourself? Because url_for still builds the right link if you later mount the app under a subpath or change the static folder configuration, while every hardcoded path silently breaks. Think of templates/ and static/ like the two drawers of a desk: one holds the documents you fill in before sending, the other holds the printed handouts you pass along untouched. Flask serves static files for you during development.

In production you hand that job to Nginx or a CDN (Content Delivery Network), which are built to serve files fast.

Common Mistakes

Two mistakes show up in almost every first Flask deploy. Both feel harmless on your laptop and both bite you the moment real users arrive.

📄 ❌ Mistake: Running with debug=True in production

# NEVER do this on a public server
app.run(debug=True, host="0.0.0.0")  # Debug mode exposes a code executor

📄 ✅ Fix: Use environment variables

import os
app.run(debug=os.getenv("FLASK_DEBUG", "0") == "1")

📄 ❌ Mistake: Storing data in global variables

messages = []  # Lost when the server restarts, not thread-safe

📄 ✅ Fix: Use a database (SQLite, PostgreSQL)

# Even SQLite is better than an in-memory list
# See the SQLite tutorial for the full pattern

Why these matter: Debug mode runs an interactive debugger right in the browser, and that debugger can execute arbitrary Python. Handy for you, a free shell for an attacker, so it must never be on in production. Reading the flag from an environment variable keeps it off by default and lets you flip it on only where it is safe. The global messages list has a quieter problem: it lives in memory, so every restart wipes it, and under a real server running several worker processes each worker keeps its own copy, so one user’s entry may simply vanish for the next. A database fixes both. It survives restarts and gives every worker the same shared source of truth.

Wrapping Up

You went from an empty folder to a working multi-page Python Flask app: routes that map URLs to functions, Jinja2 templates that keep data in Python and layout in HTML, a form that handles GET and POST in one view with the Post/Redirect/Get pattern, template inheritance so the shared layout lives in one file, and the standard templates/ plus static/ project structure. Add the two habits from the mistakes section (debug off in production, real storage instead of globals) and you have everything a small production app needs except a database.

Next we build on the same HTTP foundations with FastAPI, the async-first framework that has become the default choice for pure APIs, and you will see how much of your Flask knowledge transfers directly. For the full learning path from beginner to AI/ML, browse the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is Flask used for?

Python Flask builds web applications and APIs. Common uses: dashboards, REST APIs, prototypes, internal tools, and microservices. Companies like Netflix, Lyft, and Pinterest use Flask for parts of their stack in production.

Is Flask better than Django?

Neither is universally better. Flask is minimal and flexible, so you choose your own database, auth, and admin tools. Django is batteries-included with an ORM, admin panel, auth, and forms built in. Use Flask for APIs and small apps, Django for large full-stack apps.

How do I deploy a Flask app?

Use Gunicorn (Linux) or Waitress (Windows) as the WSGI server behind Nginx. Never use app.run() in production, because the built-in server is single-threaded and not secure. Cloud options: Railway, Render, and AWS Elastic Beanstalk.

What is Jinja2?

Jinja2 is the template engine Flask uses for HTML rendering. It lets you insert Python variables ({{ name }}), use conditionals ({% if %}), loops ({% for %}), and template inheritance in HTML files.

How do I connect Flask to a database?

Use Flask-SQLAlchemy (ORM) or raw SQLite with the sqlite3 module. See the SQLAlchemy tutorial for the ORM pattern and the SQLite tutorial for raw SQL.

Try It Yourself

Build a simple to-do list web app with Flask. Requirements: add tasks, mark tasks as complete, delete tasks, and persist data to a JSON file. Use template inheritance with a base layout and at least two pages (task list and add task form).

Interview Questions on Python Flask

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: What does the Post/Redirect/Get pattern solve, and how do you implement it in Flask?

Without it, the response to a form submission stays on the POST request, so hitting refresh makes the browser resubmit the form and you get duplicate entries (the browser even warns with a “resubmit form?” dialog). The fix is to answer every successful POST with redirect(url_for("home")), which sends a 302 that makes the browser issue a fresh GET. After that, refresh only repeats the harmless GET. In Flask this is two lines: save the data, then return the redirect instead of rendering a template directly.

Q: You deployed a Flask guestbook with Gunicorn running 4 workers, and users report their entries randomly appear and disappear between refreshes. What is happening?

The app is storing entries in a module-level Python list, and each Gunicorn worker is a separate process with its own copy of that list. A POST lands on worker 2, so only worker 2’s list has the entry; the next GET may land on worker 3, which has never seen it. The entries also vanish completely on every restart because they only live in memory. The fix is shared persistent storage: even SQLite is enough for a small app, and PostgreSQL for anything bigger.

Q: Why is running debug=True on a public server a security hole, not just bad practice?

Debug mode enables the Werkzeug interactive debugger: when an unhandled exception occurs, the error page includes a live Python console that can execute arbitrary code on the server. The console is PIN-protected, but the PIN can leak or be brute-forced, and anyone who gets past it has a remote shell with your app’s permissions. Debug mode also shows full tracebacks and source code to visitors. The safe pattern is to read the flag from an environment variable so it defaults to off, for example debug=os.getenv("FLASK_DEBUG", "0") == "1".

Q: Why use url_for(“home”) instead of hardcoding “/” in redirects and templates?

url_for builds URLs from the view function’s name, so the route decorator stays the single source of truth. If you later change @app.route("/") to @app.route("/dashboard"), every url_for("home") call updates automatically while hardcoded strings silently break. It also handles URL parameters (url_for("profile", username="anvi")), escapes them correctly, and keeps working if the app is mounted under a URL prefix behind a proxy.

Q: A new route renders fine, but the page raises jinja2.exceptions.TemplateNotFound: index.html. What do you check first?

First check that the file actually sits in a folder named exactly templates/ next to your app module, because that is the only place Flask looks by default. Common causes: the folder is misspelled (template/), the file is in the project root instead of the folder, or you launched the app from a different working directory in a layout where the module path does not match. Also confirm the name passed to render_template("index.html") matches the file exactly, including case, since production Linux servers are case-sensitive even when your Windows laptop is not.

Q: How does one Flask view function serve both the empty form and the form submission?

Register the route with methods=["GET", "POST"], then branch on request.method inside the view. On GET you just render the form template; on POST you read the submitted fields from request.form (using .get("name", "") so a missing field cannot raise a KeyError), validate them, save, and redirect. Note that request.form only holds POST body data; query-string values like ?page=2 live in request.args, and mixing the two up is a classic beginner bug.

Further reading: for the full reference, see Flask documentation.

Previous: Python: Web Scraping with Selenium

Next: Python: Building REST APIs with FastAPI (Routes, Pydantic, Async)

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *