Flask-SQLAlchemy
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3, Flask-SQLAlchemy 3.1.1 (released 2023-09-11, still the latest release on
2026-10-07), SQLAlchemy 2.1.3 and Flask-SQLAlchemy-Lite 0.2.1, checked on 2026-10-07; every Python block was executed
on Python 3.13 against SQLite (the dataclass example on SQLAlchemy 2.0.54, see Dataclass Models). Flask-SQLAlchemy 3.1.1 requires SQLAlchemy 2.0.16 or newer, so everything here also holds
on the 2.0 series; features that only exist in one series are named as such. Flask 3.2 is not released yet.
Flask-SQLAlchemy is the extension that connects Flask to SQLAlchemy, the Python SQL toolkit and object-relational
mapper. It does three jobs: it creates and configures engines from app.config, it gives you one session per
application context that is closed when the context ends, and it adds conveniences on top of the ORM (automatic table
names, get_or_404, pagination, query recording, flask shell integration). Everything else, from column types to
joins to cascades, is plain SQLAlchemy. This page follows the Bookshelf project of this section from db = SQLAlchemy(...)
to a tested set of models, queries and transactions, and lists where the books that cover Flask-SQLAlchemy (the
Flask Framework Cookbook editions and Full-Stack Flask and React) predate the current API.
What Flask-SQLAlchemy Is and What It Is Not
SQLAlchemy has two layers, and the extension touches both:
-
Core is the SQL expression language and the connection machinery:
select(),insert(),Table,Engine,Connection, connection pools and dialects (SQLite, PostgreSQL, MySQL/MariaDB, Oracle, SQL Server). -
The ORM sits on top: mapped classes, relationships, the
Sessionunit of work that tracks changes and flushes them asINSERT/UPDATE/DELETEstatements inside a transaction.
Flask-SQLAlchemy owns the lifecycle of those objects inside Flask. It does not replace SQLAlchemy’s API, it does not
create migrations (that is Alembic, driven by Flask-Migrate on the Database Migrations page of this section) and it
does not support asyncio sessions. The extension is imperative about one thing: a Flask application context must be
active whenever you touch db.session or db.engine. Requests, CLI commands and app.app_context() blocks all
provide one.
| Piece | Provided by |
|---|---|
|
SQLAlchemy 2.x |
|
Flask-SQLAlchemy |
Migrations ( |
Alembic via Flask-Migrate (separate page) |
Database server |
PostgreSQL, MySQL, SQLite and so on; the DB-API driver ( |
When the application is small enough that a sqlite3 connection on g suffices, the plain sqlite3 page of this
section shows the official Flaskr pattern; Flask-SQLAlchemy is what you move to when you want an ORM,
several database backends or relationships between tables.
Installing and Picking a Driver
python -m venv .venv
source .venv/bin/activate
pip install "Flask-SQLAlchemy>=3.1" psycopg[binary]
Source: Flask-SQLAlchemy: Quick Start
pip install Flask-SQLAlchemy pulls in SQLAlchemy. The driver is yours to choose: SQLite ships with Python
(sqlite://), PostgreSQL uses psycopg (postgresql+psycopg://, SQLAlchemy 2.0 and newer support psycopg 3) or
psycopg2 (postgresql+psycopg2://), and MySQL or MariaDB use PyMySQL, mysqlclient or mariadb. The URI scheme is
dialect+driver://; a URI that omits the driver picks SQLAlchemy’s default driver for that dialect (psycopg2 for
postgresql://), which is why the examples spell the driver out.
Flask-SQLAlchemy 3.1.1 is a Pallets-Eco project. Its last release is from 2023; the examples on this page also run on SQLAlchemy 2.1.3 with one exception (dataclass bases, see Dataclass Models). If you want a minimal, actively developed alternative that follows plain SQLAlchemy, see Flask-SQLAlchemy-Lite.
Setting Up the Extension
import enum
import secrets
from datetime import datetime
import sqlalchemy as sa
from flask import Flask, abort, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
WriteOnlyMapped,
mapped_column,
relationship,
)
from werkzeug.security import generate_password_hash
class Base(DeclarativeBase):
"""Common base class; metadata and naming conventions live here."""
metadata = sa.MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
)
db = SQLAlchemy(model_class=Base)
print(db.Model.__name__, db.Model.metadata is Base.metadata)
Source: Flask-SQLAlchemy: Quick Start
The pieces, in order:
-
Baseis a SQLAlchemy 2.xDeclarativeBasesubclass. This is the base the SQLAlchemy 2.0 documentation recommends, and since Flask-SQLAlchemy 3.1.0 you pass it asmodel_class. The oldersqlalchemy.ext.declarative.declarative_base()function (used in Microservice APIs, chapter 7) still exists as a legacy spelling; new code usesDeclarativeBase. -
db = SQLAlchemy(model_class=Base)creates the extension without an application. Models, tables and the session object can be imported anywhere before an application exists. The samedbis shared by every application you create (tests build many). -
Metadata belongs on
Base. Since 3.1.0 themetadata=argument ofSQLAlchemy(...)is ignored when you use a 2.x style base; put theMetaData(and its naming convention) on the base class as above. The naming convention gives every constraint a stable name, which is what Alembic needs to drop or alter constraints on PostgreSQL later. -
db.Modelis then a class built fromBasethat adds automatic table names (BookTagbecomesbook_tag) and the__bind_key__support covered in Multiple Databases: Binds. Passdisable_autonaming=Trueto turn the naming off. -
The extension also exposes names from
sqlalchemyandsqlalchemy.ormas attributes (db.select,db.relationship,db.Column), which is convenient but hides the origin of a name from type checkers; this page imports from SQLAlchemy directly for the typed pieces and keepsdb.selectfor statements.
Then the application factory binds the extension to a real app:
def create_app(test_config=None):
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SQLALCHEMY_DATABASE_URI="sqlite://",
SECRET_KEY=secrets.token_hex(),
)
if test_config:
app.config.update(test_config)
db.init_app(app)
return app
app = create_app({"TESTING": True})
with app.app_context():
print(db.engine.url, list(db.engines))
Source: Flask-SQLAlchemy: Configuration
init_app must run after the configuration is set, because engines are created there (3.0 and newer create them at
init_app time, not on first use), and it raises RuntimeError if neither SQLALCHEMY_DATABASE_URI nor
SQLALCHEMY_BINDS is set. Calling it twice on one application also raises. A random SECRET_KEY is used here only so
that the example is self-contained; real configuration comes from the instance folder or the environment (see the
Configuration page of this section).
Configuration Keys
| Key | Default | Meaning |
|---|---|---|
|
none |
The URI of the default engine, a string or a |
|
|
Keyword arguments for |
|
none |
A dictionary mapping bind keys to a URI string, a |
|
|
Default |
|
|
Record the statements run during a request so that |
|
|
Emit the |
Source: Flask-SQLAlchemy: Configuration
The precedence for the default engine, lowest to highest, is: engine_options passed to SQLAlchemy(...), the None
entry of SQLALCHEMY_BINDS, SQLALCHEMY_ENGINE_OPTIONS, then SQLALCHEMY_DATABASE_URI for the url. Applications
that share one db can therefore differ in URI and pool settings without touching the models.
Driver defaults the extension applies: SQLite file paths that are relative resolve against the application’s
instance_path (which is created if it does not exist), not against the working directory, so sqlite:///bookshelf.db
lands in instance/bookshelf.db. In-memory SQLite uses StaticPool with check_same_thread disabled so that every
request sees the same database. MySQL and MariaDB get charset=utf8mb4 and, with a queue pool, a default recycle time
of two hours so an idle connection is not closed by the server’s wait_timeout.
Never build a URI by pasting the password into source code. Keep it in the environment and construct the URL with SQLAlchemy so that special characters are escaped:
import os
from sqlalchemy.engine import URL
db_url = URL.create(
"postgresql+psycopg",
username=os.environ.get("BOOKSHELF_DB_USER", "bookshelf"),
password=os.environ.get("BOOKSHELF_DB_PASSWORD", ""),
host=os.environ.get("BOOKSHELF_DB_HOST", "localhost"),
port=5432,
database=os.environ.get("BOOKSHELF_DB_NAME", "bookshelf"),
)
print(db_url.render_as_string(hide_password=True))
Source: SQLAlchemy: URL.create
The URL object can be assigned directly to SQLALCHEMY_DATABASE_URI. Rendering with hide_password=True is what to
log; the repr of a URL already masks the password.
Engine Options and Connection Pools
An engine holds a connection pool. The options that matter for a web application are:
Option (create_engine) |
Default | Why it matters |
|---|---|---|
|
5 |
Persistent connections kept per process. Multiply by the number of Gunicorn workers when you size the database’s
|
|
10 |
Extra connections allowed beyond |
|
30 |
Seconds to wait for a connection before |
|
-1 |
Replace connections older than this many seconds. Set it below any proxy or server idle timeout. |
|
|
Test a connection with a lightweight statement when it is checked out and transparently reconnect, which removes most "server closed the connection unexpectedly" errors after a database restart. |
|
|
Dictionary passed to the DB-API |
|
driver default |
For example |
Source: SQLAlchemy: Connection Pooling
import tempfile
from pathlib import Path
tmp = Path(tempfile.mkdtemp())
pool_app = Flask(__name__, instance_path=str(tmp))
pool_app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///pool-demo.db",
SQLALCHEMY_ENGINE_OPTIONS={
"pool_size": 5,
"max_overflow": 10,
"pool_timeout": 10,
"pool_recycle": 1800,
"pool_pre_ping": True,
},
)
pool_db = SQLAlchemy(model_class=Base)
pool_db.init_app(pool_app)
with pool_app.app_context():
print(pool_db.engine.url.database)
print(type(pool_db.engine.pool).__name__, pool_db.engine.pool.size())
Source: Flask-SQLAlchemy: Configuration
The resulting file is pool-demo.db inside the temporary instance folder. A file-backed SQLite database uses a queue
pool in SQLAlchemy 2.0 and newer; before 2.0 it used NullPool, which is one of the places the older books describe a
different behaviour.
Pitfalls. Under Gunicorn with --preload the engine is created in the parent and shared with forked workers, which
corrupts the pool; either do not preload, or call db.engine.dispose() in the post-fork hook (SQLAlchemy’s pooling
documentation covers this "using connection pools with multiprocessing" case). A session that is never closed holds a
connection until its application context ends, so avoid long-running work inside a request while a transaction is open.
Typed Models
A model is a class that maps to a table. SQLAlchemy 2.0 introduced annotation-driven declarations: the Python type
inside Mapped[...] gives the column type and nullability, and mapped_column() adds everything the annotation cannot
say (primary key, length, defaults, constraints, indexes).
class Format(enum.Enum):
PAPERBACK = "paperback"
HARDCOVER = "hardcover"
EBOOK = "ebook"
book_tag = db.Table(
"book_tag",
sa.Column("book_id", ForeignKey("book.id", ondelete="CASCADE"), primary_key=True),
sa.Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
)
class User(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(sa.String(254), unique=True)
password_hash: Mapped[str] = mapped_column(sa.String(256))
is_admin: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
reviews: WriteOnlyMapped["Review"] = relationship(
back_populates="user", passive_deletes=True
)
def __repr__(self) -> str:
return f"<User {self.email}>"
class Author(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(sa.String(120), index=True)
bio: Mapped[str | None] = mapped_column(sa.Text)
books: Mapped[list["Book"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
order_by="Book.title",
)
class Book(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
isbn: Mapped[str] = mapped_column(sa.String(17), unique=True)
title: Mapped[str] = mapped_column(sa.String(200), index=True)
published_year: Mapped[int | None]
price: Mapped[float] = mapped_column(sa.Numeric(8, 2, asdecimal=False), default=0)
format: Mapped[Format] = mapped_column(default=Format.PAPERBACK)
cover_path: Mapped[str | None] = mapped_column(sa.String(255))
author_id: Mapped[int] = mapped_column(ForeignKey("author.id", ondelete="CASCADE"))
author: Mapped[Author] = relationship(back_populates="books")
reviews: WriteOnlyMapped["Review"] = relationship(
back_populates="book",
cascade="all, delete-orphan",
passive_deletes=True,
)
tags: Mapped[list["Tag"]] = relationship(
secondary=book_tag, back_populates="books"
)
def __repr__(self) -> str:
return f"<Book {self.isbn}>"
class Tag(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(sa.String(40), unique=True)
books: Mapped[list[Book]] = relationship(secondary=book_tag, back_populates="tags")
class Review(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
rating: Mapped[int]
body: Mapped[str] = mapped_column(sa.Text, default="")
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
book_id: Mapped[int] = mapped_column(ForeignKey("book.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
book: Mapped[Book] = relationship(back_populates="reviews")
user: Mapped[User] = relationship(back_populates="reviews")
__table_args__ = (
sa.CheckConstraint("rating BETWEEN 1 AND 5", name="rating_range"),
)
print([t.name for t in db.metadata.sorted_tables])
print(User.__tablename__, Book.__tablename__, [c.name for c in Review.__table__.constraints if c.name][:2])
What the annotations do:
-
Mapped[int]isNOT NULL INTEGER;Mapped[int | None](orOptional[int]) is nullable.Mapped[str]maps toVARCHARwithout a length, which PostgreSQL accepts and MySQL does not; spell the length withsa.String(n)when it matters. -
A
Mapped[Format]annotation with a Pythonenum.Enumcreates anEnumcolumn; the names of the members are what is stored by default. -
mapped_column(primary_key=True)on an integer is an autoincrementing key on every backend that supports it. -
The model’s table name is generated from the class name (
Authorbecomesauthor,BookTagwould bebook_tag); set__tablename__explicitly when you adopt an existing schema, or usedisable_autonaming=Trueand name every table yourself. -
db.Table("book_tag", ...)is a plainsqlalchemy.Tableregistered on the right metadata for the engine/bind; it has no model class, which is the right shape for a pure association table of a many-to-many relationship. -
Mixins hold shared columns. For timestamps prefer
server_default=func.now()(computed by the database, so the clock of the application servers is irrelevant) ordefault=a callable.
Timestamps and the utcnow() Trap
The Full-Stack Flask book (chapter 8) writes default=datetime.utcnow(). The parentheses call the function when the
class is defined, so every row gets the time the module was imported, and datetime.utcnow itself is deprecated since
Python 3.12 because it returns a naive datetime. Pass a callable that returns a timezone-aware value, or let the database
fill it:
from datetime import timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class AuditedMixin:
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
print(utc_now().tzinfo, AuditedMixin.created_at.__class__.__name__)
default and onupdate run in Python when the ORM flushes; server_default and server_onupdate live in the DDL.
SQLite does not store time zones, so a timezone-aware column round-trips as naive there; PostgreSQL’s
TIMESTAMP WITH TIME ZONE keeps the offset.
The Bookshelf Data Model
Dataclass Models
book_tag)SQLAlchemy 2.0 can also generate __init__, __repr__ and __eq__ for you by mixing MappedAsDataclass into the
base. Flask-SQLAlchemy 3.1 accepts that base as model_class:
# norun (verified on SQLAlchemy 2.0.54 with Flask-SQLAlchemy 3.1.1)
from sqlalchemy.orm import MappedAsDataclass
class DataclassBase(MappedAsDataclass, DeclarativeBase):
pass
dc_db = SQLAlchemy(model_class=DataclassBase)
class Note(dc_db.Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
text: Mapped[str]
pinned: Mapped[bool] = mapped_column(default=False)
print(Note(text="reread chapter 4"))
Printing the instance gives Note(id=None, text='reread chapter 4', pinned=False) on SQLAlchemy 2.0.54. With
SQLAlchemy 2.1.3 the same code raises InvalidRequestError ("is already a dataclass") while the extension builds db.Model,
probably because Flask-SQLAlchemy 3.1.1 was released before SQLAlchemy 2.1 changed dataclass setup; stay on SQLAlchemy 2.0.x if you need
MappedAsDataclass with this extension, or use plain declarative models. Dataclass semantics (field ordering, init=False on the key, default= rules) apply to every column, which is worth
it for small domain objects and awkward when most columns have server-side defaults. The Bookshelf models above use the
plain form.
Relationships, Cascades and Loading
A relationship is a Python attribute that follows a foreign key. Declare both sides and connect them with
back_populates (the explicit, type-checkable form); the older backref= argument creates the reverse attribute
invisibly, which type checkers and readers cannot see. The Cookbook (chapter 3) uses backref; it still works, but new
code names both sides.
def seeded_app():
"""A fresh application with the Bookshelf tables and sample rows."""
new_app = create_app({"TESTING": True})
with new_app.app_context():
db.create_all()
password = generate_password_hash(secrets.token_urlsafe(16))
ada = User(email="ada@example.com", password_hash=password, is_admin=True)
martin = Author(name="Robert C. Martin", bio="Software craftsman")
clean_code = Book(
isbn="978-0132350884",
title="Clean Code",
published_year=2008,
price=33.5,
format=Format.PAPERBACK,
author=martin,
)
clean_arch = Book(
isbn="978-0134494166", title="Clean Architecture", price=29.99, author=martin
)
clean_code.tags = [Tag(name="craft"), Tag(name="design")]
clean_arch.tags = [clean_code.tags[1]]
db.session.add_all([ada, martin])
db.session.flush()
clean_code.reviews.add(Review(rating=5, body="Essential.", user=ada))
db.session.commit()
return new_app
app = seeded_app()
with app.app_context():
martin = db.session.scalar(db.select(Author))
clean_arch, clean_code = martin.books
print([b.title for b in martin.books])
print([t.name for t in clean_arch.tags], [b.isbn for b in clean_arch.tags[0].books])
print(db.session.scalar(sa.select(func.count()).select_from(clean_code.reviews.select().subquery())))
-
One-to-many (
Author.booksandBook.author): the foreign key is on the "many" side. -
Many-to-many (
Book.tagsandTag.books):secondary=names the association table. SQLAlchemy inserts and deletes the association rows when you append or remove from either list. If the association itself needs extra columns (a "reading list" with a date added), model it as a class and use the association object pattern instead. -
One-to-one: a one-to-many with
uselist=False, or aMapped[Profile | None]annotation, plus a unique constraint on the foreign key. -
Cascades decide what happens to children when the parent is touched.
cascade="all, delete-orphan"onAuthor.booksmeans deleting an author deletes their books, and removing a book fromauthor.booksdeletes it. The default cascade (save-update, merge) never deletes anything.ondelete="CASCADE"on the foreign key makes the database do the same on a plainDELETE; withpassive_deletes=Truethe ORM trusts the database instead of loading the children to delete them one by one, which matters for large collections. -
Lazy loading is the default: a relationship loads on first access, with one extra query per parent object when you iterate. That is the classic N+1 problem; load what you will render with
selectinload()orjoinedload()(see Querying the 2.0 Way).
WriteOnlyMapped Instead of lazy="dynamic"
The Cookbook’s lazy='dynamic' (a relationship that returns a query object) is a legacy feature in SQLAlchemy 2.0.
Its replacement is WriteOnlyMapped: the collection is never loaded into memory implicitly; you add and remove items
through .add(), .remove() and build a select() with .select() when you need to read them. The example above
used clean_code.reviews.add(...); counting goes through a select() because the collection has no count(). Use it for collections that can grow without bound
(reviews of a popular book, audit rows) and a plain Mapped[list[...]] for small, bounded ones.
with app.app_context():
book = db.session.scalar(db.select(Book).filter_by(isbn="978-0132350884"))
top = db.session.scalars(
book.reviews.select().where(Review.rating >= 4).order_by(Review.created_at.desc())
).all()
print(len(top), top[0].body, top[0].user)
lazy="dynamic" and db.dynamic_loader (the extension’s name for it) remain available in 3.1, but the SQLAlchemy
project documents them as legacy, so new models use write-only collections.
Creating Tables: create_all, flask shell and the CLI
db.create_all() issues CREATE TABLE IF NOT EXISTS for every model that has been imported (registered on the
metadata), for all binds. Two rules follow from "that has been imported": the models module must be imported before the
call, and the call needs an application context, which is why the Cookbook’s and the SQL chapter’s db.create_all()
lines sit inside with app.app_context():. Existing tables are not altered: adding a column to a model and calling
create_all again does nothing. For schema evolution use migrations (Database Migrations page of this section).
import click
cli_app = create_app({"TESTING": True})
@cli_app.cli.command("init-db")
@click.option("--drop", is_flag=True, help="Drop all tables first (development only).")
def init_db_command(drop):
"""Create the Bookshelf tables."""
if drop:
db.drop_all()
db.create_all()
click.echo("Initialized the database.")
runner = cli_app.test_cli_runner()
result = runner.invoke(args=["init-db", "--drop"])
print(result.output.strip())
Source: Flask: Custom Commands
create_all(bind_key="analytics") and drop_all(bind_key=...) restrict the call to one bind (pass "__all__", the
default, for every bind). db.reflect() loads tables that already exist in the database into the metadata without
models, which is useful for scripts against a legacy schema.
flask shell. The extension registers a shell context processor, so flask shell starts with db and every model
class already defined, which is the quickest way to explore data and run db.create_all() by hand as Full-Stack Flask
and React does. Pass add_models_to_shell=False to the constructor to turn that off. Table existence can be checked
with the inspector (the replacement for the removed db.engine.table_names()):
with app.app_context():
print(sa.inspect(db.engine).get_table_names())
Querying the 2.0 Way
The ORM’s current query API is the Core select() construct executed by a Session. A statement is built, passed to
db.session.execute() (rows), db.session.scalars() (one column, usually the entity) or db.session.scalar() (the
first column of the first row), and the result is consumed.
with app.app_context():
# all books ordered by title: scalars() unwraps the one-column rows
books = db.session.scalars(db.select(Book).order_by(Book.title)).all()
print([b.title for b in books])
# primary-key lookup: uses the session's identity map first
book = db.session.get(Book, 1)
print(book, db.session.get(Book, 999))
# filter_by uses keyword equality, where() takes SQL expressions
one = db.session.scalar(db.select(Book).filter_by(isbn="978-0134494166"))
cheap = db.session.scalars(db.select(Book).where(Book.price < 30)).all()
print(one.title, [b.title for b in cheap])
# join, aggregate and group
stmt = (
db.select(Author.name, func.count(Book.id).label("n"))
.join(Book)
.group_by(Author.id)
.having(func.count(Book.id) > 1)
)
print([tuple(r) for r in db.session.execute(stmt)])
# exists / count
total = db.session.scalar(db.select(func.count()).select_from(Book))
has_reviews = db.session.scalar(
db.select(sa.exists().where(Review.book_id == Book.id)).select_from(Book).limit(1)
)
print(total, has_reviews)
-
db.select(...)issqlalchemy.select; the extension just exposes it. -
db.session.get(Model, pk)replacesModel.query.get(pk); the 1.xQuery.getis legacy and has been deprecated since SQLAlchemy 1.4. -
scalars().all(),.first(),.one()(exactly one, otherwise an exception) and.one_or_none()select how many rows you expect.scalar_one()andscalar_one_or_none()exist onexecute()results. -
Selecting
Author.name, func.count(...)returns rows, not entities; loop over them or index by name.
Eager Loading and the N+1 Problem
from sqlalchemy.orm import joinedload, selectinload
with app.app_context():
stmt = db.select(Author).options(selectinload(Author.books).selectinload(Book.tags))
authors = db.session.scalars(stmt).all()
print([(a.name, [(b.title, [t.name for t in b.tags]) for b in a.books]) for a in authors])
book = db.session.scalar(
db.select(Book).options(joinedload(Book.author)).where(Book.id == 1)
)
print(book.author.name)
selectinload issues one extra SELECT ... WHERE id IN (...) per relationship level and is the safe default for
collections; joinedload uses a JOIN and suits many-to-one. Set echo (see Debugging and Observing the SQL) during development to count the
statements a view runs.
Searching with LIKE
The Cookbook (chapter 4) searches with Model.query.filter(Model.title.like('%' + q + '%')). User input can contain %
and _, which are wildcards, so escape it. SQLAlchemy 2.0 has operators that do it for you:
def search_books(term: str):
stmt = db.select(Book).where(Book.title.icontains(term, autoescape=True))
return db.session.scalars(stmt.order_by(Book.title)).all()
with app.app_context():
print([b.title for b in search_books("clean")], search_books("100%"))
Values are always sent as bound parameters, so none of these queries is open to SQL injection. The dangerous
construct is db.session.execute(f"SELECT ... {term}") (and text() with string formatting); when you must write
literal SQL, use sa.text("... WHERE title = :title") with params={"title": term}.
get_or_404 and Friends
Three extension methods combine a query and an HTTP error, so views do not repeat the "if not found: abort(404)" code:
| Method | Behaviour |
|---|---|
|
|
|
The first row of a |
|
Exactly one row; 404 if there is none, and |
or404_app = seeded_app()
@or404_app.get("/books/<int:book_id>")
def book_detail(book_id: int):
book = db.get_or_404(Book, book_id, description="No such book.")
return jsonify(id=book.id, title=book.title, author=book.author.name)
@or404_app.get("/authors/<name>")
def author_by_name(name: str):
author = db.one_or_404(db.select(Author).filter_by(name=name))
return jsonify(id=author.id, books=[b.title for b in author.books])
client = or404_app.test_client()
print(client.get("/books/1").get_json())
print(client.get("/books/999").status_code, client.get("/authors/Nobody").status_code)
The error body is Flask’s HTML 404 page unless you register an error handler (see the Error Handling page of this
section), and the optional description is what that page shows. Model.query.get_or_404(id) and first_or_404() on a
query object are the legacy spellings that remain in the older books.
Pagination
db.paginate(select, ...) runs the page query and a COUNT(*) and returns a Pagination object.
| Argument | Meaning |
|---|---|
|
The current page. Defaults to the |
|
Items per page. Defaults to the |
|
Upper bound for a user-supplied |
|
|
|
|
page_app = seeded_app()
@page_app.get("/books")
def list_books():
page = db.paginate(db.select(Book).order_by(Book.title), max_per_page=50)
return jsonify(
items=[{"id": b.id, "title": b.title} for b in page],
page=page.page,
pages=page.pages,
total=page.total,
has_next=page.has_next,
next_num=page.next_num,
)
page_client = page_app.test_client()
print(page_client.get("/books?per_page=1&page=2").get_json())
print(page_client.get("/books?page=9").status_code, page_client.get("/books?page=0").status_code)
A Pagination has items, page, per_page, total, pages, has_prev, has_next, prev_num, next_num, the
navigation methods prev() and next(), and iter_pages(), which yields page numbers with None standing for a gap:
with page_app.test_request_context("/books?page=7"):
sample = db.paginate(db.select(Book), per_page=1, error_out=False, count=False)
sample.total = 20 # supplied by the caller because count=False
print(list(sample.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1)))
In a Jinja template (see the Templates page of this section) the page links are:
{% for p in page.iter_pages() %}
{% if p %}
<a href="{{ url_for('list_books', page=p) }}"{% if p == page.page %} aria-current="page"{% endif %}>{{ p }}</a>
{% else %}
<span>…</span>
{% endif %}
{% endfor %}
Book errata. Model.query.paginate(page, 10) with positional arguments (Flask Framework Cookbook chapters 4 and 7,
Full-Stack Flask and React chapter 11) raises TypeError since Flask-SQLAlchemy 3.0, where the arguments became
keyword-only. from flask_sqlalchemy import Pagination was removed in 3.1; annotate with
flask_sqlalchemy.pagination.Pagination if you need the type. For tables where offset paging gets slow (large offsets
scan and discard rows) or where rows are inserted while users page, use keyset pagination: order by an indexed key and
filter where(Book.id > last_seen_id).limit(n).
Sessions and Transactions per Request
db.session is a scoped_session. Flask-SQLAlchemy 3.0 and newer scope it to the application context (older
versions scoped it to the thread or greenlet id). Concretely:
-
The first access of
db.sessionin a context creates aSession; the session starts a transaction lazily (autobegin) and checks out a connection when the first statement runs. -
Nothing is saved until you commit.
db.session.add(obj)marks it pending;flush()sends the SQL inside the transaction without ending it (and fills in primary keys);commit()flushes and commits. -
When the context pops, the extension’s
teardown_appcontexthandler closes and removes the session, which rolls back anything uncommitted and returns the connection to the pool, even when the view raised an exception. A forgottencommit()therefore loses the data silently. -
After
commit(), loaded objects are expired (expire_on_commit=True): the next attribute access reloads them. That is correct behaviour, but it costs a query per object, and objects touched after the context has ended raiseDetachedInstanceError. Passsession_options={"expire_on_commit": False}to the constructor to keep the values, accepting that they can be stale. -
rollback()after an error is mandatory. After an exception during a flush the session is in a failed state and every further statement raises until you roll back.
from sqlalchemy.exc import IntegrityError
tx_app = seeded_app()
@tx_app.post("/books")
def create_book():
data = request.get_json()
author = db.get_or_404(Author, data["author_id"])
book = Book(isbn=data["isbn"], title=data["title"], author=author)
db.session.add(book)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
abort(409, description="A book with this ISBN already exists.")
return jsonify(id=book.id, title=book.title), 201
payload = {"isbn": "978-0137081073", "title": "The Clean Coder", "author_id": 1}
tx_client = tx_app.test_client()
print(tx_client.post("/books", json=payload).status_code)
print(tx_client.post("/books", json=payload).status_code)
A Small Unit of Work
Wrap "commit or roll back" once instead of repeating it. This is the unit of work of the repository pattern that
Microservice APIs (chapter 7) builds by hand with sessionmaker: with Flask-SQLAlchemy the session factory, scoping
and cleanup already exist, so the pattern reduces to a context manager and plain functions.
from contextlib import contextmanager
@contextmanager
def unit_of_work():
try:
yield db.session
db.session.commit()
except Exception:
db.session.rollback()
raise
class BookRepository:
"""Persistence-only operations; no HTTP and no commits."""
def add(self, book: Book) -> None:
db.session.add(book)
def by_isbn(self, isbn: str) -> Book | None:
return db.session.scalar(db.select(Book).filter_by(isbn=isbn))
def register_book(isbn: str, title: str, author_id: int) -> int:
"""Service function: one transaction, repositories inside."""
repo = BookRepository()
with unit_of_work():
if repo.by_isbn(isbn):
raise ValueError("duplicate isbn")
book = Book(isbn=isbn, title=title, author_id=author_id)
repo.add(book)
db.session.flush()
return book.id
with tx_app.app_context():
print(register_book("978-0201633610", "Design Patterns", 1))
try:
register_book("978-0201633610", "Design Patterns", 1)
except ValueError as exc:
print("rejected:", exc)
Notes on transactions:
-
Do not call
db.session.begin()after the session has already started a transaction by itself (any earlierexecuteoraddtriggers autobegin); SQLAlchemy raises. Usecommit/rollbackas above, ordb.session.begin_nested()for a savepoint around a risky step. -
Row-level concurrency needs the database:
select(...).with_for_update()locks rows on PostgreSQL and MySQL, and aversion_id_colmapper argument gives optimistic locking. -
Bulk statements skip the unit of work:
db.session.execute(db.update(Book).where(...).values(...))anddb.session.execute(db.delete(...))run immediately and do not update objects already loaded in the session. -
In a Celery or other background task, push an app context first (
with app.app_context():); the session is scoped to it and is removed when it exits (see the Background Jobs page of this section).
Cascade delete (Full-Stack Flask and React, chapter 11). Prefer db.session.delete(author) with the cascades above
and a 204 response; deleting through a bulk delete() bypasses ORM cascades and relies on the database’s
ON DELETE CASCADE (which SQLite enforces only after PRAGMA foreign_keys=ON).
Multiple Databases: Binds
A bind is a named engine. SQLALCHEMY_DATABASE_URI is the default bind (key None); extra ones are declared in
SQLALCHEMY_BINDS, and a model chooses its bind with __bind_key__.
class ReportBase(DeclarativeBase):
pass
report_db = SQLAlchemy(model_class=ReportBase)
class Subscriber(report_db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(sa.String(254))
class PageView(report_db.Model):
__bind_key__ = "analytics"
id: Mapped[int] = mapped_column(primary_key=True)
path: Mapped[str] = mapped_column(sa.String(255))
multi_app = Flask(__name__)
multi_app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite://",
SQLALCHEMY_BINDS={"analytics": {"url": "sqlite://", "echo": False}},
)
report_db.init_app(multi_app)
with multi_app.app_context():
report_db.create_all() # every bind
report_db.session.add_all([Subscriber(email="reader@example.com"), PageView(path="/books")])
report_db.session.commit()
print(sorted(str(k) for k in report_db.engines), report_db.engines["analytics"].url)
print(report_db.session.scalar(sa.select(func.count()).select_from(PageView)))
print([t.name for t in report_db.metadatas["analytics"].sorted_tables], report_db.metadata.tables.keys())
-
Every application that uses an extension whose models name a bind key must configure that bind, or
create_all()raisesUnboundExecutionError. That is why this example uses a separatereport_dbinstead of adding the model to the Bookshelfdb. -
On SQLAlchemy 2.1.3 a model with
__bind_key__emits anSAWarning("Attribute name 'metadata' should be left reserved for the MetaData instance") when the class is created, because Flask-SQLAlchemy 3.1.1 assigns the per-bindmetadataattribute; the models still work. On 2.0.x there is no warning. -
Each bind key has its own
MetaData(db.metadatas[key], 3.0 and newer); the default one isdb.metadata. Foreign keys cannot cross binds. -
One session serves all binds and routes each statement to the engine of the model involved. A commit commits each engine’s transaction in turn; there is no two-phase commit, so cross-database writes are not atomic.
-
The Python-level value of a bind entry may be a URI string, a
URL, or a dict withurlplus engine options. -
A model for a read replica or a reporting database is the typical use; for several schemas in one database use
__table_args__ = {"schema": "..."}instead of a bind. -
Alembic and Flask-Migrate need a
multidbtemplate to migrate several binds (Database Migrations page).
Debugging and Observing the SQL
from flask_sqlalchemy.record_queries import get_recorded_queries
debug_app = create_app({"SQLALCHEMY_RECORD_QUERIES": True, "SQLALCHEMY_ECHO": False})
@debug_app.get("/slow-report")
def slow_report():
db.session.scalars(db.select(Book)).all()
stats = [
{"sql": q.statement.splitlines()[0][:40], "ms": round(q.duration * 1000, 2)}
for q in get_recorded_queries()
]
return jsonify(count=len(stats), first=stats[0]["sql"])
with debug_app.app_context():
db.create_all()
print(debug_app.test_client().get("/slow-report").get_json())
get_recorded_queries() returns the statements run in the current request (each has statement, parameters,
start_time, end_time, duration and location, the file and line that triggered it). Use it in development or
behind a profiling flag; turning on SQLALCHEMY_ECHO prints every statement to the log, and
SQLALCHEMY_TRACK_MODIFICATIONS plus the models_committed signal (a Blinker signal; it needs the application to
subscribe) helps audit changes but slows every flush. Use a database-side slow query log in production.
Testing Database Code
The reliable test setup builds a fresh application per test with an in-memory SQLite database. Because the extension gives
in-memory SQLite a StaticPool, every connection of the test sees the same database, and create_all() inside the app
context sets up the schema:
def make_test_app():
test_app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite://"})
with test_app.app_context():
db.create_all()
test_app.add_url_rule("/books", view_func=create_book, methods=["POST"])
return test_app
def test_duplicate_isbn_returns_409():
test_app = make_test_app()
with test_app.app_context():
db.session.add(Author(name="A"))
db.session.commit()
c = test_app.test_client()
body = {"isbn": "123", "title": "T", "author_id": 1}
assert c.post("/books", json=body).status_code == 201
assert c.post("/books", json=body).status_code == 409
test_duplicate_isbn_returns_409()
print("test passed")
Here the view function of the previous section is attached to the test application with add_url_rule; in the real
project the views live in blueprints that the factory registers. With pytest the same idea is a fixture; the code below follows the Flask testing page and
was run separately with pytest against a minimal package:
# norun (conftest.py; verified with pytest on a minimal bookshelf package)
import pytest
from bookshelf import create_app
from bookshelf.extensions import db
@pytest.fixture()
def app():
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite://"})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture()
def client(app):
return app.test_client()
Remember that SQLite is lenient: it does not enforce foreign keys unless asked, ignores VARCHAR lengths and accepts
mismatched types. Run the suite against the database you deploy (PostgreSQL in a container, see PostgreSQL in Docker for Development) in CI, and
keep SQLite for fast unit tests.
The Legacy Model.query API: Code You Will Meet
Every book of this section’s reading list uses Model.query, a class property that returns a legacy
flask_sqlalchemy.query.Query. It still works in 3.1 (it is created by db.Model), but SQLAlchemy documents the Query
object as legacy, the extension flags it as legacy in its own docs, and type checkers cannot see through it. When you
meet it, translate:
| Legacy (books) | Current |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Not needed: the extension registers the teardown itself |
Source: SQLAlchemy: Migrating to 2.0
The legacy Query code is not wrong in 3.1, just on the way out; mixing both styles in one project is a source of
confusion, so convert module by module.
Flask-SQLAlchemy-Lite
Flask-SQLAlchemy-Lite (0.2.1 on 2026-10-07, same maintainers) is a new extension that does less on purpose: it
manages engines and sessions from Flask config and stays as close to plain SQLAlchemy as possible. It does not create a
db.Model or metadata for you, has no Model.query, no automatic table names, no db.paginate() and no bind system
of its own (it uses SQLAlchemy’s session binds); in exchange it supports asyncio engines and sessions with the same
object and provides db.get_or_abort, db.one_or_abort and db.test_isolation().
from flask_sqlalchemy_lite import SQLAlchemy as LiteSQLAlchemy
class LiteBase(DeclarativeBase):
pass
class Shelf(LiteBase):
__tablename__ = "shelf"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(sa.String(40))
lite = LiteSQLAlchemy()
lite_app = Flask(__name__)
lite_app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite://"}
lite.init_app(lite_app)
with lite_app.app_context():
LiteBase.metadata.create_all(lite.engine)
lite.session.add(Shelf(label="Classics"))
lite.session.commit()
print(lite.session.scalars(sa.select(Shelf)).all()[0].label)
print(lite.get_or_abort(Shelf, 1).label)
Source: Flask-SQLAlchemy-Lite
Choose it for new projects that want plain SQLAlchemy 2.0 with no ORM extras or that need async sessions. Stay on
Flask-SQLAlchemy when the project already has models on db.Model, relies on db.paginate() and get_or_404, or on
binds and the existing ecosystem (Flask-Migrate documents Flask-SQLAlchemy, and Flask-Admin, Flask-Security and
others use db.session conventions). The two cannot be initialised on the same application, because both store
themselves as app.extensions["sqlalchemy"].
PostgreSQL in Docker for Development
SQLite hides differences in types, locking and constraint behaviour. A throwaway PostgreSQL container gives development
the same engine as production. Put the password in an untracked .env file generated once:
python -c 'import secrets; print("BOOKSHELF_DB_PASSWORD=" + secrets.token_urlsafe(24))' > .env
echo ".env" >> .gitignore
Source: Python: secrets
services:
db:
image: postgres:17
environment:
POSTGRES_USER: bookshelf
POSTGRES_DB: bookshelf
POSTGRES_PASSWORD: ${BOOKSHELF_DB_PASSWORD:?set it in .env}
ports:
- "127.0.0.1:5432:5432"
volumes:
- bookshelf-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bookshelf"]
interval: 5s
retries: 10
volumes:
bookshelf-pgdata:
Binding the port to 127.0.0.1 keeps the development database off the network. The Flask side is the URL.create
snippet from Configuration Keys with the same environment variables (docker compose --env-file .env up -d db, then
flask --app bookshelf run). For running the whole application in containers, see the Docker page of this site, and for the database engine itself the PostgreSQL pages of
SQL databases. In production use a managed or hardened server, TLS (sslmode=verify-full
in connect_args), a least-privilege database role for the application (migrations run with a different role), and
pool_pre_ping.
Asyncio and Flask-SQLAlchemy
Flask-SQLAlchemy builds synchronous engines and sessions only. async def views in Flask run each request in a thread
anyway (the Async, Gevent & Quart page of this section covers what that means), and an AsyncSession bound to an event
loop is not safe to share between those requests. If you need async database access, use Flask-SQLAlchemy-Lite’s async
engines (SQLALCHEMY_ASYNC_ENGINES), or Quart or FastAPI with SQLAlchemy’s asyncio extension.
Pitfalls and Security
| Pitfall | What to do |
|---|---|
|
Push a context ( |
|
Set the configuration before |
Tables missing, or a column not added |
|
Data lost |
Missing |
|
An earlier flush failed; call |
|
Do not use ORM objects outside the context that loaded them; serialise first, or load again. |
|
The query is not unique; add a unique constraint or use |
SQLite "database is locked", or foreign keys ignored |
Use short transactions; enable |
N+1 queries |
|
Two |
The second |
Offset pagination on a huge table |
Keyset pagination, |
Hardcoded URI or password in source control |
Environment or instance config; |
String-built SQL |
Always bound parameters; never f-strings in |
Logging |
Use |
OWASP’s SQL Injection Prevention Cheat Sheet and Database Security Cheat Sheet are the reference for the security items
above; get_or_404(Model, id) with an ID from the URL is safe, but it does not check that the current user may see
that row, which is an authorization decision (see the Authorization & Roles page of this section).
Where the Books Are Outdated
| In the books | Today |
|---|---|
|
2.0-style |
Untyped |
|
|
|
|
Always in an application context (required since 3.0); migrations for changes. |
|
The first is the default and no warning exists any more; the second was removed in 3.0, because committing in teardown hides errors. Commit explicitly. |
|
Removed in SQLAlchemy 2.0; |
|
A callable, timezone-aware, or |
|
|
|
|
|
It raises an error since 3.0; set the URI explicitly. |
Plain-text secrets in |
Environment variables, the instance folder, |
Source: Flask-SQLAlchemy: Changes
Recent Changes
| Version | Change |
|---|---|
Flask-SQLAlchemy 3.1.0 / 3.1.1 (2023) |
|
Flask-SQLAlchemy 3.0 (2022) |
Session scoped to the application context; engines created in |
SQLAlchemy 2.0 (January 2023) |
Typed ORM declarations, |
SQLAlchemy 2.1 |
The next series; Flask-SQLAlchemy 3.1.1 ran every example on this page against 2.1.3, but the extension’s own tests target 2.0.x, so pin and test when upgrading. |
Flask-SQLAlchemy-Lite 0.1 / 0.2 |
Minimal extension with sync and async engines; 0.2 adds the |
Source: Flask-SQLAlchemy: Changes