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 Session unit of work that tracks changes and flushes them as INSERT/UPDATE/DELETE statements 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

Engine, pools, dialects, select(), Session, DeclarativeBase, Mapped, mapped_column, relationship

SQLAlchemy 2.x

db.Model, db.session, db.engine, db.engines, db.create_all(), db.paginate(), db.get_or_404(), binds, flask shell context, query recording

Flask-SQLAlchemy

Migrations (flask db migrate)

Alembic via Flask-Migrate (separate page)

Database server

PostgreSQL, MySQL, SQLite and so on; the DB-API driver (psycopg, PyMySQL, sqlite3) is a separate package

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]

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)

The pieces, in order:

  • Base is a SQLAlchemy 2.x DeclarativeBase subclass. This is the base the SQLAlchemy 2.0 documentation recommends, and since Flask-SQLAlchemy 3.1.0 you pass it as model_class. The older sqlalchemy.ext.declarative.declarative_base() function (used in Microservice APIs, chapter 7) still exists as a legacy spelling; new code uses DeclarativeBase.

  • 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 same db is shared by every application you create (tests build many).

  • Metadata belongs on Base. Since 3.1.0 the metadata= argument of SQLAlchemy(...) is ignored when you use a 2.x style base; put the MetaData (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.Model is then a class built from Base that adds automatic table names (BookTag becomes book_tag) and the __bind_key__ support covered in Multiple Databases: Binds. Pass disable_autonaming=True to turn the naming off.

  • The extension also exposes names from sqlalchemy and sqlalchemy.orm as 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 keeps db.select for 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))

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

SQLALCHEMY_DATABASE_URI

none

The URI of the default engine, a string or a sqlalchemy.engine.URL. Either this or SQLALCHEMY_BINDS must be set. Since 3.0 there is no implicit in-memory SQLite database.

SQLALCHEMY_ENGINE_OPTIONS

{}

Keyword arguments for sqlalchemy.create_engine() of the default engine. Higher precedence than the engine_options argument of the constructor. Applies only to the default bind (3.0 and newer).

SQLALCHEMY_BINDS

none

A dictionary mapping bind keys to a URI string, a URL or a dict with a "url" key and engine options. See Multiple Databases: Binds.

SQLALCHEMY_ECHO

False

Default echo and echo_pool for every engine: logs SQL statements and pool events through the sqlalchemy.engine logger. A development aid, not a production setting.

SQLALCHEMY_RECORD_QUERIES

False

Record the statements run during a request so that get_recorded_queries() can return them. Not switched on automatically by debug or testing mode since 3.0. See Debugging and Observing the SQL.

SQLALCHEMY_TRACK_MODIFICATIONS

False

Emit the models_committed and before_models_committed signals with the changed models. Has measurable overhead and is off by default since 3.0 (older tutorials told you to set it to False to silence a warning that no longer exists).

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))

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

pool_size

5

Persistent connections kept per process. Multiply by the number of Gunicorn workers when you size the database’s max_connections.

max_overflow

10

Extra connections allowed beyond pool_size under load; they are closed when returned.

pool_timeout

30

Seconds to wait for a connection before TimeoutError; a request thread blocked here usually means a leaked session or too small a pool.

pool_recycle

-1

Replace connections older than this many seconds. Set it below any proxy or server idle timeout.

pool_pre_ping

False

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.

connect_args

{}

Dictionary passed to the DB-API connect(): SSL options, timeouts, application_name.

isolation_level

driver default

For example "READ COMMITTED" or "SERIALIZABLE" for every connection.

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())

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] is NOT NULL INTEGER; Mapped[int | None] (or Optional[int]) is nullable. Mapped[str] maps to VARCHAR without a length, which PostgreSQL accepts and MySQL does not; spell the length with sa.String(n) when it matters.

  • A Mapped[Format] annotation with a Python enum.Enum creates an Enum column; 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 (Author becomes author, BookTag would be book_tag); set __tablename__ explicitly when you adopt an existing schema, or use disable_autonaming=True and name every table yourself.

  • db.Table("book_tag", ...) is a plain sqlalchemy.Table registered 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) or default= 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

