Filtering soft-deleted rows with with_loader_criteria

Apply the filter once with with_loader_criteria(Post, Post.deleted_at.is_(None), include_aliases=True) — it reaches the entity wherever it appears, including inside selectinload() — and register it for every query from a do_orm_execute listener with an include_deleted execution option as the escape hatch. This guide belongs to hybrid properties, column properties and SQL expressions.

Quick Answer

A soft delete is only as good as its least careful query. Repeating the predicate by hand fails in two ways: someone forgets it, and eager loads never had it.

One filter, or the same filter everywhere Left: every query repeats where deleted_at is null, and the one that forgets returns deleted rows; worse, an eager-loaded collection has no filter at all unless each loader option carries its own. Right: a single with_loader_criteria option applies the criteria to the entity wherever it appears in the statement, including inside selectinload and joinedload. a filter per query .where(Post.deleted_at.is_(None)) repeated at every call site eager loads are unfiltered the forgotten one leaks rows with_loader_criteria(Post, ...) one option on the statement applies wherever Post appears eager loads filtered too include_aliases covers aliases A soft delete that only some queries respect is worse than no soft delete: the rows come back at random.

Before — the predicate repeated, and missing from the eager load:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from blog.models import Comment, Post

# Filters the posts, but loads deleted comments with them.
stmt = (
    select(Post)
    .where(Post.deleted_at.is_(None))
    .options(selectinload(Post.comments))
)

After — one option, applied wherever the entity appears:

from sqlalchemy import select
from sqlalchemy.orm import selectinload, with_loader_criteria

from blog.models import Comment, Post

stmt = (
    select(Post)
    .options(
        selectinload(Post.comments),
        with_loader_criteria(Post, Post.deleted_at.is_(None), include_aliases=True),
        with_loader_criteria(Comment, Comment.deleted_at.is_(None), include_aliases=True),
    )
)
# SELECT ... FROM posts WHERE posts.deleted_at IS NULL
# SELECT ... FROM comments WHERE comments.post_id IN (...) AND comments.deleted_at IS NULL

include_aliases=True makes the criteria apply to aliased(Post) too, which matters as soon as a query joins the same entity twice or uses a subquery alias.

For a model-wide rule, register it once for a mixin instead of per query — the next section does that, and keeps an escape hatch for the queries that need deleted rows.

Execution Context & Async Workflow Integration

with_loader_criteria() is a loader option that carries a WHERE fragment and an entity to apply it to. Unlike a .where() clause, which applies to one place in one statement, it is applied wherever that entity appears while the statement is compiled — the top-level FROM, the IN subquery selectinload() builds, the LEFT OUTER JOIN joinedload() builds — and, because propagate_to_loaders defaults to True, it is remembered for lazy loads issued later from the objects the statement returned.

Applying the criteria globally Five steps. The session executes an ORM select. The do_orm_execute listener inspects the execution state: it acts on selects, skips column and relationship loads, and skips any statement whose execution options ask to include deleted rows. It appends a with_loader_criteria option for the soft-delete mixin. The statement then runs with the filter applied to every mapped entity that inherits the mixin. A query that genuinely needs deleted rows sets include_deleted and is left alone. session.execute(select(Post)) an ORM select nothing added yet do_orm_execute listener is_select, not a column load checks execution options statement.options(with_loader_criteria(...)) appended to the statement per mixin class runs with the filter top level and eager loads aliases included execution_options(include_deleted=True) the documented escape hatch The escape hatch matters: admin screens and restore flows need the deleted rows.

That propagation is what makes it the right tool for soft deletion, and it is also the part worth testing, because the criteria reaches places that are easy to overlook.

Applying it globally is a documented recipe: a do_orm_execute listener that appends the option to every ORM SELECT.

import datetime as dt

from sqlalchemy import event, select
from sqlalchemy.orm import Session, declarative_mixin, with_loader_criteria
from sqlalchemy.orm import Mapped, mapped_column


@declarative_mixin
class SoftDeleteMixin:
    deleted_at: Mapped[dt.datetime | None] = mapped_column(default=None, index=True)


@event.listens_for(Session, "do_orm_execute")
def _apply_soft_delete_filter(execute_state) -> None:
    if (
        execute_state.is_select
        and not execute_state.is_column_load
        and not execute_state.is_relationship_load
        and not execute_state.execution_options.get("include_deleted", False)
    ):
        execute_state.statement = execute_state.statement.options(
            with_loader_criteria(
                SoftDeleteMixin,
                lambda cls: cls.deleted_at.is_(None),
                include_aliases=True,
            )
        )

Four details in that condition matter. is_select restricts it to reads. is_column_load excludes attribute refreshes, where adding criteria would break a load for a row that is already known. is_relationship_load excludes lazy loads, which already inherit the criteria from the statement that loaded their parent — applying it twice is harmless but wasteful. And the include_deleted execution option is the escape hatch, without which an admin screen or a restore flow could not see the rows at all.

