Configuring cascade delete and delete-orphan correctly

Set cascade="all, delete-orphan" on the parent side of a composition relationship, add ondelete="CASCADE" to the child's foreign key and passive_deletes=True to the relationship — the first makes the ORM delete children, the last two let the database do it in one statement instead of loading every row. This guide belongs to modeling relationships, cascades and association objects.

Quick Answer

SQLAlchemy's default cascade does not delete children. It sets their foreign key to NULL, which fails immediately against a NOT NULL column.

Two very different deletes Left, the default save-update and merge cascade: deleting an order loads its lines and sets their order_id to NULL, which fails against a NOT NULL column with an IntegrityError. Right, cascade all with delete-orphan: the lines are deleted with the order, and removing a line from the collection deletes that row too. default cascade session.delete(order) children loaded, order_id set to NULL null value in column "order_id" violates not-null constraint cascade="all, delete-orphan" session.delete(order) DELETE FROM order_lines WHERE id IN (...) then DELETE FROM orders removing a line deletes its row delete-orphan also means a child detached from its parent is deleted, not just orphaned.

Before — the default cascade against a NOT NULL foreign key:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    lines: Mapped[list["OrderLine"]] = relationship(back_populates="order")


class OrderLine(Base):
    __tablename__ = "order_lines"
    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"))
    order: Mapped[Order] = relationship(back_populates="lines")


await session.delete(order)
await session.commit()
# sqlalchemy.exc.IntegrityError: (asyncpg.exceptions.NotNullViolationError)
# null value in column "order_id" of relation "order_lines" violates not-null constraint

After — composition declared on both sides:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    lines: Mapped[list["OrderLine"]] = relationship(
        back_populates="order",
        cascade="all, delete-orphan",   # the ORM owns the children
        passive_deletes=True,           # but lets the database delete them
    )


class OrderLine(Base):
    __tablename__ = "order_lines"
    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
    order: Mapped[Order] = relationship(back_populates="lines")


await session.delete(order)
await session.commit()
# DELETE FROM orders WHERE id = $1   — the database removes the lines

All three settings belong together. cascade tells the unit of work the children are owned; ondelete="CASCADE" puts the rule in the schema; passive_deletes=True tells SQLAlchemy it may rely on that rule instead of loading each child.

Execution Context & Async Workflow Integration

A cascade is a statement about ownership. save-update and merge, the default, only say that children follow their parent into a session. delete says a parent's deletion extends to its children. delete-orphan says a child that is removed from its parent's collection, or reassigned to another parent, has no reason to exist and should be deleted.

What an ORM cascade delete costs Five steps. session.delete(order) marks the order deleted. At flush, the unit of work loads the order lines so it can delete them one collection at a time — under async a lazy load here raises MissingGreenlet. Each child level is then deleted with its own statement. Finally the parent row is deleted. With passive_deletes=True and ON DELETE CASCADE, the middle steps disappear and the database removes the children. session.delete(order) marked deleted nothing has run yet flush loads order.lines lazy load under async MissingGreenlet unless loaded DELETE FROM order_lines one statement per collection plus grandchildren, recursively DELETE FROM orders parent last passive_deletes=True skips the loads and lets ON DELETE CASCADE do the work in one round trip.

Without delete, the unit of work still has to do something with the children when their parent goes away, and what it does is disassociate them: it loads the collection and sets each child's foreign key to NULL. On a nullable column that leaves orphan rows behind; on a NOT NULL column it fails, which is the error above. Where the child's foreign key is part of its primary key, SQLAlchemy refuses outright with AssertionError: Dependency rule tried to blank-out primary key column 'order_lines.order_id'.

Under async, the loading is the part that bites. An ORM cascade delete loads every collection it needs to cascade into, and if those collections are not already loaded, that is a lazy load — which under AsyncSession raises MissingGreenlet: greenlet_spawn has not been called. Three ways out, in order of preference: passive_deletes=True with ON DELETE CASCADE so nothing needs loading; eager loading with selectinload(Order.lines) before the delete; or await session.refresh(order, ["lines"]). The general rule behind all of them is in fixing GreenletSpawnError in async SQLAlchemy workflows.

passive_deletes=True changes what SQLAlchemy emits, not what it means. It stops the ORM loading and deleting children, and trusts the database's ON DELETE rule to remove them. If the schema has no such rule, the child rows survive — or the delete fails on the foreign key — so the two must be declared together. Alembic will not add ondelete to an existing constraint by itself; that is a drop and re-add of the foreign key, which managing enums, constraints and indexes in migrations covers.

One thing no cascade reaches is a set-based delete. await session.execute(delete(Order).where(...)) is one statement; the ORM never loads the orders, so delete-orphan does not run and only the database's own ON DELETE rules apply. That is another reason to put the rule in the schema as well as in the model.

