Databases with Flask-SQLAlchemy
Define models, query them through the session, handle transactions without leaving the session broken, and avoid the lazy-loading traps that make pages slow.
Models and the session
# app/models.py
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash, check_password_hash
from .extensions import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc))
posts = db.relationship("Post", back_populates="author",
cascade="all, delete-orphan", lazy="select")
def set_password(self, raw):
self.password_hash = generate_password_hash(raw)
def check_password(self, raw):
return check_password_hash(self.password_hash, raw)
def __repr__(self):
return f"<User {self.email}>"
class Post(db.Model):
__tablename__ = "posts"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, default="")
published = db.Column(db.Boolean, default=False, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
author = db.relationship("User", back_populates="posts")
tags = db.relationship("Tag", secondary="post_tags", back_populates="posts")| Column type | Python value | Notes |
|---|---|---|
db.Integer | int | Pair with primary_key=True for ids |
db.String(n) | str | Always declare a length — it is required on MySQL |
db.Text | str | Unbounded text, no index by default |
db.Boolean | bool | Set nullable=False and a default |
db.Numeric(10, 2) | Decimal | Money; never use Float |
db.DateTime(timezone=True) | datetime | Store UTC, convert at the edge |
db.ForeignKey("users.id") | Related row | The column is indexed automatically |
db.relationship | Related object | back_populates keeps both sides in sync |
Querying
from sqlalchemy import func, select
from app.extensions import db
from app.models import Post, User
db.session.execute(db.select(Post)).scalars().all() # 2.x style
Post.query.filter_by(published=True).all() # legacy, still works
stmt = (db.select(Post)
.where(Post.published.is_(True), Post.title.ilike("%flask%"))
.order_by(Post.id.desc())
.limit(20))
db.session.execute(stmt).scalars().all()
db.session.get(Post, 42) # by primary key, None if absent
db.get_or_404(Post, 42) # raises 404 inside a request
db.session.execute(db.select(Post).filter_by(id=42)).scalar_one_or_none()
# counts and aggregates run in the database
db.session.execute(db.select(func.count()).select_from(Post)).scalar_one()
# eager loading: one query for posts, one for their authors
stmt = (db.select(Post)
.options(db.joinedload(Post.author), db.selectinload(Post.tags))
.where(Post.published.is_(True)))
posts = db.session.execute(stmt).scalars().all()
# pagination: page 1, 20 per page, errors_outside=True to 404 on out-of-range
page = db.paginate(db.select(Post).order_by(Post.id), page=1, per_page=20, error_out=False)
page.items, page.total, page.pages, page.has_next- The session is a unit-of-work and an identity map: query for the same row twice and you get the same Python object. It is created per request (Flask-SQLAlchemy scopes it to the app context), so never cache models across requests.
.first()addsLIMIT 1and returnsNone;.one()raises if there is not exactly one row;.scalar_one_or_none()is the explicit middle ground. Pick the one that encodes your intent.- Accessing
post.authorinside a loop issues one query per row. The fix isjoinedloadfor many-to-one andselectinloadfor one-to-many or many-to-many. db.paginateruns a COUNT plus a windowed SELECT. On a large table with a deep page offset, the count is the expensive half — cache it or use keyset pagination.
Transactions and constraints
from sqlalchemy.exc import IntegrityError
from flask import abort, flash, redirect, url_for
@app.post("/posts")
def create_post():
form = request.form
post = Post(title=form["title"].strip(), body=form.get("body", ""),
author_id=current_user.id)
db.session.add(post)
try:
db.session.commit() # one commit per request, at the end
except IntegrityError:
db.session.rollback() # mandatory before any further query
flash("A post with that title already exists.", "error")
return redirect(url_for("blog.new_post"))
return redirect(url_for("blog.detail", post_id=post.id))
# add many rows, then one commit
db.session.add_all(Post(title=f"Seed {i}") for i in range(100))
db.session.commit()
# insert a lot of rows efficiently
db.session.execute(db.insert(Post), [{"title": "a"}, {"title": "b"}])
db.session.commit()
# you need the primary key before the commit? flush, do not commit
db.session.add(post)
db.session.flush() # sends INSERT, keeps the transaction open
post.id # now populated- A failed commit leaves the session unusable. Any statement after an
IntegrityErrorwithout arollback()raisesPendingRollbackError, which hides the original problem in the traceback. - Keep transactions short. Holding one open across a network call or a template render holds locks, and on SQLite it blocks every other writer.
- Translate a unique-constraint violation into the right HTTP answer: a duplicate resource is
409 Conflict, a bad value is422or400. Letting the exception reach the 500 handler tells the client nothing.
⚠️
Lazy loading is the most common performance bug in Flask-SQLAlchemy code. Rendering a list of 50 posts and touching
post.author.email in the template issues 50 extra queries, all inside the template render where no one is looking. Load explicitly with selectinload or joinedload in the query, and prove the count stays flat with SQLAlchemy's echo flag or by asserting len(recorded_statements) in a test.FAQ
Where should db.create_all() be called?
In tests and throwaway prototypes. For anything with a schema you intend to change, use Flask-Migrate: it writes versioned migration files, and it is the only way to alter a table without dropping the data.
Why does my object disappear after a request?
It does not disappear, the session did: Flask-SQLAlchemy removes the session at the end of the app context, so instances become detached from any database connection. Re-query in the new request, or copy the values you need into a plain object before the context ends.
Related
Schema migrations with Flask-Migrate Authentication with Flask-Login and password hashing
Last refreshed 2026-09-18.