Lazarus is a strict soft-delete safety layer for Ecto. It changes Repo behavior on purpose so accidental hard deletes, accidental updates to deleted rows, and soft-deleted rows leaking through supported queries become harder to miss.
That safety comes with tradeoffs. This guide explains the main limitations, why they exist, and what to do when an application needs an escape hatch.
The general rule is: prefer schema-aware Ecto code, and use the smallest escape hatch that matches the limitation.
Repo behavior changes
When you use Lazarus in a Repo, that Repo no longer behaves exactly like a
plain Ecto.Repo.
Lazarus changes these paths:
- normal reads hide soft-deleted rows unless
with_deleted: trueis passed Repo.update/2,Repo.update!/2,Repo.update_all/3, and loadedRepo.insert_or_update*calls skip soft-deleted rows unlesswith_deleted: trueis passed- direct
Repo.delete*andRepo.delete_all*calls are disabled unless the source is bypassed - raw SQL calls through the Repo require an explicit raw SQL opt-in
This is the point of the library. Lazarus is trying to make deletion behavior
explicit at the Repo boundary instead of relying on every caller to remember the
right where: is_nil(deleted_at) clause.
Bypass selected sources
If specific schemas or tables should keep ordinary Ecto behavior inside a
wrapped Repo (e.g. ability to use Repo.delete), configure bypasses:
use Lazarus,
bypass_schemas: [Oban.Job],
bypass_tables: ["oban_peers"]Use bypasses for sources Lazarus can identify from Ecto query structure or schema modules. Bypassed sources keep ordinary read, update, and delete behavior where those operations apply. Bypasses do not apply to direct raw SQL calls because Lazarus cannot inspect the SQL text to identify a source.
See Repo Module Setup for the detailed bypass rules.
Use a separate Repo module
If an integration broadly expects plain Ecto behavior, a separate plain Repo
module is often cleaner than many bypasses. Point the plain module at the
started Lazarus Repo with Ecto's :default_dynamic_repo option:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
use Lazarus
end
defmodule MyApp.Repo.Plain do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres,
default_dynamic_repo: MyApp.Repo
def default_options(_operation) do
[
with_deleted: true,
allow_raw_sql: true,
allow_schema_less_sources: true
]
end
endThis makes the boundary explicit: application-owned code uses the Lazarus Repo, while the integration receives a Repo module with plain Ecto function definitions.
Do not add the plain Repo module to your supervision tree. If it is not started
separately, default_dynamic_repo: MyApp.Repo makes its operations use the
already-started MyApp.Repo pool. That means both Repo modules share the same
pool, sandbox owner, and transaction boundary.
The plain module's functions are not Lazarus-wrapped, so calls such as
MyApp.Repo.Plain.delete/2, MyApp.Repo.Plain.delete_all/2, and
MyApp.Repo.Plain.query!/3 keep ordinary Ecto behavior. Query operations may
still pass through the wrapped Repo's prepare_query/3 through Ecto adapter
metadata, so the plain module sets defaults that tell Lazarus to stand down for
read and bulk-update filtering, fragments, and schema-less query sources.
Additional database work
Lazarus does not add the same cost to every operation. Some paths add
soft-delete predicates such as deleted_at IS NULL to generated SQL, while
others issue additional queries or writes for safety checks, cascades, reloads,
or association replacement handling.
Because those predicates become part of the SQL your database executes, hot
tables should have indexes that match active-row access patterns. Where your
database supports them, filtered or partial indexes such as
WHERE deleted_at IS NULL are often a better fit than indexing deleted_at
alone.
These tradeoffs are intentional: Lazarus spends database work to avoid changing or exposing rows outside the active-row scope.
Read and bulk-update filtering
Normal reads (e.g. Repo.all/2) and Repo.update_all/3 do not usually add
extra round trips. Instead, Lazarus rewrites supported Ecto queries so generated
SQL includes soft-delete predicates such as deleted_at IS NULL.
This applies to schema-aware roots, joins, preloads, subqueries, CTEs, and other
supported query shapes. It is why soft-deleted rows stay hidden beyond the
simplest Repo.all(Post) query.
Soft-delete predicates are not added for an individual source when any of these are true:
with_deleted: trueis passed- that source is bypassed
- that source's schema has no
deleted_atfield
The Lazarus-level escape hatches here are with_deleted: true for intentional
deleted-row visibility and
source bypasses for trusted
third-party sources.
Single-row updates
For single-row updates (Repo.update/2) on schemas with deleted_at field,
Lazarus checks that the loaded row still exists and is still active before it
lets Ecto run the update.
It does this with an exists? query against the row primary key and
deleted_at IS NULL. If that check fails, Lazarus treats the update as stale
before prepare_changes/2 callbacks run.
This happens for:
Repo.update/2Repo.update!/2- loaded
Repo.insert_or_update/2 - loaded
Repo.insert_or_update!/2 - changed changesets, or unchanged changesets with
force: true
This additional database work is skipped when any of these are true:
with_deleted: trueis passed (to allow updating soft-deleted records)- the schema is bypassed
- the schema has no
deleted_atfield - the changeset is invalid
- the changeset is unchanged and
force: trueis not passed - the loaded struct already has
deleted_atset, in which case Lazarus treats it as stale without an extra existence check
The check exists because the loaded struct may be stale. Another process may
have soft-deleted or removed the row after it was loaded. Running
prepare_changes/2 or the update in that situation would make a deleted row
look writable.
Soft-deletes
Lazarus soft deletes are implemented as guarded bulk updates. A single-row
Repo.soft_delete/2 turns the loaded struct into a primary-key query, while
Repo.soft_delete_all/2 starts from the query you pass in. Both paths update
only active rows by adding deleted_at IS NULL before setting the deleted-at
field, which depends on the database being able to execute that predicate
efficiently.
Optional reload after soft-delete
Single-row soft-deletes (Repo.soft_delete/2) use update_all under the
bonnet. Because of that, we do not automatically get the most up-to-date row
data as a return value like we would from a single-row operation such as
update or delete.
By default, single-row soft-deletes return an updated in-memory struct and reset
loaded associations to Ecto.Association.NotLoaded. That avoids a follow-up
read, keeping soft-deletes cheap and soft-delete fields up-to-date.
If reload_after_delete: true is passed or set in config, Lazarus performs an
additional Repo.get!/3 with with_deleted: true after the guarded update
succeeds. This keeps the data returned fresh, at the cost of an additional
database call.
Use the reload only when the caller needs a fresh database view of the deleted row:
Repo.soft_delete(post, reload_after_delete: true)See Fetch and Delete APIs for more details on single-row soft-deletes.
Cascading soft deletes
By default (cascade: false), the database work is one guarded update_all
against the root query.
With cascade: true, Lazarus walks eligible association branches inside a
transaction, adding more database work before the root update. It opens a
transaction, checks whether the root query has active rows, walks eligible
associations, and then applies the same guarded root update. Each traversed
branch can add its own existence check plus a guarded soft-delete update, hard
delete, or nilify update depending on association metadata. That means cascade
cost grows with the association graph, not just with the number of root rows.
Association traversal does not continue when any of the following are true:
cascade: trueis not passed- the association is listed in
skip_associations - the association has no supported cascade action
- the query matches no active parent rows
Enable cascading when eligible association branches should also be deleted:
Repo.soft_delete_all(query, cascade: true)Skip specific branches when the association should be handled separately:
Repo.soft_delete(post, cascade: true, skip_associations: [:comments])Association replacement deletes
Ecto can call Repo.delete/2 internally during association replacement, for
example through cast_assoc/3 or put_assoc/4 when an association uses
on_replace: :delete or on_replace: :delete_if_exists.
Lazarus detects that internal call. If the removed child supports soft deletion
and the parent association is not configured for hard-delete replacement,
Lazarus routes the child through Repo.soft_delete/2 instead of letting Ecto
physically delete it.
That means association replacement pays the same guarded soft-delete cost as a direct single-row soft delete. By default, association-replacement deletes do not traverse child association branches.
The additional costs do not happen when one of the following are true:
- the child schema is bypassed
- the child schema has no
deleted_atfield - the parent schema lists the association in
@hard_delete_on_replace - the association does not use a delete-triggering
on_replacestrategy
See Assoc Replace guide for more details on association replacement.
Lazarus needs inspectable Ecto metadata
Lazarus is schema-first. It can apply soft-delete behavior reliably only when it can inspect structured Ecto metadata.
That metadata tells Lazarus which field stores the deleted timestamp, which table a schema maps to, whether a deletion reason field exists, and which associations and cascade rules exist. Database foreign keys alone are not enough because Lazarus does not inspect database constraints to infer soft-delete or cascade behavior.
This is the tradeoff: schema-aware Ecto queries get Lazarus safety, while code
that hides the data source behind raw SQL or bare table strings is either
restricted or needs an explicit escape hatch. Prefer schema modules or
{"table", Schema} sources when Lazarus should manage a source.
Raw SQL is restricted
Lazarus rejects raw SQL by default because it cannot safely inspect arbitrary SQL or know where soft-delete predicates should be added.
This includes Ecto fragments, fragment-backed root or join sources,
fragment-backed CTE definitions, direct Repo.query* calls, and raw SQL
Repo.stream/3 calls through a wrapped Repo.
Prefer inspectable Ecto query expressions:
from(post in Post, where: post.title == ^title)When raw SQL is intentional, opt in per call:
from(post in Post,
where: fragment("lower(?) = ?", post.title, ^title)
)
|> Repo.all(allow_raw_sql: true)
Repo.query!("select * from posts where id = $1", [id], allow_raw_sql: true)This opt-in acknowledges the raw SQL. Direct calls to
Ecto.Adapters.SQL.query/4 and Ecto.Adapters.SQL.stream/4 bypass Lazarus
entirely, so the caller owns any soft-delete predicates and safety checks.
If raw SQL is needed across a broad integration boundary, prefer a separate plain Repo module.
Schema-less sources are limited
Ecto allows bare table strings:
from(row in "posts")Lazarus can see that this is an Ecto query, but without a schema it cannot know whether the table is soft-deletable or which field should be checked.
Prefer schema modules:
from(post in Post)Or attach a schema to an explicit table source:
from(post in {"posts", Post})Reads and Repo.update_all/3 queries with schema-less roots or joins raise by
default. When the schema-less source is intentional, opt in per call:
Repo.all(query, allow_schema_less_sources: true)This opt-in acknowledges that Lazarus cannot inspect the source. It does not make the source soft-delete-aware, so schema-less reads and bulk-update targets run with ordinary Ecto visibility for that source.
Repo.soft_delete_all/2 is stricter: the root must be schema-aware even with
allow_schema_less_sources: true, because Lazarus must know which deleted-at
field to update. Schema-less joins or nested sources can still be acknowledged
inside a schema-aware Repo.soft_delete_all/2 query.
See Query Support for the full query-shape matrix.
Soft-deleted rows still exist
Soft deletion changes application visibility. It does not remove the row from the database.
That means database-level behavior still sees the row:
- unique constraints still consider soft-deleted rows unless the index excludes them
- foreign keys still point at soft-deleted rows
- reporting queries and direct SQL still return soft-deleted rows unless they filter them out
- storage and index size still grow until rows are physically deleted
How to work with it
Database constraints remain outside Lazarus. For PostgreSQL, partial unique indexes can exclude soft-deleted rows:
create unique_index(:users, [:email],
where: "deleted_at IS NULL"
)Direct SQL also remains outside Lazarus, so it must include its own soft-delete predicates when deleted rows should be hidden.
Use Repo.hard_delete/2 or Repo.hard_delete_all/2 when the row should be
physically removed.
Third-party libraries
Third-party libraries may assume ordinary Ecto.Repo behavior. That can
conflict with Lazarus when a library issues direct deletes, uses schema-less
tables, runs raw SQL, or manages lifecycle tables that should not be
soft-delete-filtered.
Treat this as a Repo-boundary decision. For libraries that touch known Ecto schemas or tables, use source bypasses. For libraries that broadly expect plain Ecto behavior, use a separate plain Repo module. See Repo behavior changes for both patterns.