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
| Effect | When | What the page says |
|---|---|---|
DELETE | ORM cascade, or ON DELETE CASCADE | “will be deleted too” |
CLEAR | nullable FK, or ON DELETE SET NULL | “kept, without the link” |
PROTECT | NOT NULL FK with nothing cascading | blocks 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