PythonMastery
intermediate 20 min read · lesson 1 of 12 in Web Frameworks

Flask: Your First Web App in 30 Lines

1 · The lesson

read

Flask is a micro-framework: it has a small core and makes very few choices for you. You choose the database, the form library and the auth system yourself. In return, you get a small codebase that is easy to understand. Django makes these choices for you, but Flask lets you make them.

This lesson takes you from pip install to a three-route app with templates, query strings, JSON responses, and proper URL building. Every later Flask lesson builds on this.

Run locally with pip install flask and flask run. Flask doesn't run in Pyodide, because it needs a real Python interpreter listening on a network socket. Expected output is shown in comments.


1. Why Flask

Among Python web frameworks, Flask is the one you pick when you want full control. Compare:

FrameworkStyleWhat is included
FlaskMicro, syncRouting, templates, sessions. That's it.
FastAPIMicro, async-firstRouting + Pydantic validation + auto OpenAPI docs
DjangoBatteries-includedORM + admin + auth + forms + migrations + everything

Pick Flask when:

  • You want to understand every line of your stack
  • The app is small-to-medium and doesn't need async
  • You're integrating with an existing codebase that already uses Flask
  • You want to teach someone web fundamentals without ten layers of abstraction

Pick something else when:

  • You're building a JSON-first API for a single-page app (SPA): FastAPI is the modern default
  • You need an admin panel, migrations, and auth included from the start: Django
  • Async I/O is central to your design. Flask 2.0 and later supports async def views, but most Flask extensions are still sync

For framework choice as a whole, see web-which-framework.


2. The Minimum Flask App

These few lines are a real web app:

python
# app.py
from flask import Flask

app = Flask(__name__)

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

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

Run it two ways:

bash
# Modern way: this uses the Flask CLI
export FLASK_APP=app.py            # Windows PowerShell: $env:FLASK_APP="app.py"
flask run
#  * Running on http://127.0.0.1:5000

# Old way: this works, but you lose some features (the auto-reloader behaves differently)
python app.py

Open http://127.0.0.1:5000 in a browser. You'll see Hello, Flask!.

There are three parts:

  • Flask(__name__) creates the application. __name__ tells Flask where to look for templates and static files.
  • @app.route("/") registers home as the handler for GET /. This is the normal decorator pattern, the same one you saw in decorators.
  • app.run(debug=True) starts the development server. It is a simple WSGI server meant only for local development. Never expose it to the internet. It is not built for performance or security, and debug=True turns on an interactive debugger that can run Python code on your server when an exception happens (it is protected by a PIN, but it must never be public). For production use, see Section 13 and flask-blueprints.

3. Routes and URL Converters

Route paths can contain typed variables in <angle_brackets>. Flask converts and checks them before your handler runs.

python
@app.route("/user/<int:user_id>")
def show_user(user_id):
    # user_id is already an int, because Flask returns 404 if that URL part is not a number
    return f"User #{user_id}"

@app.route("/posts/<string:slug>")    # <string> is the default and matches any text without a slash
def show_post(slug):
    return f"Post: {slug}"

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

@app.route("/files/<path:filepath>")  # <path> also matches slashes, so it works for nested paths
def show_file(filepath):
    return f"File: {filepath}"

@app.route("/things/<uuid:obj_id>")
def show_thing(obj_id):
    return f"UUID: {obj_id}"           # obj_id is a uuid.UUID instance
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

The five converters you will use most are string (default), int, float, path and uuid. The converter converts the value, and it also controls which URLs the route matches. /user/abc against <int:user_id> returns a 404, not a 500. Checking input as early as possible like this is a good habit.


4. HTTP Methods

By default, @app.route only accepts GET. Pass methods= for anything else:

python
@app.route("/items", methods=["GET", "POST"])
def items():
    if request.method == "POST":
        return "creating an item", 201
    return "listing items"
+ setup added so this can run · defines app, request
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')
request = _AutoMock('request')

Since Flask 2.0 there are shorter, more readable decorators:

python
@app.get("/items")
def list_items():
    return "listing items"

@app.post("/items")
def create_item():
    return "creating an item", 201

@app.put("/items/<int:id>")
def update_item(id): ...

@app.delete("/items/<int:id>")
def delete_item(id): ...
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Prefer the per-verb decorators. They're shorter, and they show the HTTP method in the name. The methods mean the same as in apis.


5. The request Object

request is a proxy object that holds everything about the current HTTP request. Import it once:

python
from flask import request

The attributes you'll use most often:

