Flask: Your First Web App in 30 Lines
1 · The lesson
readFlask 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 flaskandflask 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:
| Framework | Style | What you get out of the box |
|---|---|---|
| Flask | Micro, sync | Routing, templates, sessions. That's it. |
| FastAPI | Micro, async-first | Routing + Pydantic validation + auto OpenAPI docs |
| Django | Batteries-included | ORM + 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 defviews 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:
# 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:
# 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("/")registershomeas the handler forGET /. 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 forflask run-style local hacking. Never expose it to the internet. It has no concurrency model, no security hardening, anddebug=Trueenables 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.
@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:
@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:
@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:
from flask import request
The four attributes you'll reach for constantly:
@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.argsandrequest.formareMultiDict— 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.jsonvsrequest.get_json()— accessing.jsonraises on malformed JSON.get_json(silent=True)returnsNoneinstead. Use the latter at API boundaries.
6. Returning Responses
A view function can return:
@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:
myapp/
app.py
templates/
base.html
index.html
static/
style.csstemplates/base.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:
{% 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:
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:
<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:
@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=Truebuilds 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
@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:
# 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
<!-- 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
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:
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 pageGET /items/<int:item_id>— show one item's details (or 404 if missing)POST /items/new— create a new item from a form (namefield), 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).
# 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/:
templates/ base.html index.html detail.html new.html
Hint 1 — Auto-incrementing IDs
Use a module-levelnext_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
# 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:
<!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:
{% 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:
{% extends "base.html" %}
{% block title %}{{ item.name }}{% endblock %}
{% block content %}
<h1>{{ item.name }}</h1>
<p>ID: {{ item.id }}</p>
{% endblock %}templates/new.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_foreverywhere — 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.pydoesn'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
requestobject carriesargs,form,json,headers,cookies,files, andmethod. Use.get(...)with defaults rather than indexing. - Returning a
stris HTML, adictis 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/— useurl_for("static", filename=...).@app.errorhandler(code)for custom error pages.debug=Trueis 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.