🔁 CRUD Operations with SQLAlchemy
Create, Read, Update, Delete — the four verbs behind almost every feature you'll ever build. This lesson shows how to perform each one through the SQLAlchemy session, how to query and paginate data, and how to wrap it all in transactions so your data stays consistent even when things go wrong.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the session and the unit-of-work pattern behind
add/commit - Create single and related records, and insert in bulk
- Read data with the modern
db.session.execute(db.select(...))API, including filtering, ordering, and pagination - Update and delete records safely, including cascade and soft deletes
- Wrap operations in transactions and avoid the N+1 query problem
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a complete CRUD flow for a Flask blog.
In This Lesson
What CRUD Means
CRUD stands for Create, Read, Update, Delete — the four fundamental operations on stored data. Almost every feature you build is some combination of these four: signing up is a Create, a profile page is a Read, editing settings is an Update, closing an account is a Delete.
💡 A useful analogy: Picture a library. Create is shelving a new book; Read is looking one up in the catalog; Update is correcting its record; Delete is removing it for good. Flask-SQLAlchemy is the librarian who does all four for you when you ask in Python.
The Session
Every CRUD operation goes through db.session. The session is a staging area: you add, modify, and mark objects for deletion, and nothing actually hits the database until you call commit(). This is the unit of work pattern — a batch of changes applied as one all-or-nothing transaction.
| Call | What it does |
|---|---|
db.session.add(obj) | Stage a new or modified object |
db.session.add_all([...]) | Stage several objects at once |
db.session.delete(obj) | Mark an object for deletion |
db.session.commit() | Write all staged changes as one transaction |
db.session.rollback() | Discard everything staged since the last commit |
db.session.flush() | Send SQL now (e.g. to get a generated id) without committing |
✅ Why the unit of work is a gift
Because staged changes commit together, they're atomic: either all succeed or none do. That means you can never leave the database half-updated — say, an order created but its line items missing — as long as they share one commit.
Create
Creating a record is three steps: build the object, add it, commit. After the commit, database-generated values like the auto-increment id are populated back onto your object.
user = User(username="john_doe", email="john@example.com")
db.session.add(user)
db.session.commit()
print(user.id) # now has a real value from the database
Creating related records
Relationships let you link objects without manually juggling foreign keys. Append a child to a parent's collection and SQLAlchemy sets the foreign key for you on commit.
user = User(username="jane_doe", email="jane@example.com")
post = Post(title="My First Post", content="Hello, world!")
# The relationship sets post.user_id automatically
user.posts.append(post)
db.session.add(user) # adding the parent cascades to the new child
db.session.commit()
Bulk inserts
users = [
User(username=f"user{i}", email=f"user{i}@example.com")
for i in range(1, 4)
]
db.session.add_all(users)
db.session.commit()
⚠️ Don't forget the commit
A very common beginner bug is calling add() but never commit(). The object sits in the session, seems fine during the request, then vanishes — because it was never actually written. If a change should persist, it needs a commit.
Read & Query
SQLAlchemy 2.0 unifies querying around db.select() plus db.session.execute(). The older Model.query.all() style still works in Flask-SQLAlchemy, but the select() style is the modern, recommended one and reads the same whether you're in Flask or plain SQLAlchemy.
Fetching rows
# All users
users = db.session.execute(db.select(User)).scalars().all()
# The first match
user = db.session.execute(db.select(User)).scalars().first()
# By primary key
user = db.session.get(User, 1) # returns None if missing
# By primary key or 404 (handy in a route)
user = db.get_or_404(User, 1)
Filtering, ordering, limiting
from sqlalchemy import or_
# WHERE is_admin = true
stmt = db.select(User).where(User.is_admin.is_(True))
admins = db.session.execute(stmt).scalars().all()
# Multiple conditions are ANDed together
stmt = db.select(User).where(User.is_active.is_(True), User.age >= 18)
# OR
stmt = db.select(User).where(
or_(User.username == "admin", User.email.endswith("@admin.com"))
)
# Case-insensitive search + newest first + only 10 rows
stmt = (
db.select(User)
.where(User.username.ilike("%john%"))
.order_by(User.created_at.desc())
.limit(10)
)
results = db.session.execute(stmt).scalars().all()
Pagination
Flask-SQLAlchemy's db.paginate() turns a select statement into a page of results plus navigation metadata — exactly what a "page 3 of 27" listing needs.
page = request.args.get("page", 1, type=int)
pagination = db.paginate(
db.select(User).order_by(User.username),
page=page,
per_page=10,
)
pagination.items # the rows on this page
pagination.total # total row count
pagination.pages # total number of pages
pagination.has_next # is there a next page?
pagination.next_num # its page number
Aggregation
from sqlalchemy import func
# A simple count
total = db.session.scalar(db.select(func.count()).select_from(User))
# Count posts per user, most prolific first
stmt = (
db.select(User.username, func.count(Post.id).label("post_count"))
.join(Post)
.group_by(User.id)
.order_by(func.count(Post.id).desc())
)
rows = db.session.execute(stmt).all() # list of (username, post_count) tuples
Update
To update one record, load it, change its attributes, and commit. SQLAlchemy tracks which attributes changed and issues the minimal UPDATE.
user = db.session.get(User, 1)
user.username = "new_username"
user.email = "new_email@example.com"
db.session.commit()
Bulk updates
To change many rows without loading them all into memory, use an update() statement.
from sqlalchemy import update
stmt = (
update(User)
.where(User.is_active.is_(False))
.values(last_login=None)
)
db.session.execute(stmt)
db.session.commit()
💡 Atomic increments beat read-modify-write
To bump a counter, don't read the value into Python and add one — two simultaneous requests could both read the same number and lose an increment. Let the database do the arithmetic in a single statement so it's race-free:
stmt = (
update(Post)
.where(Post.id == post_id)
.values(view_count=Post.view_count + 1)
)
db.session.execute(stmt)
db.session.commit()
Delete
Deleting one record mirrors updating: load it, mark it for deletion, commit.
user = db.session.get(User, 1)
db.session.delete(user)
db.session.commit()
Cascade deletes
By default, deleting a parent does not delete its children — you could be left with orphaned posts pointing at a user who no longer exists. Configure the cascade on the relationship to delete children automatically.
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
# Deleting a user now deletes all of their posts too
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
)
📖 Soft deletes
Often you don't want to truly erase data — for audit trails or "undo," you'd rather hide it. A soft delete sets a deleted_at timestamp instead of removing the row, and your normal queries filter those rows out.
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(default=None)
def soft_delete(self) -> None:
self.deleted_at = datetime.now(timezone.utc)
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
Transactions & Performance
Handle errors with try / rollback
If any step in a multi-step write fails, roll the whole thing back so you never persist a half-finished change.
from sqlalchemy.exc import SQLAlchemyError
try:
user = User(username="taylor", email="taylor@example.com")
db.session.add(user)
post = Post(title="Hello", content="First post!", author=user)
db.session.add(post)
db.session.commit() # both rows, or neither
except SQLAlchemyError:
db.session.rollback() # undo everything staged
raise # let the caller / error handler decide
Avoid the N+1 query problem
Relationships load lazily by default: touch user.posts and SQLAlchemy fires a query. Loop over 100 users touching their posts and you've fired 101 queries — one for the users, one per user. That's the N+1 problem, and it's the single most common cause of a slow ORM-backed page.
from sqlalchemy.orm import selectinload
# BAD: 1 query for users, then 1 more per user = N+1 queries
users = db.session.execute(db.select(User).limit(100)).scalars().all()
for u in users:
print(len(u.posts)) # each access triggers another query
# GOOD: 2 queries total, no matter how many users
stmt = db.select(User).options(selectinload(User.posts)).limit(100)
users = db.session.execute(stmt).scalars().all()
for u in users:
print(len(u.posts)) # already loaded — no extra queries
✅ Eager-loading cheat sheet
selectinload— great default for collections (one-to-many, many-to-many).joinedload— best for one-to-one or small single-object relationships.- Index the columns you filter, join, and sort on — no eager-loading strategy fixes a missing index.
Hands-on Exercise
🏋️ Build a Blog's CRUD Routes
Objective: Wire the four CRUD verbs into Flask routes for a Post model.
Instructions:
- Assume a
Postmodel withtitleandcontent. - Write a route that lists posts newest-first, one that shows a single post (404 if missing), one that creates a post from form data, and one that deletes a post.
- Validate that title and content aren't empty, and roll back on any database error.
💡 Hint
Use db.get_or_404(Post, post_id) for the detail and delete routes so a bad id returns a clean 404. Wrap each write in try / except SQLAlchemyError with a db.session.rollback() and a flash message.
✅ Sample solution
from flask import Blueprint, render_template, request, redirect, url_for, flash
from sqlalchemy.exc import SQLAlchemyError
from app.extensions import db
from app.models import Post
bp = Blueprint("posts", __name__)
@bp.route("/")
def home():
# READ (list)
stmt = db.select(Post).order_by(Post.created_at.desc())
posts = db.session.execute(stmt).scalars().all()
return render_template("home.html", posts=posts)
@bp.route("/post/<int:post_id>")
def detail(post_id):
# READ (one)
post = db.get_or_404(Post, post_id)
return render_template("detail.html", post=post)
@bp.route("/post/new", methods=["GET", "POST"])
def create():
if request.method == "POST":
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
if not title or not content:
flash("Title and content are required.", "danger")
return render_template("form.html")
post = Post(title=title, content=content)
db.session.add(post)
try:
db.session.commit() # CREATE
flash("Post created!", "success")
return redirect(url_for("posts.detail", post_id=post.id))
except SQLAlchemyError:
db.session.rollback()
flash("Could not save the post.", "danger")
return render_template("form.html")
@bp.route("/post/<int:post_id>/delete", methods=["POST"])
def delete(post_id):
post = db.get_or_404(Post, post_id)
try:
db.session.delete(post) # DELETE
db.session.commit()
flash("Post deleted.", "success")
except SQLAlchemyError:
db.session.rollback()
flash("Could not delete the post.", "danger")
return redirect(url_for("posts.home"))
🎯 Quick Quiz
Question 1: You call db.session.add(user) but the user never appears in the database. What's the most likely cause?
Question 2: Looping over 100 users and reading user.posts fires 101 queries. What is this called, and how do you fix it?
Question 3: Why increment a counter with values(view_count=Post.view_count + 1) instead of reading it into Python and adding one?
Summary & Quiz
🎉 Key Takeaways
- CRUD — Create, Read, Update, Delete — underlies nearly every feature you build.
- All writes flow through
db.session; nothing persists until youcommit(). - Query the modern way with
db.session.execute(db.select(...)).scalars(), and paginate withdb.paginate(). - Wrap multi-step writes in try / rollback so failures never leave half-written data.
- Eager-load relationships (
selectinload) to defeat the N+1 problem, and index the columns you query.
📚 Further Reading
- Flask-SQLAlchemy — Querying & Pagination
- SQLAlchemy 2.0 — ORM Querying Guide
- SQLAlchemy 2.0 — Session Basics
🚀 What's Next?
You can now store and manipulate data through Flask-SQLAlchemy. Next we'll formalize the structure you've been hinting at with the Application Factory Pattern — the professional way to organize a growing Flask project.
🎉 CRUD mastered!
Create, read, update, delete — the whole data lifecycle is in your hands now.