erDiagram AUTHOR ||--o{ BOOK : writes BOOK ||--o{ REVIEW : receives USER ||--o{ REVIEW : posts BOOK }o--o{ TAG : "tagged with" AUTHOR { int id PK string name string bio } BOOK { int id PK string isbn UK string title int author_id FK } REVIEW { int id PK int rating int book_id FK int user_id FK } USER { int id PK string email UK boolean is_admin } TAG { int id PK string name UK }

Dataclass Models

The Bookshelf models: one-to-many (author to books, book to reviews, user to reviews) and many-to-many (books and tags through 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.books and Book.author): the foreign key is on the "many" side.

  • Many-to-many (Book.tags and Tag.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 a Mapped[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" on Author.books means deleting an author deletes their books, and removing a book from author.books deletes it. The default cascade (save-update, merge) never deletes anything. ondelete="CASCADE" on the foreign key makes the database do the same on a plain DELETE; with passive_deletes=True the 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() or joinedload() (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())

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(...) is sqlalchemy.select; the extension just exposes it.

  • db.session.get(Model, pk) replaces Model.query.get(pk); the 1.x Query.get is 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() and scalar_one_or_none() exist on execute() 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

db.get_or_404(Model, ident, description=None)

session.get() by primary key; aborts with 404 when the row is missing.

db.first_or_404(select, description=None)

The first row of a select(); 404 if there is none.

db.one_or_404(select, description=None)

Exactly one row; 404 if there is none, and MultipleResultsFound (a 500) if there are several.

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

page

The current page. Defaults to the page query argument of the request, or 1 outside a request.

per_page

Items per page. Defaults to the per_page query argument, or 20.

max_per_page

Upper bound for a user-supplied per_page; defaults to 100, None for no limit. Always keep a limit on public endpoints.

error_out

True (default): 404 for a page past the end or a page/per_page below 1 or not an integer. False: return an empty page instead.

count

True (default): run the extra count query. Set False on very large tables and supply total yourself.

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>&hellip;</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:

sequenceDiagram participant R as Request participant C as App context participant S as db.session participant P as Engine pool R->>C: push app and request context C->>S: first db.session use creates a Session S->>P: connection checked out at first statement S->>P: commit or rollback ends the transaction R->>C: view returns, context pops C->>S: teardown_appcontext calls session.remove S->>P: connection returned to the pool
The lifetime of the session: created on first use, removed when the application context pops
  • The first access of db.session in a context creates a Session; 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_appcontext handler 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 forgotten commit() 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 raise DetachedInstanceError. Pass session_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 earlier execute or add triggers autobegin); SQLAlchemy raises. Use commit/rollback as above, or db.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 a version_id_col mapper argument gives optimistic locking.

  • Bulk statements skip the unit of work: db.session.execute(db.update(Book).where(...).values(...)) and db.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() raises UnboundExecutionError. That is why this example uses a separate report_db instead of adding the model to the Bookshelf db.

  • On SQLAlchemy 2.1.3 a model with __bind_key__ emits an SAWarning ("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-bind metadata attribute; 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 is db.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 with url plus 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 multidb template 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

User.query.get(5)

db.session.get(User, 5)

User.query.get_or_404(5)

db.get_or_404(User, 5)

User.query.filter_by(email=e).first()

db.session.scalar(db.select(User).filter_by(email=e))

User.query.filter_by(email=e).first_or_404()

db.first_or_404(db.select(User).filter_by(email=e))

User.query.filter(User.id > 3).all()

db.session.scalars(db.select(User).where(User.id > 3)).all()

Book.query.order_by(Book.title).paginate(page, 10)

db.paginate(db.select(Book).order_by(Book.title), page=page, per_page=10)

Book.query.count()

db.session.scalar(db.select(func.count()).select_from(Book))

Book.query.filter_by(...).delete()

db.session.execute(db.delete(Book).where(...)) or delete loaded objects with db.session.delete(obj)

db.session.query(Book).join(Author)

db.select(Book).join(Author)

db.Column(db.String(80)), db.relationship(..., backref=..., lazy='dynamic')

Mapped[str] = mapped_column(sa.String(80)), relationship(back_populates=...), WriteOnlyMapped

db.engine.table_names()

sa.inspect(db.engine).get_table_names() (the method was removed in SQLAlchemy 2.0)

from sqlalchemy.ext.declarative import declarative_base

from sqlalchemy.orm import DeclarativeBase

@app.teardown_appcontext with db.session.remove() written by hand

Not needed: the extension registers the teardown itself

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)

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

RuntimeError: Working outside of application context

Push a context (with app.app_context():) for scripts, tests and background jobs; requests and CLI commands already have one.

RuntimeError: ... SQLALCHEMY_DATABASE_URI or SQLALCHEMY_BINDS must be set

Set the configuration before db.init_app(app).

Tables missing, or a column not added

create_all creates only absent tables and sees only imported models. Migrate schema changes with Alembic.

Data lost

Missing commit(); teardown rolls back.

PendingRollbackError / "transaction is inactive"

An earlier flush failed; call db.session.rollback() in the except block that caught it.

DetachedInstanceError or stale data after commit

Do not use ORM objects outside the context that loaded them; serialise first, or load again.

MultipleResultsFound from one_or_404

The query is not unique; add a unique constraint or use first_or_404.

SQLite "database is locked", or foreign keys ignored

Use short transactions; enable PRAGMA foreign_keys=ON with an engine connect event listener.

N+1 queries

selectinload / joinedload; verify with echo or get_recorded_queries().

Two SQLAlchemy() instances on one app

The second init_app raises; use binds, or one extension and several metadata objects.

Offset pagination on a huge table

Keyset pagination, count=False, or an indexed sort key.

Hardcoded URI or password in source control

Environment or instance config; URL.create; .env ignored by git. Reading a key from app.config in a config.py committed to git is the same mistake.

String-built SQL

Always bound parameters; never f-strings in text() or execute().

Logging str(engine.url)

Use render_as_string(hide_password=True).

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

Model.query everywhere (Flask Framework Cookbook editions, Full-Stack Flask and React)

2.0-style db.session.execute(select), db.session.get, db.get_or_404, db.paginate; see The Legacy Model.query API: Code You Will Meet.

Untyped db.Column(db.Integer, ...) models, backref, lazy='dynamic'

Mapped and mapped_column, back_populates, WriteOnlyMapped.

Query.paginate(page, 10) positional

db.paginate(select, page=..., per_page=...); keyword-only since 3.0; Pagination import removed in 3.1.

db.create_all() at import time or without a context (older Cookbook, 2019 edition)

Always in an application context (required since 3.0); migrations for changes.

SQLALCHEMY_TRACK_MODIFICATIONS = False boilerplate and SQLALCHEMY_COMMIT_ON_TEARDOWN

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.

db.engine.table_names()

Removed in SQLAlchemy 2.0; sa.inspect(db.engine).get_table_names().

default=datetime.utcnow() (Full-Stack Flask and React)

A callable, timezone-aware, or server_default=func.now(); see Timestamps and the utcnow() Trap.

declarative_base() from sqlalchemy.ext.declarative (Microservice APIs chapter 7, SQLAlchemy 1.x)

DeclarativeBase; the declarative extension path is deprecated.

session.query(...) and sessionmaker hand-wired in Flask services

db.session and db.select(); the extension already manages scoping and teardown.

SQLALCHEMY_DATABASE_URI unset gives an in-memory database (Flask-SQLAlchemy 2.x)

It raises an error since 3.0; set the URI explicitly.

Plain-text secrets in config.py, such as SECRET_KEY or database passwords, in example repositories

Environment variables, the instance folder, secrets.token_hex(); see the Configuration page.

Recent Changes

Version Change

Flask-SQLAlchemy 3.1.0 / 3.1.1 (2023)

model_class accepts a SQLAlchemy 2.x DeclarativeBase subclass (and MappedAsDataclass); metadata= is ignored for such bases; disable_autonaming added; Pagination import removed.

Flask-SQLAlchemy 3.0 (2022)

Session scoped to the application context; engines created in init_app; SQLite paths relative to the instance folder; SQLALCHEMY_COMMIT_ON_TEARDOWN, use_native_unicode and implicit in-memory DB removed; keyword-only arguments; per-bind metadata; db.paginate, db.get_or_404, db.first_or_404 and db.one_or_404 added; Model.query kept but legacy.

SQLAlchemy 2.0 (January 2023)

Typed ORM declarations, select() as the single query form, Session.get, write-only relationships, DeclarativeBase, MappedAsDataclass; legacy Query and lazy="dynamic" kept for compatibility.

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 get_or_abort, one_or_abort and test_isolation helpers.

References

Books consulted for the concept narrative (publisher pages only; nothing is copied):