Skip to main content

🔁 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.

flowchart LR A[Create] --> B[Read] B --> C[Update] C --> D[Delete] D -.new record.-> A
💡 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.

flowchart LR A[Python objects] -->|add / delete| B[Session] B -->|commit| C[(Database)] B -->|rollback| A C -->|query loads| B
CallWhat 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:

  1. Assume a Post model with title and content.
  2. 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.
  3. 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 you commit().
  • Query the modern way with db.session.execute(db.select(...)).scalars(), and paginate with db.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

🚀 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.