Resolving Warnings, Errors & Common Mistakes

Exact error or warningRoot CauseProduction Fix
IntegrityError: null value in column "order_id" ... violates not-null constraintDefault cascade blanked the child's foreign key.cascade="all, delete-orphan" on the parent relationship.
AssertionError: Dependency rule tried to blank-out primary key column 'order_lines.order_id'The foreign key is part of the child's primary key, so it cannot be nulled.Same fix; the relationship is composition.
MissingGreenlet: greenlet_spawn has not been called on session.delete()The cascade needed to load a collection lazily.passive_deletes=True, or eager-load the collection first.
SAWarning: DELETE statement on table 'order_lines' expected to delete 3 row(s); Only 0 were matchedThe database already removed them via ON DELETE CASCADE while the ORM also tried.Add passive_deletes=True so the ORM stops trying.
InvalidRequestError: For many-to-many relationship ..., delete-orphan cascade is normally configured only on the "one" side of a one-to-many relationshipdelete-orphan on a secondary relationship.Use an association object, or add single_parent=True if the child really has one owner.
Child rows left behind after deleting a parentpassive_deletes=True without ON DELETE CASCADE in the schema.Add ondelete="CASCADE" and migrate the constraint.
Deleting a parent takes minutesORM cascade loading hundreds of thousands of children.passive_deletes=True and let the database cascade.
The cascade values that matter Four tiles. save-update and merge is the default: adding a parent to a session adds its children, and nothing is deleted. delete extends a parent delete to its children. delete-orphan deletes a child that is removed from the collection or reassigned. all is shorthand for save-update, merge, refresh-expire, expunge and delete, and is usually written together with delete-orphan. save-update, merge the default children follow the parent into the session delete parent delete → child delete still loads the children delete-orphan removed from the collection → the row is deleted "all, delete-orphan" the usual choice for true composition Use it only where the child cannot exist without the parent: order lines, not customers.

The expected to delete 3 row(s); Only 0 were matched warning is worth understanding rather than silencing, because it means two mechanisms are fighting. The database removed the children as part of the parent's delete, and the ORM then issued its own DELETE for rows that were already gone. Nothing is corrupted, but the extra statements are wasted and the warning hides real mismatches — such as a row deleted by another transaction — that the same message would otherwise report.

The many-to-many restriction reflects a real ambiguity. In a secondary relationship, the rows on the other side are typically shared: deleting a Tag because it was removed from one Post would be wrong. single_parent=True asserts that sharing does not happen, and SQLAlchemy then allows delete-orphan. When the association itself carries data, the right answer is an association object, covered in using association objects for many-to-many with extra columns.

Advanced: Choosing Between ORM and Database Cascades

Both mechanisms delete children; they differ in who does the work and what else runs.

Who deletes the children? Left, ORM cascade: SQLAlchemy loads and deletes children itself, so ORM events and delete-orphan semantics apply, at the cost of loading every child row. Right, passive_deletes with ON DELETE CASCADE: the database deletes children in one statement, covering every writer including other services, but ORM events never fire for those rows. ORM cascade loads every child to delete it ORM events fire per row only covers ORM deletes slow for large collections passive_deletes + ON DELETE CASCADE one DELETE, database cascades no per-row ORM events covers every writer fast for any size Declare both: cascade="all, delete-orphan", passive_deletes=True, and ondelete="CASCADE" on the FK.

An ORM cascade loads each child and deletes it individually, so before_delete and after_delete mapper events fire per row, history tables and search indexes maintained in Python stay correct, and delete-orphan works for collection removals — something no database rule can express. The cost is one SELECT per collection plus one DELETE per level, and memory for every child.

A database cascade is a single statement. It covers deletes issued by anything — other services, a psql session, a bulk delete() — and its cost does not grow with collection size. What it cannot do is run Python.

from sqlalchemy import ForeignKey, event
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    # Composition, deleted by the database, with the ORM aware of the rule.
    lines: Mapped[list["OrderLine"]] = relationship(
        back_populates="order", cascade="all, delete-orphan", passive_deletes=True
    )
    # Not composition: an order's customer outlives it.
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id", ondelete="RESTRICT"))

ondelete="RESTRICT" on the other relationship is the counterpart worth adopting deliberately: it makes the database refuse to delete a customer who still has orders, turning a data-integrity mistake into an error instead of a cascade nobody intended.

When per-row Python must run and collections are large, do the work in bulk before the delete rather than per row:

from sqlalchemy import delete, select

from shop.models import Order, OrderLine
from shop.search import remove_documents