python
@app.route("/demo", methods=["GET", "POST"])
def demo():
    request.method                  # "GET" / "POST" / ...
    request.args                    # ImmutableMultiDict with the query string (?q=...&page=2)
    request.form                    # ImmutableMultiDict with the fields of a POSTed form
    request.json                    # parsed JSON body (a 415 error if the body isn't JSON; request.get_json(silent=True) gives None)
    request.headers                 # case-insensitive dict
    request.cookies                 # dict of cookies sent by the browser
    request.files                   # uploaded files (covered in flask-forms)

    # Examples
    q = request.args.get("q", "")           # ?q=python gives "python", and a missing q gives ""
    page = request.args.get("page", 1, type=int)
    name = request.form.get("name")          # from a POST form
    data = request.get_json(silent=True)     # returns None on bad JSON instead of raising
    ua = request.headers.get("User-Agent")
    return f"got method={request.method}, q={q!r}"
+ setup added so this can run · defines request, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

request = _AutoMock('request')
app = _AutoMock('app')

Two common mistakes:

  • request.args and request.form are MultiDicts. A single key can have multiple values. request.form.get("tags") returns the first. request.form.getlist("tags") returns all. Important for checkbox forms.
  • request.json vs request.get_json(): accessing .json raises an error on malformed JSON. get_json(silent=True) returns None instead. Use the latter at API boundaries.

6. Returning Responses

A view function can return:

python
@app.get("/text")
def text():
    return "Hello"                          # a str gives 200 OK with text/html

@app.get("/json")
def json_response():
    return {"ok": True, "count": 42}        # a dict is turned into JSON (application/json)

@app.get("/jsonify")
def explicit_json():
    from flask import jsonify
    return jsonify(items=[1, 2, 3])         # same effect, more explicit

@app.get("/created")
def created():
    return {"id": 7}, 201                   # (body, status)

@app.get("/headered")
def headered():
    return "ok", 200, {"X-Custom": "yes"}   # (body, status, headers)

@app.get("/redirected")
def redirected():
    from flask import redirect, url_for
    return redirect(url_for("home"))
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Returning a dict became auto-JSON in Flask 1.1. Before that you had to call jsonify() explicitly, so you'll see both styles in older codebases.


7. Templates with Jinja2

Returning hardcoded HTML strings becomes hard to manage after two or three routes. Flask uses Jinja2 for templates. Put them in a templates/ directory next to app.py:

python
myapp/
  app.py
  templates/
    base.html
    index.html
  static/
    style.css

templates/base.html:

