This guide is the high-level map of Lazarus's read, update, and delete surface.
Read APIs
Lazarus does not add separate read helpers. Instead, it changes the behavior of
ordinary Repo reads by filtering out soft-deleted rows unless you opt in with
with_deleted: true.
Default behavior
Schema-backed reads hide soft-deleted rows:
Repo.get(Post, id)
Repo.all(Post)
from(post in Post, select: post.id)
|> Repo.all()Opting into deleted rows
Pass with_deleted: true when you intentionally want deleted rows included:
Repo.get(Post, id, with_deleted: true)
Repo.all(Post, with_deleted: true)
from(post in Post, where: post.author_id == ^author_id)
|> Repo.all(with_deleted: true)The same opt works for preloads:
post =
Repo.get!(Post, id, with_deleted: true)
|> Repo.preload(:comments, with_deleted: true)
account =
Repo.get!(Account, id)
|> Repo.preload([posts: [:comments]], with_deleted: true)Joined schema-aware sources follow the same with_deleted behavior as query
roots. This means, with with_deleted: false, Lazarus will recurse into
supported deeper queries to exclude soft-deleted records. Otherwise, if
with_deleted: true, results of the root and joined queries will include
soft-delete records, essentially disabling any Lazarus filters.
Schema-less roots and joins raise by default unless you pass
allow_schema_less_sources: true. Raw fragments and direct SQL calls raise by
default unless you pass allow_raw_sql: true.
Use
repo-level bypass_schemas / bypass_tables
for third-party sources Lazarus should always leave alone. Repo-level bypasses
apply to configured schema and table sources; when a bypassed source is the
query root, Lazarus leaves that Ecto query alone, including fragments. They do
not allow direct SQL calls.
See Query Support for the full query-shape rules.
Update APIs
For schemas with soft_deletes(), Lazarus changes updates so soft-deleted rows
are ignored by default. In practice, already-deleted rows behave like rows that
were physically deleted unless you pass with_deleted: true.
Update one row
For schemas with soft_deletes(), Repo.update/2 and Repo.update!/2 calls
affect only active rows by default.
post =
Repo.get!(Post, id)
|> Ecto.Changeset.change(title: "Updated title")
Repo.update(post)If the row was already soft-deleted, Lazarus treats the update as stale. That means the usual Ecto stale options still apply:
Repo.update(changeset, stale_error_field: :deleted_at)
Repo.update(changeset, stale_error_field: :deleted_at, stale_error_message: "was deleted")
Repo.update(changeset, allow_stale: true)Invalid changesets still use Ecto's normal validation error path. Unchanged
non-forced changesets keep Ecto's no-op behavior. force: true counts as an
update attempt and does not allow updating a soft-deleted row.
To intentionally update a soft-deleted row, pass with_deleted: true:
post =
Repo.get!(Post, id, with_deleted: true)
|> Ecto.Changeset.change(title: "Administrative correction")
Repo.update(post, with_deleted: true)Schemas without Lazarus soft-delete fields and bypassed schemas keep Ecto update behavior.
Insert or update
Repo.insert_or_update/2 and Repo.insert_or_update!/2 follow the same rule
when the changeset data is loaded: the update side ignores soft-deleted records
unless with_deleted: true is passed.
loaded_post
|> Ecto.Changeset.change(title: "Updated title")
|> Repo.insert_or_update()Built changesets still insert normally:
%Post{}
|> Ecto.Changeset.change(title: "New post")
|> Repo.insert_or_update()Update many rows
Repo.update_all/3 uses Lazarus query filtering. For schema-aware sources,
active rows are updated by default and soft-deleted rows are skipped.
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all(set: [status: "archived"])Pass with_deleted: true when a bulk update should include soft-deleted rows:
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all([set: [status: "archived"]], with_deleted: true)Lazarus follows Ecto's bulk-update return shape: {count, nil} without a
select, and {count, returned} when a select is present. The count and
returned rows reflect what was actually updated.
Joined schema-aware sources, subqueries, CTEs, and update expressions follow the
same query-shape rules as reads. Prefer schema-aware sources for bulk updates.
Schema-less update targets raise by default; allow_schema_less_sources: true
is an explicit escape hatch, and those updates run unfiltered because Lazarus
has no schema metadata to identify soft-deleted rows. Raw SQL fragments require
allow_raw_sql: true.
See Query Support for the exact update query rules.
Delete APIs
Soft-delete one row
Use Repo.soft_delete/2 when you want to keep the row and mark it deleted.
{:ok, deleted_post} = Repo.soft_delete(post, reason: "Deleted by user")Pass cascade: true when eligible associations should also be deleted:
{:ok, deleted_post} = Repo.soft_delete(post, cascade: true)See Cascade Soft-Deletes for more details.
Use Repo.soft_delete!/2 when you want the same behavior but prefer a raising
API.
deleted_post = Repo.soft_delete!(post)
deleted_post = Repo.soft_delete!(post, reason: "Moderator action")
deleted_post = Repo.soft_delete!(post, reason: "Moderator action", reload_after_delete: true)Under the bonnet, all soft deletes (single row soft_delete and bulk
soft_delete_all) use the update_all function. Single-row soft_delete
builds a primary-key query, runs it through the same bulk path, and returns
{:error, :not_found} when no active row was updated because the row was
already soft-deleted or no longer exists.
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 delete (Repo.soft_delete/2) returns an in-memory
copy of the loaded struct with soft-delete fields applied and loaded
associations reset to Ecto.Association.NotLoaded. That avoids a follow-up
read, keeping soft-deletes cheap and soft-delete fields up-to-date.
If you absolutely need fresh data, you can pass reload_after_delete: true,
which will perform an additional Repo.get!/3 with with_deleted: true after
the update succeeds. This keeps the data returned fresh, at the cost of an
additional database call.
{:ok, deleted_post} = Repo.soft_delete(post, reload_after_delete: true)For the exact return values and option list, see Lazarus.soft_delete/3,
Lazarus.soft_delete!/3, and the injected Repo docs described in
Lazarus.Repo.
Soft-delete many rows
Use Repo.soft_delete_all/2 when you want to mark every row in a schema-aware
query as deleted.
{2, nil} =
Repo.soft_delete_all(
from(post in Post, where: post.author_id == ^author_id),
reason: "Bulk cleanup"
)
{2, titles} =
Repo.soft_delete_all(
from(post in Post,
where: post.author_id == ^author_id,
select: post.title
)
)Lazarus follows Ecto's bulk-update return shape here: {count, nil} without a
select, and {count, returned} when a select is present.
Repo.soft_delete_all/2 requires a schema-aware root query. See
Query Support for the allowed query shapes.
Pass cascade: true when the bulk soft delete should also traverse eligible
associations:
Repo.soft_delete_all(query, cascade: true)See Cascade Soft-Deletes for more details.
Hard-delete one row
Use Repo.hard_delete/2 when you explicitly want ordinary physical delete
semantics.
{:ok, _post} = Repo.hard_delete(post)
deleted_post = Repo.hard_delete!(post)Use Repo.hard_delete!/2 when you prefer the raising variant.
Hard-delete many rows
Use Repo.hard_delete_all/2 for explicit bulk physical deletes.
{3, nil} =
Repo.hard_delete_all(
from(row in "audit_logs", where: field(row, :inserted_at) < ^cutoff)
)
{3, ids} =
Repo.hard_delete_all(
from(post in Post,
where: post.author_id == ^author_id,
select: post.id
)
)Like ordinary Ecto bulk deletes, the return value is {count, nil} without a
select, and {count, returned} when a select is present.
Hard delete is the escape hatch, so it remains available for schema-aware queries and schema-less table queries that Ecto can delete from.
Ordinary Repo.delete* calls
Outside bypassed schemas and tables, Lazarus disables direct Repo.delete* and
Repo.delete_all* calls:
Repo.delete/1Repo.delete/2Repo.delete!/1Repo.delete!/2Repo.delete_all/1Repo.delete_all/2
Use Repo.soft_delete* or Repo.hard_delete* instead.
Repo APIs vs Lazarus Helpers
The Repo.* functions are the usual application-facing API:
- ordinary Ecto read functions such as
Repo.get/3,Repo.all/2, andRepo.one/2 - Ecto update functions with Lazarus soft-delete behavior, such as
Repo.update/2,Repo.update!/2,Repo.update_all/3,Repo.insert_or_update/2, andRepo.insert_or_update!/2 Repo.soft_delete/2Repo.soft_delete!/2Repo.soft_delete_all/2Repo.hard_delete/2Repo.hard_delete!/2Repo.hard_delete_all/2
The Lazarus.* helpers are the repo-explicit equivalents for cases where you
want to pass the Repo module yourself:
Lazarus.soft_delete(Repo, struct_or_changeset, opts)Lazarus.soft_delete!(Repo, struct_or_changeset, opts)Lazarus.soft_delete_all(Repo, queryable, opts)
For most application code, the Repo API is the natural choice. The explicit
Lazarus.* helpers are more useful in shared helpers, tests, or integrations
that should not assume a specific Repo module has already imported the injected
functions. There are no repo-explicit Lazarus helpers for updates; use the Repo
update functions listed above, with the soft-delete behavior added by
use Lazarus.