async def delete_order(session, order_id: int) -> None:
    line_ids = (await session.scalars(
        select(OrderLine.id).where(OrderLine.order_id == order_id)
    )).all()
    await remove_documents("order_line", line_ids)     # one call, not one per row
    await session.execute(delete(Order).where(Order.id == order_id))
    await session.commit()

Soft deletion is the third option, and often the right one for anything a user might want back. Instead of cascading, mark rows deleted and filter them out of every query — the pattern in filtering soft-deleted rows with with_loader_criteria, which applies the filter to eager loads as well as to top-level queries.

Testing Cascades Before They Reach Production

Cascade bugs are asymmetric: the error cases fail loudly in development, while the quiet ones — orphan rows left behind, passive_deletes without a schema rule — surface months later as data nobody can explain. Two small tests per composition relationship cover both.

Before you set a cascade Three checks. Is the child owned by exactly one parent, so deleting the parent should really delete it? Does the foreign key column allow NULL, because without a cascade the ORM will try to null it. Does the database have ON DELETE CASCADE, which is what makes passive_deletes safe and fast. is the child owned by one parent? order lines yes; a customer referenced by many orders, no — that is a foreign key, not composition is the foreign key NOT NULL? if so, the default cascade cannot work: it blanks the column and the insert fails does the schema have ON DELETE CASCADE? required before passive_deletes=True, or children are silently left behind

The first deletes a parent and asserts the children are gone from the database, not from the session. Querying in a fresh session after the commit is what makes it meaningful:

import pytest
from sqlalchemy import func, select

from shop.models import Order, OrderLine


@pytest.mark.asyncio
async def test_deleting_an_order_deletes_its_lines(session_factory, order_factory):
    order_id = await order_factory(line_count=3)

    async with session_factory() as session:
        order = await session.get(Order, order_id)
        await session.delete(order)
        await session.commit()

    async with session_factory() as session:
        remaining = await session.scalar(
            select(func.count(OrderLine.id)).where(OrderLine.order_id == order_id)
        )
    assert remaining == 0


@pytest.mark.asyncio
async def test_removing_a_line_deletes_the_row(session_factory, order_factory):
    order_id = await order_factory(line_count=3)

    async with session_factory() as session:
        order = await session.get(Order, order_id, options=[selectinload(Order.lines)])
        order.lines.remove(order.lines[0])       # delete-orphan
        await session.commit()

    async with session_factory() as session:
        remaining = await session.scalar(
            select(func.count(OrderLine.id)).where(OrderLine.order_id == order_id)
        )
    assert remaining == 2

The second test is the one that catches a missing delete-orphan: without it, removing the line only nulls its foreign key, and the count stays at three — or the commit fails.

A schema-level check is worth adding once, for the whole model. passive_deletes=True is a promise about the database, and a test can verify the promise holds for every relationship that makes it:

import pytest
from sqlalchemy import inspect

from shop.models import Base


@pytest.mark.asyncio
async def test_passive_deletes_relationships_have_ondelete_cascade(migrated_engine):
    async with migrated_engine.connect() as conn:
        rules = await conn.run_sync(lambda c: {
            (t, fk["constrained_columns"][0]): (fk.get("options") or {}).get("ondelete")
            for t in inspect(c).get_table_names()
            for fk in inspect(c).get_foreign_keys(t)
        })

    for mapper in Base.registry.mappers:
        for rel in mapper.relationships:
            if not rel.passive_deletes:
                continue
            for column in rel.remote_side:
                if column.primary_key:
                    continue
                actual = rules.get((column.table.name, column.name))
                assert actual and actual.upper() == "CASCADE", (
                    f"{mapper.class_.__name__}.{rel.key} uses passive_deletes, but "
                    f"{column.table.name}.{column.name} has ON DELETE {actual!r}"
                )

Run it against a database built by the migrations, as in running tests against a Postgres testcontainer, so it checks the schema you actually deploy rather than the one create_all() would build.

Frequently Asked Questions

What is the difference between delete and delete-orphan?

delete extends a parent's deletion to its children. delete-orphan additionally deletes a child that is removed from the parent's collection or reassigned, even when the parent itself is untouched. Composition relationships normally want both.

Do I need ON DELETE CASCADE if I set cascade="all, delete-orphan"?

Not for correctness through the ORM, but it is strongly recommended: it covers bulk deletes and other writers, and it is what lets passive_deletes=True avoid loading every child row.

Why does session.delete() raise MissingGreenlet?

Because the cascade needed to load a collection that was not loaded, and lazy loading is not possible under async. Use passive_deletes=True, or eager-load the collection before deleting.

Does delete(Order).where(...) cascade?

Only through the database. A set-based delete never loads the parents, so ORM cascades and delete-orphan do not run; the schema's ON DELETE rules are what apply.