This version is in beta. Some features may change before release.

Soft delete

Tag a model with soft_delete and deletes become reversible. Rows hide instead of dying.

Soft delete

Tag a model #[umbral(soft_delete)] and the framework stops destroying its rows: delete() becomes an UPDATE ... SET deleted_at = <now> (the current UTC timestamp, bound as a parameter - not a SQL NOW() call, so it's identical on SQLite and Postgres), and every normal query automatically excludes rows where deleted_at is set. You get a trash can instead of a shredder: undo, audits, and "restore this account" flows all stay possible.

The rewrite is idempotent: it only touches rows where deleted_at IS NULL, so re-deleting an already-trashed row never bumps its timestamp.

You declare the timestamp column yourself (the marker doesn't synthesize it):

Code
rust
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
#[umbral(soft_delete)]
pub struct Post {
pub id: i64,
pub title: String,
pub deleted_at: Option<DateTime<Utc>>,
}

That's the whole setup. From here:

Code
rust
// Soft-deletes: UPDATE post SET deleted_at = NOW() WHERE title = 'a'
Post::objects().filter(post::TITLE.eq("a")).delete().await?;
 
// Normal reads never see the row again: WHERE deleted_at IS NULL
// is injected on every terminal (fetch / first / get / count / exists).
let visible = Post::objects().fetch().await?;
 
// Opt back in:
let everything = Post::objects().with_deleted().fetch().await?; // live + trashed
let trash = Post::objects().only_deleted().fetch().await?; // trashed only
 
// Restore: clear the timestamp on the trashed row.
// .only_deleted() scopes the UPDATE to trashed rows only, so no live
// rows are touched even if you omit the ID filter.
let mut patch = serde_json::Map::new();
patch.insert("deleted_at".into(), serde_json::Value::Null);
Post::objects().only_deleted().filter(post::ID.eq(id)).update_values(patch).await?;
 
// True purge ("empty the trash"): opt-in, two explicit steps.
Post::objects()
.filter(post::ID.eq(id))
.with_deleted()
.hard_delete()
.delete()
.await?;

Models without the marker keep the classic behaviour: delete() is a real DELETE FROM, no filter injection.

Info
Adding soft delete to an existing model is the normal migration loop: add the attribute and the nullable `deleted_at` field, run `makemigrations` (autodetects a nullable `AddColumn`, no default needed), then `migrate`. Existing rows are untouched and stay visible, since their `deleted_at` starts NULL.
Warning
Soft-deleted rows still exist: they count toward UNIQUE constraints, foreign keys keep pointing at them, and they occupy the table. If a unique column (say `email`) must be reusable after deletion, that's a schema design decision soft delete doesn't make for you.
Info
`update_values` and `update_expr` honour the same soft-delete scope as reads: the default queryset only updates live rows (`deleted_at IS NULL`); `.with_deleted()` lifts the filter (live + trashed); `.only_deleted()` restricts to trashed rows only. This is what makes the restore pattern above safe - no explicit `filter(post::ID.eq(id))` is strictly required when `.only_deleted()` is in play, though adding one is good practice for targeted restores.

Admin and REST

The dynamic path (DynQuerySet) used by the admin and the REST plugin honours soft-delete identically: list endpoints exclude trashed rows by default, and the DELETE endpoint soft-deletes instead of hard-deleting. The same with_deleted() / only_deleted() / hard_delete() toggles are available on DynQuerySet, plus a restore() terminal that clears deleted_at on the matched trashed rows (the inverse of a soft delete()).

The admin ships a full trash UI for any #[umbral(soft_delete)] model, with no per-model config:

  • A Trash / Active toggle in the changelist header (with a trashed-row count badge). ?trash=1 lists only soft-deleted rows; the default view shows the live set.
  • A built-in Restore selected bulk action (and per-row restore in the trash view) that calls DynQuerySet::restore().
  • A built-in Delete permanently bulk action behind a confirm dialog - a real hard_delete(), so the row leaves the table entirely.
  • The default Delete selected action soft-deletes (moves rows to trash) on a soft-delete model, because it routes through DynQuerySet::delete().

Restore is gated by the change_<model> permission; Delete-permanently by delete_<model>. See the Admin plugin docs for the trash workflow.

Pinned by crates/umbral-core/tests/soft_delete.rs (feature #72, gaps2 #34), crates/umbral-core/tests/soft_delete_dynamic.rs (gaps2 #35), and plugins/umbral-admin/tests/soft_delete_admin.rs (the admin trash UI); behaviour reference in the Model::SOFT_DELETE docs in crates/umbral-core/src/orm/model.rs.

ormsoft-deletetrash