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.

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