Soft Deletes¶
Soft deletes mark rows as deleted instead of removing them, so records can be audited or restored later. In Ferro this is two flag fields plus a small mixin that supplies the lifecycle methods.
The Pattern¶
from datetime import UTC, datetime
from ferro import Field, Model, connect, engines
from ferro.query import Query
class SoftDeleteMixin:
"""Soft-delete behavior as a plain mixin.
Declare ``is_deleted`` and ``deleted_at`` on each concrete model;
the mixin supplies the lifecycle methods.
"""
async def soft_delete(self) -> None:
self.is_deleted = True
self.deleted_at = datetime.now(UTC)
await self.save()
async def restore(self) -> None:
self.is_deleted = False
self.deleted_at = None
await self.save()
@classmethod
def active(cls) -> Query:
return cls.where(lambda invoice: invoice.is_deleted == False) # noqa: E712
class Invoice(SoftDeleteMixin, Model):
id: int | None = Field(default=None, primary_key=True)
number: str
is_deleted: bool = False
deleted_at: datetime | None = None
from datetime import UTC, datetime
from typing import Annotated
from ferro import Field, Model, connect, engines
from ferro.query import Query
class SoftDeleteMixin:
"""Soft-delete behavior as a plain mixin.
Declare ``is_deleted`` and ``deleted_at`` on each concrete model;
the mixin supplies the lifecycle methods.
"""
async def soft_delete(self) -> None:
self.is_deleted = True
self.deleted_at = datetime.now(UTC)
await self.save()
async def restore(self) -> None:
self.is_deleted = False
self.deleted_at = None
await self.save()
@classmethod
def active(cls) -> Query:
return cls.where(lambda invoice: invoice.is_deleted == False) # noqa: E712
class Invoice(SoftDeleteMixin, Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
number: str
is_deleted: bool = False
deleted_at: datetime | None = None
- Fields on the concrete model.
is_deletedanddeleted_atare declared onInvoiceitself. Every model that wants soft deletes repeats these two declarations. SoftDeleteMixinfor behavior.soft_delete()andrestore()flip the flags andsave();active()is a classmethod returning a normalQueryfiltered to non-deleted rows, so it chains like any other query.
The mixin is a plain class, not a Model subclass. Ferro registers a table schema for each model class as it is defined, so a Model base class cannot contribute fields to subclasses — declare fields on each concrete model and keep shared behavior in the mixin. (See the Timestamps how-to for the same pattern.)
Usage¶
invoice = await Invoice.create(number="INV-001")
await Invoice.create(number="INV-002")
await invoice.soft_delete()
assert await Invoice.active().count() == 1
assert await Invoice.select().count() == 2 # row still exists
await invoice.restore()
assert await Invoice.active().count() == 2
soft_delete() keeps the row in the table — Invoice.select().count() still sees it — while Invoice.active() excludes it. restore() brings it back.
Querying¶
Use active() as the entry point everywhere you would otherwise use select() or where():
unpaid = await Invoice.active().where(lambda t: t.number.like("INV-%")).all()
trashed = await Invoice.where(lambda t: t.is_deleted == True).all() # noqa: E712
Two things to remember:
- Nothing is filtered automatically.
Invoice.all(),Invoice.select(),Invoice.get(pk)and relationship traversals still return soft-deleted rows. Theactive()discipline is a convention your code must follow. - Batch and instance deletes bypass soft delete.
await invoice.delete()andawait Invoice.where(...).delete()issue realDELETEstatements — the mixin only addssoft_delete(), it does not intercept the built-in delete paths. Reach for the hard delete deliberately (e.g. retention cleanup), not by accident.
If you want soft-deleted rows to age out, a periodic job can purge them for real:
async def purge_deleted() -> int:
return await Invoice.where(lambda t: t.is_deleted == True).delete() # noqa: E712
Trade-offs¶
- Unique constraints see soft-deleted rows. A unique column like
number: str = Field(unique=True)still holds the value after a soft delete, so creating a replacement with the same number fails. Options: hard-delete in that flow, rename the value on soft delete (e.g. suffix the primary key), or drop the database-level constraint and enforce uniqueness among active rows in application code. - Data growth. Soft-deleted rows stay in the table and its indexes, so table scans, backups, and index sizes grow forever unless you purge. Pair soft deletes with a retention policy.
- Privacy. "Deleted" data is still data. If users expect deletion to remove personal information, soft delete alone does not satisfy that — schedule a real purge.
See Also¶
- Timestamps how-to — the mixin pattern explained in detail
- Queries guide — building filtered queries
- Mutations guide —
save(),delete(), and batch operations