Python: Zero to Hero
Home/The Python Ecosystem
Share

Chapter 40: Web Development with Flask

Flask is a web framework for Python. It lets you turn Python functions into web pages and APIs with just a few lines of code.

You already know Python. With Flask, that's enough to build a real website.

What is Flask?

Flask is called a "micro-framework." Micro doesn't mean small or weak — it means Flask gives you the essentials and lets you add only what you need. No mandatory ORM, no required project structure, no magic configuration files.

Compare that to Django, which comes with everything built-in. Flask is lighter, easier to learn, and perfect for APIs and small-to-medium apps.

pip install flask

Your First Web App

# app.py
from flask import Flask

app = Flask(__name__)


@app.route("/")
def home():
    return "Hello, World!"


@app.route("/about")
def about():
    return "This is the about page."


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

Run it:

python app.py

Open http://127.0.0.1:5000/ in your browser. You'll see "Hello, World!".

That's it. That's a web server.

@app.route("/") maps the URL path / to the home function. When someone visits that URL, Flask calls your function and sends the return value back as the response.

debug=True enables auto-reload (no need to restart when you edit code) and a browser debugger on errors. Never use it in production.

Routes and URL Parameters

Static routes

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

@app.route("/contact")
def contact():
    return "Contact us at hello@example.com"

Dynamic routes — path parameters

@app.route("/user/<username>")
def profile(username):
    return f"Profile of {username}"

@app.route("/product/<int:product_id>")
def product(product_id):
    # Flask converts product_id to int for you
    return f"Product #{product_id}"

@app.route("/price/<float:amount>")
def price(amount):
    return f"Price: ${amount:.2f}"

Visit /user/alice -> "Profile of alice" Visit /product/42 -> "Product #42"

Type converters: string (default), int, float, path (slashes allowed), uuid.

HTTP methods

By default, routes only accept GET. Specify methods to allow others:

from flask import Flask, request

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        return f"Logging in as {username}"
    # GET — show the login form
    return """
        <form method="POST">
            <input name="username" placeholder="Username">
            <input name="password" type="password" placeholder="Password">
            <button type="submit">Log in</button>
        </form>
    """

The request Object

When someone calls your route, Flask puts all request data in request:

from flask import request

@app.route("/search")
def search():
    # Query string: /search?q=python&page=2
    query = request.args.get("q", "")       # "python"
    page  = request.args.get("page", 1, type=int)  # 2
    return f"Search: '{query}', page {page}"


@app.route("/submit", methods=["POST"])
def submit():
    # Form data (application/x-www-form-urlencoded)
    name  = request.form.get("name")
    email = request.form.get("email")

    # JSON body (application/json)
    data  = request.get_json()

    # Headers
    auth  = request.headers.get("Authorization")

    # Uploaded files
    file  = request.files.get("avatar")

    return "OK"

HTML Templates with Jinja2

Returning HTML strings from functions gets messy fast. Flask uses Jinja2 — a template engine that keeps HTML in separate files.

Create a templates/ folder next to app.py:

my_app/
├── app.py
└── templates/
    ├── base.html
    ├── index.html
    └── users.html

templates/base.html — the layout every page inherits:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}My Site{% endblock %}</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 1rem; }
        nav a { margin-right: 1rem; }
    </style>
</head>
<body>
    <nav>
        <a href="/">Home</a>
        <a href="/users">Users</a>
    </nav>
    <hr>
    {% block content %}{% endblock %}
</body>
</html>

templates/index.html — extends the base:

{% extends "base.html" %}

{% block title %}Home{% endblock %}

{% block content %}
  <h1>Welcome, {{ name }}!</h1>
  <p>The date is {{ date }}.</p>

  {% if items %}
    <ul>
      {% for item in items %}
        <li>{{ item }}</li>
      {% endfor %}
    </ul>
  {% else %}
    <p>No items found.</p>
  {% endif %}
{% endblock %}

app.py — render the template:

from flask import Flask, render_template
from datetime import date

app = Flask(__name__)


@app.route("/")
def home():
    return render_template(
        "index.html",
        name="Alice",
        date=date.today(),
        items=["Python", "Flask", "Jinja2"],
    )

Jinja2 syntax:

  • {{ variable }} — output a value
  • {% if condition %}...{% endif %} — conditionals
  • {% for item in list %}...{% endfor %} — loops
  • {% extends "base.html" %} — template inheritance
  • {% block name %}...{% endblock %} — override sections
  • {{ value | filter }} — filters: upper, lower, length, default("none"), safe

Static Files — CSS and Images

Put static files (CSS, JS, images) in a static/ folder:

my_app/
├── app.py
├── templates/
└── static/
    ├── style.css
    └── logo.png

Reference them in templates using url_for:

<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Logo">

url_for generates the correct URL regardless of where you deploy the app.

Forms — Reading POST Data

templates/contact.html:

{% extends "base.html" %}
{% block title %}Contact{% endblock %}
{% block content %}
<h1>Contact Us</h1>

