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 — small core, no opinions, no batteries. You bring the database, the form library, the auth system. In return you get a tiny, understandable codebase that gets out of your way. Django decides; Flask lets you decide.

This lesson takes you from pip install to a three-route app with templates, query strings, JSON responses, and proper URL building — the foundation every later Flask lesson assumes.

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


1. Why Flask

Flask owns the "I want full control" slot in the Python web ecosystem. Compare:

FrameworkStyleWhat you get out of the box
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 an SPA — FastAPI is the modern default
  • You need an admin panel, migrations, and auth out of the box — Django
  • Async I/O is core to your design — Flask 2.x supports async def views but the ecosystem is still mostly sync

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


2. The Minimum Flask App

Seven lines. That's 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 — 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 — works but loses some features (auto-reloader behaviour differs)
python app.py

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

Three moving 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 /. Pure decorator pattern — same shape as everything in decorators.
  • app.run(debug=True) starts the development server. It's a single-threaded WSGI server meant for flask run-style local hacking. Never expose it to the internet. It has no concurrency model, no security hardening, and debug=True enables a remote code execution panel for anyone who triggers an exception. 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 validates them before your handler runs.

python
@app.route("/user/<int:user_id>")
def show_user(user_id):
    # user_id is already an int — Flask returns 404 if it isn't
    return f"User #{user_id}"

@app.route("/posts/<string:slug>")    # <string> is the default — matches any non-slash text
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> matches slashes too — 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')

Five built-in converters: string (default), int, float, path, uuid. The converter not only types the value — it controls what the route matches. /user/abc against <int:user_id> returns a 404, not a 500. That validation-at-the-edge habit is worth keeping.


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

Shortcuts since Flask 2.0 — more readable:

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 hint at intent — same verb semantics as in apis.


5. The request Object

request is a thread-local proxy that holds everything about the incoming HTTP request. Import it once:

python
from flask import request

The four attributes you'll reach for constantly:

python
@app.route("/demo", methods=["GET", "POST"])
def demo():
    request.method                  # "GET" / "POST" / ...
    request.args                    # ImmutableMultiDict — query string (?q=...&page=2)
    request.form                    # MultiDict — POST form bodies
    request.json                    # parsed JSON body (None if not JSON)
    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 → "python"; missing → ""
    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 gotchas:

  • request.args and request.form are MultiDict — 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 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"                          # str → 200 OK, text/html

@app.get("/json")
def json_response():
    return {"ok": True, "count": 42}        # dict → auto-jsonified, 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 — you'll see both styles in older codebases.


7. Templates with Jinja2

Returning hardcoded HTML strings stops being fun around the third route. Flask uses Jinja2 for templates. Drop 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 — important).
  • {% tag %} — control flow: if, for, extends, block, include, set, with.
  • |filter — pipe a value through a filter: {{ name|upper }}, {{ items|length }}, {{ html|safe }} (disables escaping — only for trusted strings).
  • Template inheritance — {% extends "base.html" %} + {% block %} blocks lets one shell template define the page chrome and child templates fill the slots.

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 verbatim 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 Flask — Flask's static handler is fine for development but wasteful in front of real traffic.


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

Hardcoded URLs rot. The moment you change /users/<id> to /u/<id> in a route decorator, every template, redirect, and link that wrote /users/42 by hand silently breaks. 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://...) — needed for email links.

There is no excuse for writing a literal Flask URL string in templates. None. url_for is mandatory.


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 e somewhere (Sentry, file, ...) 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 dropping them on Flask's default white screen. In production, register a generic handler that catches Exception and renders a clean 500 page — but log the exception first so you can debug.


11. Preview: The Application Factory

You'll see app = Flask(__name__) at module level in every tutorial. It works, but it doesn't scale: testing with two different configs, running multiple instances, or breaking circular imports all get harder. The grown-up pattern 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; production creates one with ProductionConfig. Full treatment in flask-blueprints.


12. Common Mistakes

1. Hardcoding URLs in templates

html
<!-- WRONG — 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 — an interactive Python console in the browser, on any unhandled exception. Exposed publicly, this is remote code execution as a feature. 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 — same dict reused across calls
    data["seen"] = True
    return data

Classic Python footgun, doubly dangerous in a web handler where requests share the function. 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 honours FLASK_ENV, picks up .flaskenv files, and behaves predictably with the auto-reloader. Treat app.run() as a fallback for 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 just shipped stored XSS. 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 /         → render templates/index.html with items.values()
# TODO 2: define GET /items/<int:item_id>  → render detail, 404 if missing
# TODO 3: define GET /items/new            → render the form
# TODO 4: define POST /items/new           → read request.form["name"],
#         create an item, redirect 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. Because Flask's dev server is single-threaded by default this is safe. (In a multi-worker production setup you'd use a database to 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 — wire up 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 — proper validation comes in flask-forms
        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 name and the templates still build correct URLs.
  • 404 via abort — Flask handles the response, 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 refreshing the result page doesn't re-submit the form. This is the canonical 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 vanishes when the server restarts. flask-database replaces it with SQLAlchemy.
  • Project structure — one giant app.py doesn't scale. flask-blueprints splits this into a factory + blueprints.

What You Learned

  • Flask is a micro-framework — minimal core, you bring 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 — keep it that way.
  • 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 is a remote-code-execution panel.

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