Deleting things

The confirmation counts what actually goes — rows that cascade, rows kept with the link cleared, and rows that block the delete outright.

Available since v0.1.0

Most admins ask “are you sure?” and then find out. FastFort walks the graph before anything is written and reports, per related model, what would actually happen.

The three effects

EffectWhenWhat the page says
DELETEORM cascade, or ON DELETE CASCADE“will be deleted too”
CLEARnullable FK, or ON DELETE SET NULL“kept, without the link”
PROTECTNOT NULL FK with nothing cascadingblocks the delete

Each one is read off the mapper and the constraint, not guessed.

class Invoice(Base):
    # Declared, so the plan reports DELETE.
    lines: Mapped[list[InvoiceLine]] = relationship(
        back_populates="invoice", cascade="all, delete-orphan"
    )


class InvoiceLine(Base):
    # NOT NULL with no database cascade → PROTECT.
    invoice_id: Mapped[int] = mapped_column(sa.ForeignKey("invoices.id"))


class Zone(Base):
    # Nullable with SET NULL → CLEAR.
    hub_id: Mapped[int | None] = mapped_column(
        sa.ForeignKey("hubs.id", ondelete="SET NULL")
    )

It reports what the ORM will do, not what you meant

This is the part that matters. A model that never declared cascade="all, delete-orphan" gets SQLAlchemy’s default behaviour: children are nulled, not deleted.

FastFort reports that honestly rather than claiming a cascade the ORM will not perform. If your confirmation says “kept, without the link” and you expected a cascade, the confirmation is right and the model is missing a declaration.

PROTECT refuses in a sentence

This customer cannot be deleted: 3 invoice lines would be left without an
invoice.

Not an IntegrityError with a constraint name in it, arriving after the write was attempted.

Found from the child side

Relations are discovered by scanning the registry’s mappers for many-to-one relationships that land on this table — from the child, not from a back-reference on the parent. So a foreign key whose model never declared a back-reference is still found.

Many-to-many is deliberately excluded: deleting a product removes its rows from product_tags, not the tags themselves.

Cascades are followed, and counted honestly

Three levels deep. Deeper levels are reached through a subquery, not through the rows the level above happened to sample — otherwise the second number would be a fraction printed as a total.

A composite foreign key stops the recursion.

Deleting 1 customer will affect:

  Invoices          14  kept, without the link
  Shipments         86  kept, without the link
  Invoice lines     41  will be deleted too       ← reached at depth 2, through
                                                    the invoice cascade

Five rows are named by way of example. The count stops at a thousand and renders as “1000+” rather than running a COUNT(*) over a table that might have millions.

Checked twice

Once on the confirmation page, and again immediately before the write — because a confirmation page may have been open for an hour and the graph may have changed under it. The same second check runs before the bulk-delete action.

The inline buttons

The delete button on each row uses a cheap spec-level hint — whether any relation cascades — with no query and no counts. The full plan is only computed for the confirmation page, because computing it per row would be one graph walk per row of the table.

Deleting from an action

The built-in delete action runs the same plan across every selected row. If any of them is protected, nothing is deleted and the page says which — half a bulk delete is worse than none, because the operator now has to work out which half.

Seeing it

In the demo:

/admin/billing.invoice/1/delete      DELETE — the lines cascade
/admin/billing.customer/1/delete     CLEAR at two depths, and a DELETE at depth 2
/admin/logistics.hub/1/delete        CLEAR — the zones are kept