A walkthrough of a small Flask app: a shared notes list with a form to add entries, stored in SQLite via SQLAlchemy. It combines the application factory, templates, flash messages, and basic database access from earlier tutorials.
What we are building
- GET
/shows recent notes. - POST form adds a note with title and body.
- SQLite stores data between restarts.
- Tests use Flask’s test client and a temporary database.
Project layout
notes_app/
├── pyproject.toml
├── src/notes_app/
│ ├── __init__.py
│ ├── models.py
│ ├── routes.py
│ └── templates/
│ ├── base.html
│ └── index.html
└── tests/
└── test_routes.py
Model
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column()
body: Mapped[str] = mapped_column(default="")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
App factory with database init
from flask import Flask
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from notes_app.models import Base
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY="dev-only-change-me",
SQLALCHEMY_DATABASE_URI="sqlite:///notes.db",
)
if test_config:
app.config.update(test_config)
engine = create_engine(app.config["SQLALCHEMY_DATABASE_URI"], future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
@app.before_request
def open_session():
from flask import g
g.db = SessionLocal()
@app.teardown_request
def close_session(exc):
from flask import g
g.db.close()
from notes_app.routes import bp
app.register_blueprint(bp)
return app
Routes and form handling
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
from notes_app.models import Note
bp = Blueprint("main", __name__)
@bp.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
title = request.form.get("title", "").strip()
body = request.form.get("body", "").strip()
if not title:
flash("Title is required.", "error")
else:
g.db.add(Note(title=title, body=body))
g.db.commit()
flash("Note saved.", "success")
return redirect(url_for("main.index"))
notes = g.db.query(Note).order_by(Note.created_at.desc()).limit(20).all()
return render_template("index.html", notes=notes)
Template sketch
{% extends "base.html" %}
{% block content %}
<h1>Notes</h1>
<form method="post">
<label>Title <input name="title" required></label>
<label>Body <textarea name="body"></textarea></label>
<button type="submit">Save</button>
</form>
<ul>
{% for note in notes %}
<li><strong>{{ note.title }}</strong> - {{ note.body }}</li>
{% else %}
<li>No notes yet.</li>
{% endfor %}
</ul>
{% endblock %}
In your project, save that as templates/index.html. Escape user content is handled by Jinja2’s default auto-escaping for HTML.
Test the happy path
import pytest
from notes_app import create_app
@pytest.fixture
def client(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}"})
return app.test_client()
def test_create_note(client):
response = client.post("/", data={"title": "Test", "body": "Hello"}, follow_redirects=True)
assert response.status_code == 200
assert b"Test" in response.data
Before you call it done
- Move secrets to environment variables.
- Turn off Flask debug mode outside local development.
- Add basic styling in
static/style.cssif you like. - Consider Alembic once schema changes become frequent.
That closes Going further with Python. You can write idiomatic modules, pin dependencies, test them, expose a CLI, and ship a small Flask app with forms and persistent data. The advanced tutorials goes deeper on concurrency, performance, and API design when you are ready.

