Module 2: Doing Soft Deletes Right
Soft delete in SQLAlchemy: mixins and events
Capsule overview
You already have the base pattern in PostgreSQL: a deleted_at TIMESTAMPTZ NULL column + a partial index. Now comes the operational problem: in a codebase with 50 queries and a team of 10 people, how do you guarantee every new query includes WHERE deleted_at IS NULL? The answer "everyone always remembers" doesn't scale. What scales is automating the filter at the ORM level.
SQLAlchemy 2.0 offers three mechanisms for this: a before_compile event listener that injects the filter automatically, a custom query class that replaces the query factory with one that has the filter hardcoded, and a mixin with @declared_attr that only adds the column and the helper methods without touching the query. Each one has trade-offs. This capsule implements them all, compares them with concrete criteria, and recommends mixin + before_compile event listener as the default combination. It's the decision you'll take to the module project (capsule 08) and to the capstone project (module 8 of the guide).
By the end you'll have runnable code for all three approaches, criteria to defend your choice in a code review, and a clean escape hatch for the cases where you do need to see deleted records (audit, recovery, analytics). It's the module's most important technical capsule.
The problem: the forgotten filter in production
Picture this sequence. Your app has tasks with soft delete. The existing queries include WHERE deleted_at IS NULL. Code reviews are rigorous. Everything is fine.
A teammate implements a new endpoint:
@app.get("/tasks/recent")
async def recent_tasks(db: AsyncSession = Depends(get_session)):
result = await db.execute(
select(Task).order_by(Task.created_at.desc()).limit(20)
)
return result.scalars().all()
QA passes. Tests pass. Deploy. Three days later, support gets a ticket: "I'm seeing tasks I deleted in my recent feed." The bug is obvious in hindsight: where(Task.deleted_at.is_(None)) is missing. But nobody caught it in the code review because the endpoint was trivial and "nobody would think to write a query without that filter."
This bug is semantic, not a performance one. The SQL plan is correct, the query returns data, the types are right. It's just that the semantics are wrong. Unit tests typically don't catch it (who writes a specific test for "verify the endpoint doesn't return soft-deleted rows"?).
The solution isn't "more rigorous code reviews." The solution is to remove the possibility: make it impossible to write a query against Task without the filter, unless you explicitly ask to see the deleted ones. That's what the automatic mechanism does.
Mental model: the filter as a layer of abstraction
Think of soft delete like authentication. Nobody on your team writes if not user.is_authenticated: return 401 in every endpoint — you use a decorator or middleware that does it automatically. If someone has an endpoint that genuinely has to be public, they mark it explicitly. The default rule is "authenticated"; the opt-out is explicit.
Soft delete should work the same way. By default, every query excludes soft-deleted rows. If someone needs to see them (an auditor, a recovery flow), they opt in explicitly. The operational difference is enormous: a new teammate can write queries without remembering the filter and the system imposes the correct semantics on them.
The three options in SQLAlchemy 2.0
Let's look at them in order of increasing inversion of control: from the manual approach with helpers only, to the automatic approach that intercepts every query.
Option 1: Mixin with @declared_attr (no automatic filtering)
The most basic mixin only adds the column and the helper methods. It doesn't automate anything about the filter.
# app/models/mixins.py
from datetime import datetime, timezone
from typing import ClassVar
from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
class SoftDeleteMixin:
"""
Mixin that adds a `deleted_at` column and helpers to a model.
Does NOT automate query filtering: every query has to explicitly
include `where(Model.deleted_at.is_(None))`.
"""
@declared_attr
def deleted_at(cls) -> Mapped[datetime | None]:
return mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
index=False, # the partial index is defined at the table level
)
def soft_delete(self) -> None:
"""Marks the entity as soft-deleted."""
self.deleted_at = datetime.now(timezone.utc)
def restore(self) -> None:
"""Restores a soft-deleted entity."""
self.deleted_at = None
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
Usage:
# app/models/task.py
from sqlalchemy import BigInteger, Index, String, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.models.mixins import SoftDeleteMixin
class Base(DeclarativeBase):
pass
class Task(Base, SoftDeleteMixin):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
author_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
# Partial index: the canonical pattern
__table_args__ = (
Index(
"idx_tasks_author_created_active",
"author_id",
"created_at",
postgresql_where=text("deleted_at IS NULL"),
),
)
Every query is still manual:
# Delete
task = await session.get(Task, task_id)
task.soft_delete()
await session.commit()
# List (manual filter is mandatory)
result = await session.execute(
select(Task).where(Task.deleted_at.is_(None))
)
Trade-offs:
| Pro | Con |
|---|---|
| Simple code, easy to read | The filter is still manual in every query |
| No magic: what you see is what runs | The "forgotten filter" bug is still possible |
| Compatible with any query pattern | Code reviews still need vigilance |
| Zero runtime overhead | Doesn't scale to large teams |
When to use it: very small apps, scripts, prototypes, teams of 1-2 people with high discipline. NOT recommended for multi-team production.
Option 2: Custom query class
SQLAlchemy 1.x had first-class support for query classes; SQLAlchemy 2.0 discouraged this pattern in favor of event listeners. Even so, some people implement it with a wrapper around select(). The idea: a select_active(Model) factory function that returns a Select with the filter already applied.
# app/db/queries.py
from typing import TypeVar
from sqlalchemy import Select, select
from app.models.mixins import SoftDeleteMixin
ModelT = TypeVar("ModelT", bound=SoftDeleteMixin)
def select_active(model: type[ModelT]) -> Select[tuple[ModelT]]:
"""
Factory that returns a Select with the soft delete filter applied.
Usage: `select_active(Task).where(Task.author_id == 42)`.
"""
return select(model).where(model.deleted_at.is_(None))
def select_with_deleted(model: type[ModelT]) -> Select[tuple[ModelT]]:
"""Factory that does NOT apply the filter. For audit, recovery, analytics."""
return select(model)
Usage:
# List (uses the factory, the filter is inside)
result = await session.execute(
select_active(Task).where(Task.author_id == 42)
)
# Audit (a factory that sees deleted rows)
result = await session.execute(
select_with_deleted(Task).where(Task.author_id == 42)
)
Trade-offs:
| Pro | Con |
|---|---|
| Automatic filter when you use the factory | If someone uses select(Task) directly, the filter is NOT applied |
Explicit: the opt-out (select_with_deleted) is visible | Convention-dependent; someone new on the team may not know it |
| No event listeners (simpler to debug) | Has to be replicated for JOINs (join_active, etc.) |
| Compatible with SQLAlchemy 2.0 with no tricks | Doesn't intercept queries elsewhere (relationships, lazy loading, etc.) |
When to use it: teams that prefer explicitness and accept the discipline of "always use the factory." The forgotten filter bug is still possible if someone writes select(Task) directly. Convention with no enforcement.
Option 3: before_compile event listener (automatic interception)
SQLAlchemy offers events at the Session and Query level. The do_orm_execute event (the modern successor to before_compile in SQLAlchemy 2.0) fires before executing any ORM statement, giving you the chance to modify it.
# app/db/soft_delete_filter.py
from typing import Any
from sqlalchemy import event, Select
from sqlalchemy.orm import Session, with_loader_criteria
from app.models.mixins import SoftDeleteMixin
def install_soft_delete_filter(target_session_class: type[Session]) -> None:
"""
Registers the event listener that injects `WHERE deleted_at IS NULL`
into every ORM query that touches models with SoftDeleteMixin.
To skip the filter on a specific query:
result = session.execute(
select(Task).execution_options(include_deleted=True)
)
"""
@event.listens_for(target_session_class, "do_orm_execute")
def _add_soft_delete_filter(orm_execute_state) -> None:
# If the query explicitly asked to see deleted rows, don't inject
if orm_execute_state.execution_options.get("include_deleted", False):
return
# If it isn't a SELECT, don't inject (UPDATE/DELETE don't need the filter)
if not orm_execute_state.is_select:
return
# Inject the filter through with_loader_criteria.
# This applies the WHERE to ANY SoftDeleteMixin entity that shows up
# in the query, including joins and relationships.
orm_execute_state.statement = orm_execute_state.statement.options(
with_loader_criteria(
SoftDeleteMixin,
lambda cls: cls.deleted_at.is_(None),
include_aliases=True,
)
)
Activation in the app's setup:
# app/db.py
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.db.soft_delete_filter import install_soft_delete_filter
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/softdelete_demo"
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Register the automatic filter on the session class
install_soft_delete_filter(AsyncSession)
async def get_session():
async with AsyncSessionLocal() as session:
yield session
Usage:
# List (automatic filter applied, without writing it)
result = await session.execute(select(Task).where(Task.author_id == 42))
# Actual SQL executed:
# SELECT * FROM tasks
# WHERE tasks.author_id = 42
# AND tasks.deleted_at IS NULL
# Audit (explicit escape hatch)
result = await session.execute(
select(Task)
.where(Task.author_id == 42)
.execution_options(include_deleted=True)
)
# Actual SQL executed:
# SELECT * FROM tasks WHERE tasks.author_id = 42
# JOIN (the filter is applied to BOTH entities if both are SoftDelete)
result = await session.execute(
select(Task, Comment).join(Comment, Comment.task_id == Task.id)
)
# Actual SQL executed:
# SELECT * FROM tasks
# JOIN comments ON comments.task_id = tasks.id
# WHERE tasks.deleted_at IS NULL
# AND comments.deleted_at IS NULL
Trade-offs:
| Pro | Con |
|---|---|
| Impossible to forget the filter: it's injected automatically | Code that looks simple runs SQL different from what's written (can confuse) |
| Works in JOINs and relationships with no extra code | Debugging requires knowing about the listener |
The opt-out is explicit and visible (include_deleted=True) | If two systems configure different listeners, subtle conflicts |
| Scales to large teams with no extra discipline | Small runtime overhead (negligible for typical queries) |
| A pattern officially documented by SQLAlchemy | Requires SQLAlchemy 2.0+ (with with_loader_criteria) |
When to use it: real production, teams of >2 people, a codebase with >10 queries using soft delete. It's the approach SQLAlchemy 2.0 endorses for this use case.
Recommendation: mixin + event listener as the default
The combination you'll take to the module project (capsule 08) and to the capstone project (module 8) is:
- Mixin (
SoftDeleteMixin) to add the column and the helper methods in a reusable way. do_orm_executeevent listener to automate the filter across all SELECT queries.- An explicit escape hatch (
execution_options(include_deleted=True)) for audit/recovery.
Why this combination wins:
-
The mixin alone is insufficient: it automates the column but leaves the filter up to the developer. The forgotten filter bug is still possible.
-
The custom query class is convention with no enforcement: it works if everyone uses the factory, but a direct
select(Task)(perfectly valid syntactically) silently bypasses the filter. -
The event listener alone is hard to maintain: without the mixin, there's no way to know which entities have soft delete. The mixin is the explicit marker: "this model has
deleted_atand the filter has to be applied to it." -
Combined, the system is safe by default: the developer writes queries without thinking about the filter and the system guarantees the correct semantics. Opting out requires conscious action and stays visible in code review.
-
It's what SQLAlchemy 2.0 documents: the official "soft delete" example in the
with_loader_criteriadocumentation uses exactly this pattern.
Complete final implementation
# app/models/mixins.py
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
class SoftDeleteMixin:
"""
Standard mixin for soft delete.
Adds:
- A `deleted_at TIMESTAMPTZ NULL` column
- A `soft_delete()` method: marks as deleted
- A `restore()` method: restores
- An `is_deleted` property: bool
For the automatic filtering to work, you have to register
`install_soft_delete_filter(SessionClass)` in the app's setup.
"""
@declared_attr
def deleted_at(cls) -> Mapped[datetime | None]:
return mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
)
def soft_delete(self) -> None:
self.deleted_at = datetime.now(timezone.utc)
def restore(self) -> None:
self.deleted_at = None
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
# app/db/soft_delete_filter.py
from sqlalchemy import event
from sqlalchemy.orm import Session, with_loader_criteria
from app.models.mixins import SoftDeleteMixin
def install_soft_delete_filter(target_session_class: type[Session]) -> None:
"""
Registers the listener that automatically injects `WHERE deleted_at IS NULL`
into SELECT queries that touch SoftDeleteMixin entities.
Escape hatch:
session.execute(select(Task).execution_options(include_deleted=True))
"""
@event.listens_for(target_session_class, "do_orm_execute")
def _filter_soft_deleted(orm_execute_state) -> None:
if orm_execute_state.execution_options.get("include_deleted", False):
return
if not orm_execute_state.is_select:
return
orm_execute_state.statement = orm_execute_state.statement.options(
with_loader_criteria(
SoftDeleteMixin,
lambda cls: cls.deleted_at.is_(None),
include_aliases=True,
)
)
# app/models/task.py
from datetime import datetime
from sqlalchemy import BigInteger, Index, String, DateTime, func, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.models.mixins import SoftDeleteMixin
class Base(DeclarativeBase):
pass
class Task(Base, SoftDeleteMixin):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
author_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
__table_args__ = (
Index(
"idx_tasks_author_created_active",
"author_id",
"created_at",
postgresql_where=text("deleted_at IS NULL"),
),
)
# app/db.py
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.db.soft_delete_filter import install_soft_delete_filter
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/softdelete_demo"
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
install_soft_delete_filter(AsyncSession)
async def get_session():
async with AsyncSessionLocal() as session:
yield session
End-to-end verification
# scripts/verify_soft_delete.py
import asyncio
from sqlalchemy import select
from app.db import AsyncSessionLocal, engine
from app.models.task import Base, Task
async def main() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with AsyncSessionLocal() as session:
# Create 3 tasks: 2 active, 1 deleted
t1 = Task(author_id=1, title="Active 1")
t2 = Task(author_id=1, title="Active 2")
t3 = Task(author_id=1, title="Deleted")
session.add_all([t1, t2, t3])
await session.commit()
# Soft-delete one
t3.soft_delete()
await session.commit()
# Default query: automatic filter
result = await session.execute(select(Task).where(Task.author_id == 1))
active = result.scalars().all()
print(f"Default query returned: {[t.title for t in active]}")
# Expected: ['Active 1', 'Active 2']
# Escape hatch: see them all
result = await session.execute(
select(Task)
.where(Task.author_id == 1)
.execution_options(include_deleted=True)
)
all_tasks = result.scalars().all()
print(f"Include deleted query returned: {[t.title for t in all_tasks]}")
# Expected: ['Active 1', 'Active 2', 'Deleted']
if __name__ == "__main__":
asyncio.run(main())
python scripts/verify_soft_delete.py
# Default query returned: ['Active 1', 'Active 2']
# Include deleted query returned: ['Active 1', 'Active 2', 'Deleted']
The filter worked automatically without writing where(Task.deleted_at.is_(None)) in the first query. And the escape hatch worked when you asked for it explicitly.
Why does this automation matter in real work?
1. It's the only approach that scales to teams. A developer who's new on the team can write queries without knowing about the soft delete pattern. The system imposes the correct semantics on them. Without this, soft delete is growing technical debt: every new endpoint is an opportunity for a bug.
2. The escape hatch is explicit and reviewable. When someone writes execution_options(include_deleted=True), it's visible in the PR diff. The reviewer can ask "why do you need to see deleted rows here?". Without this, the queries that see deleted rows are indistinguishable from the ones that filter (both look like select(Task)).
3. It works with relationships and joins automatically. with_loader_criteria applies to any alias of the model in the query. That includes joins and eager loading (selectinload, joinedload). Without the listener, every relationship would require its own manual filter.
4. It's the officially documented pattern. SQLAlchemy 2.0 endorses with_loader_criteria for soft delete. You aren't inventing a workaround; you're using the tool designed for the case. That means: maintainable code, future-compatible, and recognizable to any dev who knows SQLAlchemy.
Traps and common mistakes
Mistake 1 (conceptual): assuming the filter applies to UPDATE/DELETE
Symptom: you run await session.execute(update(Task).where(Task.id == 5).values(title="X")) and a teammate asks "shouldn't it also be filtered by deleted_at IS NULL automatically?".
Why it's wrong: the listener as we implemented it only intercepts SELECTs (if not orm_execute_state.is_select: return). UPDATEs and DELETEs aren't filtered because you typically want to operate on any row by id, soft-deleted or not (recovery, for example, needs updates on deleted rows).
How to tell: if you need the "don't update deleted rows" semantics, add the filter explicitly:
await session.execute(
update(Task)
.where(Task.id == 5, Task.deleted_at.is_(None))
.values(title="X")
)
How to fix it: the rule is "the automatic filter applies to SELECTs; mutations require an explicit filter if you need it." It's consistent with the defensive filter on the soft delete UPDATE you saw in capsule 03.
Mistake 2 (practical): with_loader_criteria with include_aliases=False and joins fail
Symptom: with joins or aliases (aliased(Task)), the filter is NOT applied to the alias, only to the main model.
Why it happens: with_loader_criteria by default only applies to the exact model. Aliases and subqueries aren't covered unless you pass include_aliases=True.
How to tell: run a query with a join and aliased:
TaskAlias = aliased(Task)
result = await session.execute(
select(Task, TaskAlias).join(TaskAlias, TaskAlias.author_id == Task.author_id)
)
If the generated SQL has the filter only on tasks and not on the alias, you're missing include_aliases=True.
How to fix it: make sure the listener has include_aliases=True (as in the recommended implementation). That's why the implementation includes that parameter explicitly.
Mistake 3 (conceptual): thinking the listener intercepts raw SQL (session.execute(text(...)))
Symptom: you run await session.execute(text("SELECT * FROM tasks")) expecting the filter to be applied. It returns deleted rows.
Why it happens: text() runs raw SQL without going through the ORM system. The do_orm_execute listener only intercepts ORM queries, not raw SQL.
How to tell: check the code: if you use text() or engine.execute() directly, the filter doesn't apply. If you use select(Model), it does.
How to fix it: for raw SQL, write the filter by hand or avoid text() when you can. This is a conscious decision: raw SQL is an escape from the ORM abstraction, and with the escape come the manual responsibilities.
Mistake 4 (edge case): the listener gets registered twice (on re-imports or test setup)
Symptom: the filter is applied with a double AND: WHERE deleted_at IS NULL AND deleted_at IS NULL. Functionally correct but ugly and slightly more expensive.
Why it happens: install_soft_delete_filter gets called multiple times during test setup or from uvicorn's hot-reload.
How to tell: enable echo=True on the engine and review the generated SQL. If you see duplicate filters, this is it.
How to fix it: use a single-registration guard:
_REGISTERED = set()
def install_soft_delete_filter(target_session_class):
if target_session_class in _REGISTERED:
return
_REGISTERED.add(target_session_class)
@event.listens_for(target_session_class, "do_orm_execute")
def _filter_soft_deleted(orm_execute_state):
# ... the rest is the same
Mistake 5 (conceptual): a custom query class mixed with an event listener
Symptom: the team used a custom query class (select_active) in parts of the code and an event listener in others. Some queries get filtered twice, others once, nothing breaks but the logic is opaque.
Why it happens: each mechanism is independent. If select_active(Task) already has the filter in the WHERE and then the listener adds it too, the result has the filter duplicated.
How to fix it: pick one mechanism and use it consistently. This capsule's recommendation is the event listener (don't use the custom factory). If you're coming from a codebase with a factory, migrate all the queries and remove the factory.
Exercises
Exercise 1: implement the full pattern in an app from scratch
Set up a FastAPI project with SQLAlchemy 2.0 async and PostgreSQL. Implement the SoftDeleteMixin, the install_soft_delete_filter, a Task entity that uses the mixin, and a GET /tasks endpoint that lists the active ones. Verify with curl that the soft-deleted ones don't show up.
See solution
Structure:
softdelete-fastapi/
├── app/
│ ├── __init__.py
│ ├── db.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── mixins.py
│ │ └── task.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── soft_delete_filter.py
│ └── main.py
└── seed.py
app/main.py:
from datetime import datetime
from typing import List
from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.models.task import Task
app = FastAPI(title="Soft Delete Demo")
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
deleted_at: datetime | None
@app.get("/tasks", response_model=List[TaskOut])
async def list_tasks(db: AsyncSession = Depends(get_session)):
"""Lists active tasks (automatic filter)."""
result = await db.execute(select(Task).order_by(Task.id))
return result.scalars().all()
@app.get("/tasks/all", response_model=List[TaskOut])
async def list_all_tasks(db: AsyncSession = Depends(get_session)):
"""Lists every task including soft-deleted ones (escape hatch)."""
result = await db.execute(
select(Task)
.order_by(Task.id)
.execution_options(include_deleted=True)
)
return result.scalars().all()
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(task_id: int, db: AsyncSession = Depends(get_session)):
"""Soft delete: marks deleted_at."""
task = await db.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
if task.is_deleted:
raise HTTPException(status_code=410, detail="Task already deleted")
task.soft_delete()
await db.commit()
seed.py:
import asyncio
from app.db import AsyncSessionLocal, engine
from app.models.task import Base, Task
async def main():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with AsyncSessionLocal() as s:
s.add_all([
Task(author_id=1, title="Buy milk"),
Task(author_id=1, title="Walk dog"),
Task(author_id=1, title="Submit report"),
])
await s.commit()
print("Seeded 3 tasks")
asyncio.run(main())
Try it:
python seed.py
uvicorn app.main:app --reload &
curl -s http://localhost:8000/tasks | jq
# 3 tasks listed
curl -X DELETE http://localhost:8000/tasks/2
curl -s http://localhost:8000/tasks | jq
# Only 2 tasks listed (#2 is hidden by the automatic filter)
curl -s http://localhost:8000/tasks/all | jq
# All 3 tasks listed (escape hatch)
Verify the SQL that ran (with echo=True on the engine):
SELECT tasks.id, tasks.title, tasks.author_id, tasks.created_at, tasks.deleted_at
FROM tasks
WHERE tasks.deleted_at IS NULL
ORDER BY tasks.id
The filter was injected automatically without writing it in list_tasks.
Exercise 2: compare the three mechanisms against concrete metrics
For each mechanism (mixin alone, query class factory, event listener), answer:
a) How many lines of code does the dev have to write for a new query?
b) Is it possible to forget the filter accidentally?
c) What does the opt-out (explicitly seeing deleted rows) look like?
d) Does it work with joins automatically?
See solution
| Aspect | Mixin alone | Query class factory | Event listener |
|---|---|---|---|
| Lines for a new query | 2 (select + filter) | 1 (factory wrap) | 1 (select only) |
| Possible to forget the filter? | Yes (high probability) | Yes (if you don't use the factory) | No (automatic) |
| How do you opt out? | Omit where(deleted_at.is_(None)) (invisible) | select_with_deleted(Task) (visible) | .execution_options(include_deleted=True) (visible) |
| Does it work in joins? | Manual: where(Task.deleted_at.is_(None), Comment.deleted_at.is_(None)) | Manual: a factory for each model | Automatic with include_aliases=True |
| Setup lines | ~10 (mixin) | ~15 (factories) | ~25 (listener + mixin) |
| Learning curve for new devs | Trivial | Low (learn the convention) | Medium (understand the listener) |
Compatible with raw SQL (text()) | N/A | N/A | No (has to be filtered manually) |
Quantitative verdict:
- Mixin alone: minimal setup, maximum flexibility, maximum bug risk.
- Query class factory: medium setup, clear semantics, medium risk (depends on discipline).
- Event listener: highest setup, cleanest query code, near-zero risk.
For a team of 1 person and 5 queries: any of them works. For a team of 10 people and 100 queries: only the event listener scales.
The module project (capsule 08) and the capstone project (module 8) use the event listener for this reason.
Exercise 3: test the automatic filter and the escape hatch
Write async pytest tests that verify:
a) A default query doesn't return soft-deleted rows.
b) A query with execution_options(include_deleted=True) does return them.
c) A join between two models with SoftDeleteMixin filters both tables automatically.
d) An UPDATE isn't filtered by deleted_at IS NULL (we checked this in common mistake 1).
See solution
# tests/test_soft_delete.py
import pytest
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task
pytestmark = pytest.mark.asyncio
async def test_filtro_automatico_excluye_soft_deleted(session: AsyncSession):
t1 = Task(author_id=1, title="Active")
t2 = Task(author_id=1, title="Deleted")
session.add_all([t1, t2])
await session.commit()
t2.soft_delete()
await session.commit()
# Default query: the listener injects WHERE deleted_at IS NULL
result = await session.execute(select(Task).where(Task.author_id == 1))
titles = [t.title for t in result.scalars().all()]
assert titles == ["Active"]
async def test_escape_hatch_devuelve_soft_deleted(session: AsyncSession):
t1 = Task(author_id=1, title="Active")
t2 = Task(author_id=1, title="Deleted")
session.add_all([t1, t2])
await session.commit()
t2.soft_delete()
await session.commit()
# Explicit escape hatch
result = await session.execute(
select(Task)
.where(Task.author_id == 1)
.execution_options(include_deleted=True)
)
titles = sorted(t.title for t in result.scalars().all())
assert titles == ["Active", "Deleted"]
async def test_join_filtra_ambas_tablas(session: AsyncSession):
"""
Assumes Comment also uses SoftDeleteMixin.
Setup: 1 active task with 2 comments (1 active, 1 deleted).
"""
from app.models.comment import Comment # assumed to exist
task = Task(author_id=1, title="T1")
session.add(task)
await session.flush() # to get task.id
c1 = Comment(task_id=task.id, body="Active comment")
c2 = Comment(task_id=task.id, body="Deleted comment")
session.add_all([c1, c2])
await session.commit()
c2.soft_delete()
await session.commit()
# JOIN: the filter has to be applied to Task AND Comment
result = await session.execute(
select(Task, Comment).join(Comment, Comment.task_id == Task.id)
)
rows = result.all()
assert len(rows) == 1 # only the active comment
_, comment = rows[0]
assert comment.body == "Active comment"
async def test_update_no_se_filtra_automaticamente(session: AsyncSession):
"""
An UPDATE on a soft-deleted row has to work (it isn't filtered).
This is what enables recovery: UPDATE SET deleted_at = NULL.
"""
t1 = Task(author_id=1, title="Original")
session.add(t1)
await session.commit()
t1.soft_delete()
await session.commit()
# UPDATE on a soft-deleted row: it has to affect the row
await session.execute(
update(Task).where(Task.id == t1.id).values(title="Restored")
)
await session.commit()
# Verify with the escape hatch
result = await session.execute(
select(Task)
.where(Task.id == t1.id)
.execution_options(include_deleted=True)
)
task = result.scalar_one()
assert task.title == "Restored" # the UPDATE worked
assert task.is_deleted is True # it's still soft-deleted
Why these tests matter:
- Test (a) verifies the default behavior — without this, the pattern adds no value.
- Test (b) verifies the escape hatch works — without this, you couldn't do audit/recovery.
- Test (c) verifies the behavior in joins — the pattern's subtlest case.
- Test (d) verifies UPDATE still works on soft-deleted rows — it's what enables recovery.
These tests should live next to the listener's code and run in CI.
Exercise 4: add the escape hatch to an audit endpoint
Implement an admin endpoint GET /admin/tasks/audit that returns every task (active and soft-deleted) with metadata about when they were deleted. It should only return the soft-deleted ones from the last 90 days.
See solution
# app/main.py (extension)
from datetime import datetime, timedelta, timezone
from typing import List
from pydantic import BaseModel
from sqlalchemy import or_
class TaskAuditOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
author_id: int
created_at: datetime
deleted_at: datetime | None
status: str # "active" or "deleted"
@app.get("/admin/tasks/audit", response_model=List[TaskAuditOut])
async def audit_tasks(db: AsyncSession = Depends(get_session)):
"""
Audit endpoint: returns active and soft-deleted tasks from the last 90 days.
Requires the escape hatch to include deleted rows.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=90)
result = await db.execute(
select(Task)
.where(
or_(
Task.deleted_at.is_(None), # active
Task.deleted_at >= cutoff, # deleted in the last 90 days
)
)
.order_by(Task.deleted_at.desc().nulls_last(), Task.id.desc())
.execution_options(include_deleted=True) # CRITICAL: the escape hatch
)
tasks = result.scalars().all()
return [
TaskAuditOut(
id=t.id,
title=t.title,
author_id=t.author_id,
created_at=t.created_at,
deleted_at=t.deleted_at,
status="deleted" if t.is_deleted else "active",
)
for t in tasks
]
Try it:
# Setup: 3 active + 1 soft-deleted today + 1 soft-deleted 200 days ago
curl -s http://localhost:8000/admin/tasks/audit | jq
# Expected output: 4 tasks (3 active + the one deleted today)
# The one deleted 200 days ago does NOT show up (the 90-day cutoff filter)
Why this pattern is important:
- The
execution_options(include_deleted=True)is explicit. Any reviewer reading this endpoint immediately sees that it's asking to see deleted rows. - The
or_(deleted_at IS NULL, deleted_at >= cutoff)filter belongs to the domain (what you want to see), not to the pattern (default visibility). - The separation matters: the listener handles the "default filter," the endpoint handles the "domain filter."
Operational lesson: the few endpoints that need to see deleted rows are visible in a grep:
grep -r "include_deleted" app/
# app/main.py: .execution_options(include_deleted=True)
# app/admin/recovery.py: .execution_options(include_deleted=True)
This makes security audits easy: "which endpoints can see deleted data?". Without the listener, there'd be no way to list them.
Exercise 5: defend the decision in a code review (real scenario)
A senior teammate proposes using a query class factory (select_active(Task)) instead of an event listener "because it's more explicit and debugger-friendly." It's on you to defend the event listener decision in a PR comment. List 3 concrete arguments with examples.
See solution
Example comment:
I agree the factory is more explicit at each query, and that's appealing. But I think for our case the event listener wins for these reasons:
1. Impossible to forget the filter vs convention-dependent.
With the factory, this query is syntactically valid and compiles:
result = await session.execute(select(Task).where(Task.author_id == 42))No error, no warning. It returns deleted rows to production. The review has to catch it. With the event listener, the same query is safe because the filter is injected automatically. The only way to bypass it is to write
execution_options(include_deleted=True)— which is reviewable in a grep and in code review.2. Joins and eager loading.
With the factory, this is manual:
select_active(Task).join(select_active(Comment).subquery()) # ... and for each relationship with selectinload, tooWith the event listener +
include_aliases=True, the joins are filtered automatically. We have Task → Comment → Reaction relations, all with soft delete. Without the listener, every query with joins requires remembering the filter at each level.3. Auditability of "which endpoints see deleted rows."
With the event listener, the endpoints that see deleted rows are the ones that write
include_deleted=True. It's greppable:grep -r "include_deleted" app/With the factory, the ones that see deleted rows are the ones that write
select_with_deletedAND the ones that writeselect(Model)directly (without the factory). The second group is invisible. There's no way to list them.On debugging: I take the point. The real SQL differs from the code as written, and that's confusing at first. The mitigation is turning on
echo=Trueon the engine during development, which shows the exact SQL. After a couple of queries, the pattern gets internalized.Proposal: event listener + good tests of the filter (capsule 04 of the module has a set of tests to copy). If we regret it in 3 months, migrating to the factory is ~1 day of refactoring (search-and-replace
select(Model)withselect_active(Model)). Migrating from factory to listener is ~1 hour (register the listener, optionally remove the factories).
Why this comment works:
- It acknowledges the teammate's point (the factory is more explicit) instead of dismissing it.
- It cites concrete code examples, not abstractions.
- It brings real operational data ("we have relations 3 levels deep").
- It proposes a mitigation to the counter-argument (debugging) instead of ignoring it.
- It closes with a rollback plan ("if we regret it, X days of migration").
This is senior communication. The technical decision is defensible; so is the way of defending it.
Summary and next step
In this capsule you learned:
- The forgotten filter is soft delete's most expensive semantic bug. Automation at the ORM level prevents it.
- Three mechanisms in SQLAlchemy 2.0: mixin alone (no filtering), query class factory (convention),
do_orm_executeevent listener (automatic). Each has concrete trade-offs. - Default recommendation: mixin (
SoftDeleteMixin) + event listener (install_soft_delete_filter) + escape hatch (execution_options(include_deleted=True)). It's what we'll use in the module project and in the capstone project. with_loader_criteriawithinclude_aliases=Trueis SQLAlchemy 2.0's official primitive for this case. It works with joins, relationships, and eager loading with no extra code.- The listener intercepts only SELECTs, not UPDATEs/DELETEs. This is what allows recovery (
UPDATE SET deleted_at = NULL) with no need for a bypass. - The escape hatch is greppable:
grep -r "include_deleted" app/lists every endpoint that sees deleted rows. It's auditable.
Before moving on you should be able to:
- Implement the full pattern (mixin + listener + escape hatch) in a new FastAPI app in under 30 minutes.
- Justify the choice of event listener vs factory to a senior teammate with 3 concrete arguments.
- Write async pytest tests that verify the automatic filter, the escape hatch, and the behavior in joins.
- Diagnose the forgotten filter bug in a codebase that does NOT use automation (how to find it, how to migrate to the pattern).
Next capsule — Partial indexes for soft deletes. You're going to go deeper into the pattern's other pillar: the partial index. You'll apply what you saw in capsule 03 (the canonical case of partial indexes) with the automatic mechanism from 04, measuring the impact on real queries: how the planner detects the index's implicit filter when the listener injects the WHERE deleted_at IS NULL. It's the bridge to guide #12 (module 3 on advanced indexing) without re-explaining what you already know.
Resources
- SQLAlchemy 2.0 —
with_loader_criteria()— the official reference. The "Adding global WHERE / ON criteria" section describes exactly the soft delete pattern. - SQLAlchemy 2.0 — ORM Events:
do_orm_execute— the reference for the event our listener uses. - SQLAlchemy 2.0 — Mixins and
@declared_attr— to understand how the mixin with@declared_attrworks. - Cultured Systems — "Avoiding the soft delete anti-pattern" — the critical manifesto. Useful for understanding why the forgotten filter is so common.
- Milan Jovanovic — "Implementing the Soft Delete Pattern" — a comparison of mechanisms in .NET. The reasoning about "global query filters" applies conceptually.
- Brandur Leach — "Soft deletion probably isn't worth it" — a real case from Stripe. It reinforces why the automation matters when the pattern is applied at scale.
- django-safedelete documentation — a reference for how Django implements the pattern with managers; useful for comparing approaches across frameworks.
- SQLAlchemy 2.0 — Versioning Examples — official examples of similar patterns (versioning, audit) that use event listeners.
Module 2 — SQL Patterns for Production APIs Guide
Next capsule: Partial indexes for soft deletes — the canonical case, measurable before-and-after, integration with the automatic filter.