Flask: Your First Web App in 30 Lines
1 · The lesson
readFlask 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 flaskandflask 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:
| Framework | Style | What is included |
|---|---|---|
| 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 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 defviews, 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:
# 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: 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("/")registershomeas the handler forGET /. 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, anddebug=Trueturns 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.
@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:
@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:
@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:
from flask import request
The attributes you'll use most often:
@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.argsandrequest.formareMultiDicts. 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 an error 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" # 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:
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, 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:
<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:
@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://...), which you need for links in emails.
Don't write Flask URLs as literal strings in templates. Always use url_for.
10. Error Handlers
@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:
# 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
<!-- 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
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:
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 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 / 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/:
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. 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
# 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:
<!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'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.pybecomes 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
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, so keep it on. 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 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.