Passing the criteria as a lambda taking cls is what lets one registration cover every class using the mixin: SQLAlchemy calls it per entity, so cls is the actual mapped class each time.

The listener is registered on the synchronous Session class, which every AsyncSession wraps, so it applies to async code unchanged. Because the filter is added at execution time, it is also part of the cache key, so statements with and without include_deleted are compiled and cached separately.

One gap to know about: session.get() for an object already in the identity map returns it without issuing a query, so no criteria can apply. A soft-deleted row fetched earlier in the same session is still reachable by get(). Where that matters — an endpoint loading by id, then acting on the object — check the flag explicitly, or use select() with .where().

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
Deleted rows appear inside eager-loaded collectionsThe predicate was a .where() on the outer query only.with_loader_criteria() for the child entity too, or the global listener.
Deleted rows appear only for aliased entitiesinclude_aliases left at its default.include_aliases=True.
session.get(Post, id) returns a deleted rowThe object was already in the identity map, so no query ran.Load with select(), or check the flag after get().
IntegrityError: duplicate key value violates unique constraint "uq_users_email" after deleting and re-registeringA plain unique constraint still counts deleted rows.Partial unique index with WHERE deleted_at IS NULL.
Set-based update()/delete() statements touch deleted rowsLoader criteria apply to ORM SELECT only.Add the predicate to those statements explicitly.
Counts and aggregates include deleted rowsThe aggregate was built with Core select(func.count()) on the table.Use the ORM entity so the listener applies, or add the predicate.
An admin page shows nothingThe global listener filtered a query that wanted deleted rows..execution_options(include_deleted=True).
Four places the filter must reach Four tiles. Top-level ORM selects are covered by the loader criteria. Eager loads are covered too, because the option propagates into selectinload and joinedload. Lazy loads inherit the criteria from the statement that loaded the parent. Core selects and set-based updates and deletes are not covered at all, and need the predicate written out. ORM select covered by the option eager loads covered propagates into loaders lazy loads inherited from the parent statement Core select / update / delete not covered write the predicate session.get() on an object already in the identity map also bypasses it — it returns without a query.

The partial unique index is the schema change teams most often discover late, usually when a user deletes their account and cannot sign up again with the same address:

from sqlalchemy import Index, text
from sqlalchemy.orm import Mapped, mapped_column


class User(SoftDeleteMixin, Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]

    __table_args__ = (
        Index(
            "uq_users_email_live",
            "email",
            unique=True,
            postgresql_where=text("deleted_at IS NULL"),
        ),
    )

Only live rows participate, so one address can be reused after a delete while remaining unique among active users. Build it with CREATE UNIQUE INDEX CONCURRENTLY on an existing table, as in creating indexes concurrently in Alembic migrations, and drop the old constraint only after the new index is valid.

The other gap worth planning for is that soft deletion does not cascade. session.delete(post) is not involved at all, so no relationship cascade runs: soft-deleting a post leaves its comments live. Decide per relationship whether children should be soft-deleted with the parent, and if so do it in one statement:

from sqlalchemy import func, update

from blog.models import Comment, Post


async def soft_delete_post(session, post_id: int) -> None:
    now = func.now()
    await session.execute(
        update(Post).where(Post.id == post_id).values(deleted_at=now)
    )
    await session.execute(
        update(Comment)
        .where(Comment.post_id == post_id, Comment.deleted_at.is_(None))
        .values(deleted_at=now)
    )
    await session.commit()

Advanced: Restores, Retention and Intercepting Deletes

Three operational requirements follow a soft delete into production, and each is easier if it is designed alongside the filter rather than after it.

What soft deletion does to the schema Three bands. Unique constraints must become partial unique indexes, or a deleted row keeps its email address reserved forever. Foreign keys now point at rows that may be deleted, so reads through them need the same filter. And every index that serves a filtered query should include the deleted_at predicate, so the index only holds live rows. unique → partial unique index UNIQUE (email) WHERE deleted_at IS NULL — otherwise a deleted user blocks the address foreign keys can point at deleted rows the database will not stop it; the application must decide what a deleted parent means partial indexes for hot queries WHERE deleted_at IS NULL keeps the index small and matches what queries actually ask

Restore is an update, and it has to reverse exactly what the delete did — including children:

from sqlalchemy import select, update

from blog.models import Comment, Post


async def restore_post(session, post_id: int) -> None:
    post = await session.scalar(
        select(Post).where(Post.id == post_id).execution_options(include_deleted=True)
    )
    if post is None or post.deleted_at is None:
        raise ValueError("post is not deleted")
    deleted_at = post.deleted_at
    await session.execute(update(Post).where(Post.id == post_id).values(deleted_at=None))
    # Only the comments deleted *with* the post, not ones deleted earlier.
    await session.execute(
        update(Comment)
        .where(Comment.post_id == post_id, Comment.deleted_at == deleted_at)
        .values(deleted_at=None)
    )
    await session.commit()