{% if message %}
  <p style="color: green;">{{ message }}</p>
{% endif %}

{% for error in errors %}
  <p style="color: red;">{{ error }}</p>
{% endfor %}

<form method="POST">
  <label>Name<br>
    <input type="text" name="name" value="{{ form_data.name or '' }}">
  </label><br><br>
  <label>Email<br>
    <input type="email" name="email" value="{{ form_data.email or '' }}">
  </label><br><br>
  <label>Message<br>
    <textarea name="body">{{ form_data.body or '' }}</textarea>
  </label><br><br>
  <button type="submit">Send</button>
</form>
{% endblock %}

app.py:

@app.route("/contact", methods=["GET", "POST"])
def contact():
    message   = ""
    errors    = []
    form_data = {}

    if request.method == "POST":
        name  = request.form.get("name", "").strip()
        email = request.form.get("email", "").strip()
        body  = request.form.get("body", "").strip()
        form_data = {"name": name, "email": email, "body": body}

        if not name:
            errors.append("Name is required.")
        if not email or "@" not in email:
            errors.append("A valid email is required.")
        if not body:
            errors.append("Message cannot be empty.")

        if not errors:
            # In a real app: send an email, save to DB, etc.
            message = f"Thanks, {name}! We'll be in touch."
            form_data = {}

    return render_template(
        "contact.html",
        message=message,
        errors=errors,
        form_data=form_data,
    )

Flask with SQLite — A Real Task App

Let's build a task manager that stores tasks in SQLite.

task_app/
├── app.py
└── templates/
    ├── base.html
    └── tasks.html

app.py:

import sqlite3
from flask import Flask, render_template, request, redirect, url_for, flash
from pathlib import Path

app = Flask(__name__)
app.secret_key = "change-this-in-production"  # required for flash messages

DB_PATH = "tasks.db"