html
<!doctype html>
<html>
<head>
    <title>{% block title %}My App{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <nav><a href="{{ url_for('home') }}">Home</a></nav>
    {% block content %}{% endblock %}
</body>
</html>

templates/index.html:

html
{% extends "base.html" %}
{% block title %}Items{% endblock %}
{% block content %}
    <h1>Items ({{ items|length }})</h1>
    {% if items %}
        <ul>
        {% for item in items %}
            <li><a href="{{ url_for('show_item', item_id=item.id) }}">{{ item.name }}</a></li>
        {% endfor %}
        </ul>
    {% else %}
        <p>No items yet.</p>
    {% endif %}
{% endblock %}

The view:

python
from flask import render_template

@app.get("/")
def home():
    items = [{"id": 1, "name": "First"}, {"id": 2, "name": "Second"}]
    return render_template("index.html", items=items)
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Jinja2 basics:

  • {{ expr }}: print an expression (HTML-escaped by default, which is important).
  • {% tag %}: control flow: if, for, extends, block, include, set, with.
  • |filter: pass a value through a filter: {{ name|upper }}, {{ items|length }}, {{ html|safe }} (disables escaping, so use it only for trusted strings).
  • Template inheritance: with {% extends "base.html" %} and {% block %} blocks, one base template defines the parts shared by every page (head, navigation, footer), and child templates fill in the blocks.

Auto-escaping is on by default for .html files. {{ user_input }} is safe even if the user submitted <script>alert(1)</script>. The |safe filter and {% autoescape false %} disable it. Use them only with strings you generated yourself.


8. Static Files

Files in static/ are served unchanged at /static/<filename>. Use url_for("static", filename=...) to build the URL. Never hardcode /static/style.css:

html
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script src="{{ url_for('static', filename='app.js') }}"></script>
<img src="{{ url_for('static', filename='img/logo.png') }}">

In production, you serve static/ from nginx or a CDN, not from Flask. Flask's static handler is fine for development, but it is inefficient for real traffic.


9. url_for: Build URLs by Name, Never by String

Hardcoded URLs go out of date. As soon as you change /users/<id> to /u/<id> in a route decorator, every template, redirect and link that has /users/42 written by hand breaks, with no error. url_for builds URLs from the view function name:

python
@app.get("/users/<int:user_id>")
def show_user(user_id):
    return ...

# In a template
{{ url_for("show_user", user_id=42) }}      # → /users/42
{{ url_for("show_user", user_id=42, format="json") }}   # → /users/42?format=json

# In code
from flask import url_for, redirect
return redirect(url_for("show_user", user_id=42))

Rules:

  • Positional-looking kwargs that match route variables become path segments.
  • Extra kwargs become query string parameters.
  • _external=True builds an absolute URL (http://...), which you need for links in emails.

Don't write Flask URLs as literal strings in templates. Always use url_for.


10. Error Handlers

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

@app.errorhandler(500)
def server_error(e):
    # Log the error e somewhere (Sentry, a file, and so on) before responding.
    return render_template("500.html"), 500
+ setup added so this can run · defines app, render_template
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')
def render_template(*_a, **_kw):
    print('-> render_template() called')
    return _AutoMock('render_template()')

Custom error pages keep users on your site instead of showing them Flask's plain default error page. In production, register a general handler that catches Exception and renders a clean 500 page, but log the exception first so you can debug it.


11. Preview: The Application Factory

You'll see app = Flask(__name__) at module level in every tutorial. It works, but it becomes a problem as the app grows: testing with two different configs, running multiple instances, and avoiding circular imports all get harder. The pattern used in larger apps is a factory function:

python
# myapp/__init__.py
from flask import Flask

def create_app(config_class=None):
    app = Flask(__name__)
    if config_class:
        app.config.from_object(config_class)

    from . import routes
    app.register_blueprint(routes.bp)

    return app

You build the app inside a function, configure it, register blueprints, and return it. Testing creates a fresh app per test, and production creates one with ProductionConfig. This is covered fully in flask-blueprints.


12. Common Mistakes

1. Hardcoding URLs in templates

html
<!-- Wrong: this breaks when you change the route -->
<a href="/users/42">profile</a>

<!-- RIGHT -->
<a href="{{ url_for('show_user', user_id=42) }}">profile</a>

2. debug=True in production

app.run(debug=True) enables the Werkzeug debugger, which opens an interactive Python console in the browser on any unhandled exception. If the server is public, this lets an attacker run code on your server. Set DEBUG=False (or just don't pass debug=True) anywhere a stranger can reach the URL. See security-checklist.

3. Mutable default in form parsing

python
def parse(data={}):                 # Wrong: the same dict is reused across calls
    data["seen"] = True
    return data

This is a well-known Python mistake, and it is even more dangerous in a web handler, because all requests share the same function and the same default dict. Use None:

python
def parse(data=None):
    data = dict(data or {})
    ...

4. Running app.run() instead of flask run

Both start the server, but flask run has a --debug switch, picks up .flaskenv files (with python-dotenv installed), and works reliably with the auto-reloader. Use app.run() only for quick one-off scripts.

5. Trusting user input in a template via |safe

{{ user_bio|safe }} renders the bio without escaping. If the bio is <script>steal_cookies()</script>, you have a stored XSS vulnerability. Only use |safe on strings you built yourself (e.g. markdown you just rendered through a sanitiser). See security-checklist.


🎯 Your Turn: A Three-Route Items App

Build a Flask app with an in-memory item store:

  • GET /: list all items as an HTML page, one link per item to its detail page
  • GET /items/<int:item_id>: show one item's details (or 404 if missing)
  • POST /items/new: create a new item from a form (name field), redirect back to /
  • GET /items/new: show the new-item form

Use a module-level dict for storage. Use url_for everywhere. Render a real Jinja template (you can put HTML inline as a string for the form if you want to keep it short).

python
# app.py
from flask import Flask, request, redirect, url_for, render_template, abort

app = Flask(__name__)

# In-memory store: {id: {"id": int, "name": str}}
items: dict[int, dict] = {}
next_id = 1

# TODO 1: define GET /         that renders templates/index.html with items.values()
# TODO 2: define GET /items/<int:item_id>  that renders detail.html, or 404 if missing
# TODO 3: define GET /items/new            that renders the form
# TODO 4: define POST /items/new           that reads request.form["name"],
#         creates an item and redirects to /

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

Templates you'll need under templates/:

python
templates/
  base.html
  index.html
  detail.html
  new.html
Hint 1: Auto-incrementing IDs Use a module-level next_id integer. On each POST, store the item under items[next_id] and then next_id += 1. This is fine for a learning app. (The development server handles requests in separate threads, so two requests at the same moment could get the same ID. In production you'd let a database generate IDs, not a counter.)
Hint 2: 404 in detail view from flask import abort, then if item_id not in items: abort(404). Flask renders its default 404 page. Register an @app.errorhandler(404) if you want a custom one.
Show full solution
python
# app.py
from flask import Flask, request, redirect, url_for, render_template, abort

app = Flask(__name__)

items: dict[int, dict] = {}
next_id = 1


@app.get("/")
def home():
    return render_template("index.html", items=items.values())


@app.get("/items/<int:item_id>")
def show_item(item_id):
    item = items.get(item_id)
    if item is None:
        abort(404)
    return render_template("detail.html", item=item)


@app.get("/items/new")
def new_item_form():
    return render_template("new.html")


@app.post("/items/new")
def create_item():
    global next_id
    name = (request.form.get("name") or "").strip()
    if not name:
        # Re-render the form with an error (the flask-forms lesson shows proper validation)
        return render_template("new.html", error="Name is required"), 400
    items[next_id] = {"id": next_id, "name": name}
    next_id += 1
    return redirect(url_for("home"))


@app.errorhandler(404)
def not_found(_e):
    return "Not found", 404


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

templates/base.html:

html
<!doctype html>
<html>
<head><title>{% block title %}Items{% endblock %}</title></head>
<body>
    <nav>
        <a href="{{ url_for('home') }}">Home</a> |
        <a href="{{ url_for('new_item_form') }}">New item</a>
    </nav>
    <hr>
    {% block content %}{% endblock %}
</body>
</html>

templates/index.html:

html
{% extends "base.html" %}
{% block content %}
    <h1>Items</h1>
    {% if items %}
        <ul>
        {% for item in items %}
            <li><a href="{{ url_for('show_item', item_id=item.id) }}">{{ item.name }}</a></li>
        {% endfor %}
        </ul>
    {% else %}
        <p>No items yet. <a href="{{ url_for('new_item_form') }}">Add one.</a></p>
    {% endif %}
{% endblock %}

templates/detail.html:

html
{% extends "base.html" %}
{% block title %}{{ item.name }}{% endblock %}
{% block content %}
    <h1>{{ item.name }}</h1>
    <p>ID: {{ item.id }}</p>
{% endblock %}

templates/new.html:

html
{% extends "base.html" %}
{% block content %}
    <h1>New item</h1>
    {% if error %}<p style="color:red">{{ error }}</p>{% endif %}
    <form method="post" action="{{ url_for('create_item') }}">
        <label>Name: <input name="name" required></label>
        <button type="submit">Create</button>
    </form>
{% endblock %}

What this gets right:

  • url_for everywhere: change a route's URL and the templates still build correct URLs.
  • 404 via abort: Flask builds the response, and you write one line.
  • Auto-escaping: even if a user submits <script> as the item name, Jinja escapes it on display.
  • POST-redirect-GET: after a successful POST, redirect to a GET, so that refreshing the result page doesn't submit the form again. This is the standard web pattern. Learn it once and use it everywhere.

What's missing for a real app:

  • Validation with WTForms, covered in flask-forms.
  • Persistence: the dict is lost when the server restarts. flask-database replaces it with SQLAlchemy.
  • Project structure: one huge app.py becomes hard to manage. flask-blueprints splits this into a factory + blueprints.

What You Learned

  • Flask is a micro-framework: it has a minimal core, and you choose the rest. Pick it when you want full control over the stack.
  • @app.route(path, methods=[...]) or the per-verb shortcuts (@app.get, @app.post) register handlers.
  • URL converters (<int:>, <string:>, <float:>, <path:>, <uuid:>) type and validate path segments before your handler runs.
  • The request object carries args, form, json, headers, cookies, files, and method. Use .get(...) with defaults rather than indexing.
  • Returning a str is HTML, a dict is auto-JSON, and (body, status, headers) tuples let you control everything.
  • Jinja2 templates live in templates/. Use {% extends %} + {% block %} for inheritance. Auto-escaping is on, so keep it on.
  • url_for("view_name", ...) builds URLs by view name. Never hardcode paths.
  • static/ is served at /static/. Use url_for("static", filename=...).
  • @app.errorhandler(code) for custom error pages.
  • debug=True is dev-only. The Werkzeug debugger lets anyone who can reach it run code on your server.

Next: flask-forms: server-side forms with WTForms, CSRF protection, flash messages, and file uploads.

Found this useful? Share it with a friend who’s learning Python.