Matching on the exact timestamp is what makes the restore honest: a comment the author deleted last week stays deleted. That only works because the cascade wrote one timestamp for the whole operation, which is why func.now() was evaluated once above.

Retention turns soft deletes into hard ones eventually, both to keep tables small and because a deletion request under data-protection rules is not satisfied by a flag. A scheduled job deletes rows whose deleted_at is older than the retention window, in batches, with the escape hatch set:

import datetime as dt

from sqlalchemy import delete

from blog.models import Post


async def purge_deleted(session, older_than: dt.timedelta) -> int:
    cutoff = dt.datetime.now(dt.UTC) - older_than
    result = await session.execute(
        delete(Post)
        .where(Post.deleted_at.is_not(None), Post.deleted_at < cutoff)
        .execution_options(include_deleted=True)
    )
    await session.commit()
    return result.rowcount

Intercepting real deletes closes the loophole where code calls session.delete() and bypasses the whole scheme. A before_delete mapper event can refuse it outright, which is blunt and effective during a migration to soft deletes:

from sqlalchemy import event
from sqlalchemy.orm import Mapper


@event.listens_for(SoftDeleteMixin, "before_delete", propagate=True)
def _block_hard_delete(mapper: Mapper, connection, target) -> None:
    if not getattr(target, "_allow_hard_delete", False):
        raise RuntimeError(
            f"{type(target).__name__} uses soft deletion; set deleted_at instead"
        )

It catches only ORM deletes — a set-based delete() never loads the object — so pair it with code review on bulk statements, or with database privileges that deny DELETE to the application role and grant it to the purge job alone.

Choosing Soft Deletion in the First Place

Soft deletion is often adopted as an obvious default and then regretted, because its cost is not in the column — it is in the fact that every future query must know about it. Two questions decide whether it is the right shape.

Soft delete, or archive and delete Left, soft delete: one column, instant restore, and every query in the system must filter; the live table keeps growing and indexes hold rows nobody reads. Right, archive and delete: rows move to an archive table in one statement, the live table stays small and needs no global filter, and restoring means moving a row back. soft delete (deleted_at) restore is one UPDATE every query must filter live table keeps dead rows partial indexes everywhere archive table + DELETE live queries need no filter restore is an INSERT ... SELECT live table stays small one data-modifying CTE per move Archiving suits data that is rarely restored; soft deletion suits data users undelete themselves.

Who restores the data, and how often? If users undelete their own items — a trashcan, a draft, an archived conversation — soft deletion is exactly right: the restore is instant, the row never moves, and references to it stay valid. If restores happen once a year, by an engineer, from a support ticket, then an archive table is simpler: the live tables stay small, no global filter is needed, and nothing else in the system has to be aware of the scheme.

How large does the table get? Dead rows still occupy the table and every index on it. A table where most rows are deleted makes every index scan read entries it will discard, and the fix — partial indexes on WHERE deleted_at IS NULL — has to be applied to each index individually. At that point moving rows out is usually cheaper than filtering them out, and the move is one statement:

from sqlalchemy import delete, insert, select

from blog.models import Post, PostArchive

COLUMNS = ["id", "title", "body", "author_id", "published_at", "deleted_at"]

moved = (
    delete(Post)
    .where(Post.deleted_at.is_not(None))
    .returning(*(Post.__table__.c[name] for name in COLUMNS))
    .cte("moved")
)
archive = insert(PostArchive).from_select(
    COLUMNS, select(*(moved.c[name] for name in COLUMNS))
)

That is the data-modifying CTE pattern from using CTEs with INSERT, UPDATE and DELETE ... RETURNING, and it is atomic: a row cannot be archived without being deleted.

A middle path suits many systems: soft-delete for a short, user-visible window — thirty days of trash — and archive or purge after it. The live table then holds only recent deletions, the global filter stays cheap, and long-term storage is a table nobody queries on the hot path.

Whichever path you take, write down which one it is. The failure mode of soft deletion is not a bug in the mechanism; it is a codebase where half the queries assume the filter is automatic and half assume it is manual, and the rows that leak are the ones nobody was looking at.

Frequently Asked Questions

Does with_loader_criteria apply to eager loads?

Yes. The criteria is applied wherever the entity appears in the statement, including inside selectinload() and joinedload(), and it propagates to lazy loads issued from the returned objects.

How do I write a query that includes deleted rows?

Give it an execution option the global listener checks — .execution_options(include_deleted=True) — so the listener skips adding the criteria for that statement.

Why does a unique constraint break after soft deletion?

Because deleted rows still participate in it. Replace the constraint with a partial unique index restricted to WHERE deleted_at IS NULL.

Do relationship cascades soft-delete children?

No. Cascades only run for real ORM deletes. Soft-delete children explicitly, with a single timestamp for the whole operation so a restore can match on it.