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.
Soft delete
Everything above describes what delete does by default: a row removed, and
a graph walked to say what goes with it. ModelAdmin.soft_delete_field
replaces that with something narrower — a write instead of a removal, and
nothing to walk at all.
from fastfort import admin
class OrderAdmin(admin.ModelAdmin):
soft_delete_field = "deleted_at"
Name a nullable boolean or date/datetime column the project already owns, and
delete stops calling the adapter’s delete — it writes the marker instead.
No migration beyond the column itself, no second table: a trashed row never
leaves the one it was always in.
deletion_plan is never consulted. A soft-deleted row has not gone anywhere,
so nothing pointing at it needs to cascade, clear or block — every effect
this page describes above is a question this pipeline no longer asks. The
confirmation page reflects that: no related-records warnings, and “this
cannot be undone” becomes “it can be restored from Trash.”
The list gains a Trash view (?trashed=1, and a link to it beside Add) that
shows only the marked rows, offering Restore in place of Delete. A
boolean marker takes True/False; a date or datetime marker takes the
moment it was trashed and None — nullable is required of the second kind
and not the first, because a boolean already has both its states built in.
The marker field itself never appears on a create or edit form. It is the deletion pipeline’s own bookkeeping, not a value anyone should set by hand.
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