def get_db():
    """Open a database connection."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row   # rows behave like dicts
    return conn


def init_db():
    """Create tables if they don't exist."""
    with get_db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id        INTEGER PRIMARY KEY AUTOINCREMENT,
                title     TEXT    NOT NULL,
                done      BOOLEAN NOT NULL DEFAULT 0,
                created   TEXT    NOT NULL DEFAULT (datetime('now'))
            )
        """)


@app.route("/")
def task_list():
    with get_db() as conn:
        tasks = conn.execute(
            "SELECT * FROM tasks ORDER BY done, created DESC"
        ).fetchall()
    return render_template("tasks.html", tasks=tasks)


@app.route("/add", methods=["POST"])
def add_task():
    title = request.form.get("title", "").strip()
    if not title:
        flash("Task title cannot be empty.", "error")
    else:
        with get_db() as conn:
            conn.execute("INSERT INTO tasks (title) VALUES (?)", (title,))
        flash(f"Task '{title}' added.", "success")
    return redirect(url_for("task_list"))


@app.route("/toggle/<int:task_id>", methods=["POST"])
def toggle_task(task_id):
    with get_db() as conn:
        conn.execute(
            "UPDATE tasks SET done = NOT done WHERE id = ?", (task_id,)
        )
    return redirect(url_for("task_list"))


@app.route("/delete/<int:task_id>", methods=["POST"])
def delete_task(task_id):
    with get_db() as conn:
        conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
    flash("Task deleted.", "info")
    return redirect(url_for("task_list"))


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

templates/tasks.html:

{% extends "base.html" %}
{% block title %}Tasks{% endblock %}
{% block content %}

<h1>My Tasks</h1>

{% with messages = get_flashed_messages(with_categories=true) %}
  {% for category, msg in messages %}
    <p style="color: {{ 'green' if category == 'success' else 'red' if category == 'error' else 'blue' }}">
      {{ msg }}
    </p>
  {% endfor %}
{% endwith %}

<form method="POST" action="/add">
  <input type="text" name="title" placeholder="New task..." required>
  <button type="submit">Add</button>
</form>

<ul style="list-style: none; padding: 0; margin-top: 1rem;">
  {% for task in tasks %}
  <li style="margin: 0.5rem 0; {{ 'opacity: 0.5;' if task.done }}">
    <form method="POST" action="/toggle/{{ task.id }}" style="display: inline;">
      <button type="submit" title="{{ 'Mark undone' if task.done else 'Mark done' }}">
        {{ '[x]' if task.done else '[ ]' }}
      </button>
    </form>
    <span style="{{ 'text-decoration: line-through;' if task.done }}">
      {{ task.title }}
    </span>
    <form method="POST" action="/delete/{{ task.id }}" style="display: inline;">
      <button type="submit" onclick="return confirm('Delete this task?')"
              style="margin-left: 0.5rem; color: red;">[-]</button>
    </form>
  </li>
  {% endfor %}
</ul>

{% if not tasks %}
  <p>No tasks yet. Add one above!</p>
{% endif %}

{% endblock %}

Blueprints — Organizing a Larger App

When your app grows, split it into Blueprints:

my_app/
├── app.py
├── auth/
│   ├── __init__.py
│   └── routes.py
└── tasks/
    ├── __init__.py
    └── routes.py

auth/routes.py:

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

auth_bp = Blueprint("auth", __name__, url_prefix="/auth")


@auth_bp.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        # handle login
        return redirect(url_for("tasks.task_list"))
    return render_template("auth/login.html")


@auth_bp.route("/logout")
def logout():
    return redirect(url_for("auth.login"))

app.py:

from flask import Flask
from auth.routes import auth_bp
from tasks.routes import tasks_bp

app = Flask(__name__)
app.secret_key = "your-secret-key"

app.register_blueprint(auth_bp)
app.register_blueprint(tasks_bp)

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

Each blueprint has its own routes, templates, and URL prefix. url_for("auth.login") generates /auth/login.

Building a JSON API with Flask

Flask also works great as a pure API server:

from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# In-memory store (use a database in production)
tasks = [
    {"id": 1, "title": "Buy milk",  "done": False},
    {"id": 2, "title": "Walk dog",  "done": True},
]
next_id = 3


@app.route("/api/tasks", methods=["GET"])
def get_tasks():
    done = request.args.get("done")
    if done is not None:
        done_bool = done.lower() == "true"
        filtered  = [t for t in tasks if t["done"] == done_bool]
        return jsonify(filtered)
    return jsonify(tasks)


@app.route("/api/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
    task = next((t for t in tasks if t["id"] == task_id), None)
    if task is None:
        abort(404)
    return jsonify(task)


@app.route("/api/tasks", methods=["POST"])
def create_task():
    global next_id
    data = request.get_json()
    if not data or "title" not in data:
        abort(400, description="'title' is required")
    task = {"id": next_id, "title": data["title"], "done": False}
    next_id += 1
    tasks.append(task)
    return jsonify(task), 201


@app.route("/api/tasks/<int:task_id>", methods=["PATCH"])
def update_task(task_id):
    task = next((t for t in tasks if t["id"] == task_id), None)
    if task is None:
        abort(404)
    data = request.get_json() or {}
    task.update({k: v for k, v in data.items() if k in ("title", "done")})
    return jsonify(task)


@app.route("/api/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    global tasks
    task = next((t for t in tasks if t["id"] == task_id), None)
    if task is None:
        abort(404)
    tasks = [t for t in tasks if t["id"] != task_id]
    return "", 204


@app.errorhandler(404)
def not_found(e):
    return jsonify(error=str(e)), 404


@app.errorhandler(400)
def bad_request(e):
    return jsonify(error=str(e)), 400

Test it:

# Get all tasks
curl http://127.0.0.1:5000/api/tasks

# Create a task
curl -X POST http://127.0.0.1:5000/api/tasks \
     -H "Content-Type: application/json" \
     -d '{"title": "Learn Flask"}'

# Update a task
curl -X PATCH http://127.0.0.1:5000/api/tasks/1 \
     -H "Content-Type: application/json" \
     -d '{"done": true}'

# Delete a task
curl -X DELETE http://127.0.0.1:5000/api/tasks/1

Error Handlers

@app.errorhandler(404)
def page_not_found(e):
    return render_template("404.html"), 404

@app.errorhandler(500)
def internal_error(e):
    return render_template("500.html"), 500

Deploying Flask

Option 1: Render (free tier)

  1. Push your code to GitHub
  2. Go to render.com -> New Web Service
  3. Connect your repo
  4. Set start command: gunicorn app:app
  5. Done — Render gives you a public URL
pip install gunicorn

Never use app.run() (Flask's dev server) in production. gunicorn is the production server.

Option 2: PythonAnywhere (free tier)

  1. Upload your files
  2. Go to the Web tab -> Add new web app
  3. Choose Flask
  4. Configure WSGI to point to your app.py

requirements.txt for deployment

pip freeze > requirements.txt

Include at minimum:

flask
gunicorn

What You Learned in This Chapter

  • Flask(__name__) creates an app. @app.route("/path") maps URLs to functions.
  • Return a string for plain text, render_template() for HTML, jsonify() for JSON.
  • <int:id> in the route captures a typed URL parameter.
  • request.args for query strings, request.form for POST form data, request.get_json() for JSON bodies.
  • Jinja2 templates live in templates/. Use {{ }} for values, {% %} for logic, {% extends %} and {% block %} for layouts.
  • Static files live in static/. Reference them with url_for('static', filename='...').
  • flash() + get_flashed_messages() for one-time user notifications.
  • redirect(url_for("function_name")) sends users to another route.
  • Blueprints split a large app into independent modules.
  • Use gunicorn to deploy — never Flask's dev server.

What's Next?

Chapter 41 covers FastAPI — a modern, high-performance API framework with automatic validation, type hints, and interactive documentation. If Flask is the classic choice, FastAPI is where the industry is heading.

© 2026 Abhilash Sahoo. Python: Zero to Hero.