Why Flask for a first web app?
Flask is a good choice for a first web app because it is small, readable and explicit: a route is a Python function, a page is a template, and you add a database, forms or logins one piece at a time. You see every part of the app, which is the fastest way to understand how web apps work.
Flask calls itself a microframework. That doesn’t mean it is only for small apps; it means the core stays small and you choose the extensions you need. If you are new to the language itself, start with our guide to Python for beginners, then come back here.
What you’ll build, and what you need
You’ll build a small notes app: a page that lists notes, a form to add one, a database to keep them, tests that prove it works, and a production server to run it. Along the way you’ll use the patterns real Flask projects use, not shortcuts you’d have to unlearn.
You need:
- Python 3.10 or newer. Check with
python3 --version. - A terminal and a code editor, such as VS Code or PyCharm.
- About an hour.
Every command and file below was run end to end with Python 3.11, Flask 3.1, Flask-SQLAlchemy 3.1, SQLAlchemy 2.0, pytest and Gunicorn while writing this tutorial.
Step 1: create the project and a virtual environment
A virtual environment keeps this project’s packages separate from everything else on your computer.
mkdir notes && cd notes
python3 -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install flask flask-sqlalchemy pytest gunicorn
pip freeze > requirements.txt
Create this layout. The app folder is a Python package, so Flask can find your code and templates:
notes/
app/
__init__.py
models.py
routes.py
templates/
base.html
index.html
tests/
test_app.py
Step 2: the app factory
Instead of creating the app at import time, real Flask projects use an app factory: a function that builds and configures the app. It lets tests create a fresh app with a test database, and production create one with real settings.
app/__init__.py:
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ.get("SECRET_KEY", "dev"),
SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///notes.db"),
)
if test_config:
app.config.update(test_config)
db.init_app(app)
from . import routes
app.register_blueprint(routes.bp)
with app.app_context():
db.create_all()
return app
Settings come from environment variables, with safe defaults for your laptop. The secret key signs session cookies, so in production it must be a long random value, never “dev”. The SQLite file is created in an instance folder next to the package the first time the app runs.
Step 3: a model with Flask-SQLAlchemy
A model is a Python class that maps to a database table. Each note has an id, its text and the time it was created.
app/models.py:
from datetime import datetime, timezone
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from . import db
class Note(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
text: Mapped[str] = mapped_column(String(500))
created: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc))
This is the SQLAlchemy 2.0 style, with type hints describing each column. Older tutorials use db.Column(...); it still works, but new code should use this form.
Step 4: routes and a form
Routes connect URLs to Python functions. A blueprint groups related routes, which keeps the app tidy as it grows. Here the same URL shows the notes on a GET request and saves a new one on a POST.
app/routes.py:
from flask import Blueprint, flash, redirect, render_template, request, url_for
from . import db
from .models import Note
bp = Blueprint("notes", __name__)
@bp.get("/")
def index():
notes = db.session.scalars(db.select(Note).order_by(Note.created.desc())).all()
return render_template("index.html", notes=notes)
@bp.post("/")
def add():
text = request.form.get("text", "").strip()
if not text:
flash("Write something first.")
else:
db.session.add(Note(text=text))
db.session.commit()
return redirect(url_for("notes.index"))
Two habits worth noticing. The form input is cleaned and checked before it is saved, and after a successful POST the app redirects instead of rendering a page, so refreshing the browser doesn’t submit the note twice.
Step 5: templates
Flask renders pages with Jinja templates. A base template holds the shared page structure, and each page fills in its own block. Jinja escapes values by default, so a note containing HTML is shown as text, not run as code.
app/templates/base.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
</head>
<body>
{% for message in get_flashed_messages() %}
<p>{{ message }}</p>
{% endfor %}
{% block content %}{% endblock %}
</body>
</html>
app/templates/index.html:
{% extends "base.html" %}
{% block content %}
<h1>Notes</h1>
<form method="post">
<input name="text" maxlength="500" required>
<button>Add</button>
</form>
<ul>
{% for note in notes %}
<li>{{ note.text }}</li>
{% endfor %}
</ul>
{% endblock %}
Step 6: run it
From the notes folder, with the virtual environment active:
flask --app app run --debug
Open http://127.0.0.1:5000 in your browser, add a note and refresh. Debug mode reloads the app when you save a file and shows detailed errors in the browser. Never use debug mode on a server anyone else can reach.
Step 7: tests
Tests let you change the app without fear. Flask’s test client sends requests to the app without a browser, and the app factory gives each test a fresh in-memory database.
tests/test_app.py:
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"})
return app.test_client()
def test_home_page_loads(client):
response = client.get("/")
assert response.status_code == 200
assert b"<h1>Notes</h1>" in response.data
def test_add_note(client):
response = client.post("/", data={"text": "Buy milk"}, follow_redirects=True)
assert b"Buy milk" in response.data
def test_empty_note_is_rejected(client):
response = client.post("/", data={"text": " "}, follow_redirects=True)
assert b"Write something first." in response.data
Run them with python -m pytest -q. You should see three passing tests.
Step 8: a production server
Flask’s built-in server is for development only. In production, a WSGI server such as Gunicorn runs several worker processes and handles real traffic:
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
gunicorn -w 2 -b 0.0.0.0:8000 "app:create_app()"
On Windows, Waitress plays the same role as Gunicorn. In a real deployment, a reverse proxy such as nginx sits in front for HTTPS, or you deploy to a platform that provides it, such as Render, Railway, Fly.io, Google Cloud Run or AWS App Runner.
From tutorial to production: what changes when a real product goes live
A tutorial app and a production app differ less in the code you’ve written than in what surrounds it. Before real users arrive, add:
- User accounts and permissions. Flask-Login handles sessions; hash passwords with a proper library and never store them in plain text.
- CSRF protection on every form. Flask-WTF adds tokens so other sites can’t submit forms on a user’s behalf. This tutorial leaves it out for clarity; a live app shouldn’t.
- Database migrations. Replace
db.create_all()with Flask-Migrate (built on Alembic), so schema changes are versioned and reversible. - A production database. PostgreSQL or MySQL instead of SQLite once several users or workers write at the same time.
- Secrets and configuration in environment variables or a secret manager, separate for each environment.
- Logging and error monitoring, for example Sentry or your host’s logs, so you hear about errors before users report them.
- Backups you have restored at least once. An untested backup is a hope, not a backup.
- Pinned dependencies and updates. Keep
requirements.txtpinned, and update on a schedule. - Automated tests and deploys through a pipeline, so every change is tested the same way.
Our guide on how to build a web application with Python looks at how Flask fits alongside the other Python frameworks as a project grows.
When should you pick Flask for a project?
Pick Flask when the project is an API, a small or medium web app, an internal tool, a prototype, or a service around a Python library or model. Decide by the project’s traits:
| Project trait | Flask fits | A batteries-included framework (such as Django) may fit better |
|---|---|---|
| Size of the data model | A handful of tables | Dozens of related models |
| Admin interface | Not needed, or built to order | Needed on day one |
| Shape of the product | An API, a microservice or a focused app | A content-heavy site with accounts, roles and admin |
| Team preference | Choose each library yourself | Use one set of conventions everywhere |
| Python libraries at the core | A model, a data pipeline or a scientific library to expose | Mostly standard web features |
How we’d take a Flask prototype to production
When a prototype becomes a product, this is how we’d approach it as a method: review the code against the production list above, move configuration and secrets out of the code, add migrations and a production database, put tests and deploys in a pipeline, add monitoring and backups, then load-test the busiest pages before launch. That work sits within Python web development and custom web app development projects, and if you’d rather add people to your own team, you can hire Python developers.
