A soft-delete library for Ecto/Elixir with guard rails.
Lazarus is a strict soft-delete safety layer for Ecto, not just a convenience wrapper. It is for applications where soft-delete correctness matters more than unrestricted query flexibility.
Motivation
Soft deletion is one of those patterns that looks simple until a team has to live with it at scale. Across multiple teams and projects, I kept seeing the same class of bugs surface around deletion logic: records being hard-deleted accidentally, soft-deleted records leaking back into queries, and edge cases slipping through in more complex queries through subqueries, joins, and unions. This package came out of that frustration: it aims to make soft-delete behavior predictable and reduce the number of ways developers can get it wrong.
Features
- Direct
Repo.delete*andRepo.delete_all*are deprecated and overridden to raise, in favour of forcing explicit soft and hard delete paths (e.g.Repo.soft_delete(...),Repo.hard_delete(...)) - Reads (
Repo.get/2,Repo.all/1, etc.) skip soft-deleted rows by default. To include soft-deleted rows, passwith_deleted: true - Updates (
Repo.update/2,Repo.update_all/3, etc.) skip soft-deleted rows by default. To update soft-deleted rows intentionally, passwith_deleted: true - Soft-deleted rows stay hidden through complex Ecto query shapes like joins, subqueries, CTEs, and unions
- Soft deletes can cascade associations where relationships define
on_delete: :delete_allbehaviour - Automatic soft-delete for delete-triggering Ecto association replacement flows such as
on_replace: :deleteandon_replace: :delete_if_exists - Schema and migration helpers
Limitations / Considerations
- Lazarus may issue additional database queries to enforce safety, especially around updates, single-row soft deletes, and cascading deletes. This is an intentional correctness tradeoff that can affect performance-sensitive paths
- Raw SQL and schema-less Ecto sources are restricted because Lazarus cannot safely inspect or filter what it cannot understand
- Third-party libraries that expect ordinary Repo behavior may need source bypasses or a separate unwrapped Repo
- Wrapping a Repo changes default behavior: reads are filtered, updates skip soft-deleted rows, and direct
Repo.delete*andRepo.delete_all*calls are disabled - Soft-delete fields, associations, and cascade behavior need to be represented in Ecto schemas. Database foreign keys alone are not enough for Lazarus to infer soft-delete cascade rules
- Soft-deleted rows still exist in the database, so unique constraints, indexes, foreign keys, reporting queries, and direct SQL need to account for them
See Overcoming Limitations for the full list of limitations and escape hatches.
Compatibility
- Adapter: tested only with Postgres
- Elixir: 1.15 - 1.20
- Ecto: 3.13 - 3.14
Installation
def deps do
[
{:lazarus, "~> 1.0"}
]
endQuick Start
1. Wire your Repo
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
use Lazarus
endThat does three things:
- Blocks direct
Repo.delete*andRepo.delete_all*calls unless the schema or table is bypassed - Adds explicit
Repo.soft_delete*andRepo.hard_delete*functions - Hides soft-deleted rows from normal reads and skips them in updates by default
See Repo Module Setup guide for more info
2. Add soft-delete fields to your schema
defmodule MyApp.Post do
use Ecto.Schema
use Lazarus.Schema
schema "posts" do
soft_deletes()
end
endThis adds deleted_at and deletion_reason fields by default.
See Schema Setup guide for more info
3. Add matching columns in a migration
defmodule MyApp.Repo.Migrations.AddSoftDeletesToPosts do
use Ecto.Migration
use Lazarus.Migrations
def change do
alter table(:posts) do
soft_deletes()
end
end
endThis adds deleted_at and deletion_reason fields by default.
See Migration Setup guide for more info
4. Read and update active rows normally
Repo.get(Post, id)
Repo.all(Post)
post
|> Ecto.Changeset.change(title: "Updated")
|> Repo.update()
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all(set: [status: "archived"])Soft-deleted rows are hidden from reads and skipped by updates.
See Fetch, Update, and Delete APIs and Query Support guides for more info
5. Use the explicit delete APIs
Repo.soft_delete(post, reason: "deleted by user")
Repo.soft_delete_all(Post, reason: "cleanup job")
Repo.hard_delete(post)
Repo.hard_delete_all(Post)See Fetch, Update, and Delete APIs and Query Support guides for more info
6. Include deleted rows only when you mean to
Repo.get(Post, id, with_deleted: true)
Repo.all(Post, with_deleted: true)
post
|> Ecto.Changeset.change(title: "Restore note")
|> Repo.update(with_deleted: true)See Fetch, Update, and Delete APIs and Query Support guides for more info
Cascade soft-deletes
When you soft-delete record(s), you can cascade soft-delete relationships.
Cascading is opt-in. Pass cascade: true to follow association metadata
recursively:
on_delete: :delete_allsoft-deletes the child branchon_delete: :nilify_allis a noop for soft deletes because the parent row still existson_delete: :nothingis a noop
If a child branch is soft-deleted, the related schema needs a deleted_at
field (see schema setup). Branches listed in
@hard_delete_on_cascade are physically deleted instead, and their descendants
follow hard-delete cascade rules.
Example
Suppose we have a Post which has many Comment's
If we want Repo.soft_delete(post, cascade: true) to cascade soft-delete all
comments that belong to that post, we'd need to set them up like so:
# comment.ex
schema "comments" do
soft_deletes()
end
# post.ex
schema "posts" do
has_many :comments, Comment, on_delete: :delete_all
endThen enable cascading at the call site:
Repo.soft_delete(post, cascade: true)See more info: Cascade Soft-Deletes
Association replacement
Ecto can call Repo.delete/2 internally during association management, for example through cast_assoc/3 or put_assoc/4 when an association uses a delete-triggering on_replace strategy such as :delete or :delete_if_exists.
Lazarus intercepts that flow:
- If the child schema has a
deleted_atfield and the parent schema does not opt that association into@hard_delete_on_replace, it is soft-deleted - Otherwise, it is hard-deleted
That means delete-triggering on_replace flows stay data-preserving whenever the child schema is soft-delete-aware, but keep the default hard-delete behaviour when they are not.
See more info: Assoc Replace