Soft Delete & Restore¶
Soft deletion is optional and configured per viewset. The supported combinations
are SQLAlchemy with BaseViewset or AsyncBaseViewset, Tortoise with
AsyncBaseViewset, and Peewee with BaseViewset.
Boolean marker¶
First add an is_deleted boolean column to the ORM model and migrate existing
rows to False. For example, with SQLAlchemy:
from sqlalchemy import Boolean, Column
# Inside your SQLAlchemy model:
is_deleted = Column(Boolean, nullable=False, default=False, server_default="false")
The corresponding Tortoise field is fields.BooleanField(default=False);
Peewee uses BooleanField(default=False). Then configure the viewset:
from fastapi.security import OAuth2PasswordBearer
from fastapi_viewsets import BaseViewset, SoftDeleteConfig
items = BaseViewset(
endpoint="/items",
model=Item,
response_model=ItemSchema,
db_session=get_session,
soft_delete=SoftDeleteConfig(),
)
items.register(
oauth_protect=OAuth2PasswordBearer(tokenUrl="/token"),
protected_methods=["DELETE"],
)
app.include_router(items)
For async SQLAlchemy or Tortoise, use AsyncBaseViewset and the appropriate
adapter/session factory. orm_adapter=... can be passed as usual. Configuring one
viewset does not change other viewsets using the same model or adapter.
Timestamp marker¶
Use a nullable datetime column when you need to retain the deletion time:
Pass soft_delete=policy to the viewset. An active row has deleted_at = NULL;
DELETE writes the current UTC datetime, and restore clears it to NULL. Prefer a
timezone-aware column when supported by your database. With SQLAlchemy use
Column(DateTime(timezone=True), nullable=True), with Tortoise use
fields.DatetimeField(null=True), and with Peewee use DateTimeField(null=True).
Configuration rejects missing columns, primary keys, and mismatched marker types when constructing the viewset. Timestamp columns must be nullable. The package does not add columns or run migrations. Backfill boolean markers to False; boolean NULL values are not considered active.
HTTP behavior¶
| Operation | Behavior with soft deletion enabled |
|---|---|
GET /items |
Only active rows; filtering happens before limit/offset |
GET /items/{id} |
Deleted and missing records return 404 |
POST /items |
Always creates an active record |
PUT/PATCH /items/{id} |
Deleted records return 404; marker changes in bodies are ignored |
DELETE /items/{id} |
Marks an active record deleted and returns the existing success response |
POST /items/{id}/restore |
Restores a deleted record; active and missing records return 404 |
Restore returns {"status": true, "text": "successfully restored"}. Repeating
DELETE on a deleted record returns 404 and does not replace its deletion time.
Search, ordering, query filters, and eager loading continue to work. Query
parameters, including include_deleted, cannot remove the active-record filter.
Route selection and permissions¶
register() automatically adds RESTORE when soft deletion is enabled and DELETE
is registered. Explicit methods=["RESTORE"] can expose only restoration; adding
RESTORE without a soft-delete configuration raises ValueError. Registering only
LIST/GET does not add a restore route. There is no built-in permanent-delete route
for a soft-delete viewset.
RESTORE inherits DELETE's protection when DELETE is listed in protected_methods.
It can also be protected explicitly with protected_methods=["RESTORE"]. With no
configured OAuth2 dependency, routes remain public, matching other CRUD methods.
OAuth2PasswordBearer extracts a token; application-specific token validation and
object permissions remain your responsibility, as described in
Authentication.
Constraints, relations and custom adapters¶
Soft-deleted rows still occupy unique values unless the application defines a database-specific partial unique index. If restoring a record would violate a constraint, the endpoint returns 409 and leaves its marker unchanged. Restoration does not merge records or resolve those conflicts automatically.
The policy applies to the viewset's root records. It does not cascade to children,
change direct ORM queries, or filter records embedded through relationships.
Eager loading still uses the existing RelatedConfig behavior.
The default soft_delete=None retains physical DELETE and existing routes.
Third-party adapters need no changes for existing usage. An adapter wishing to
support the option must explicitly declare supports_soft_delete = True, support
the optional soft_delete keyword on its CRUD methods, and implement the relevant
restore_element/restore_element_async method. Optional base restore methods are
not abstract, so existing adapters remain instantiable.