Lazarus applies soft-delete behavior automatically only when it can interpret an Ecto query source safely. This guide explains which query shapes are supported, which ones are rejected, and where the rules differ across reads, bulk updates, bulk soft-deletes, and bulk hard-deletes.
Terminology
schema-aware source
- A schema source like
Authororfrom(author in Author). - A schema-attached tuple source like
from(author in {"authors", Author}).
derived raw source
- A raw-looking source name that is defined inside the same query, currently through a CTE.
- Example:
cte =
from(author in Author,
select: %{id: author.id}
)
query =
from(row in "visible_authors",
select: field(row, :id)
)
|> with_cte("visible_authors", as: ^cte)schema-less source
- A bare table string with no schema attached, such as
from(row in "authors")orjoin: row in "authors".
Reads
Schema-aware sources
Schema-aware roots and joins are filtered by default.
Repo.all(Author)
from(author in Author, select: author.id)
|> Repo.all()
from(author in {"authors", Author}, select: author.id)
|> Repo.all()
# Soft-deleted posts AND comments are filtered out
from(post in Post,
join: comment in Comment,
on: comment.post_id == post.id,
select: {post.id, comment.id}
)
|> Repo.all()To include soft-deleted rows, pass with_deleted: true.
Repo.all(Author, with_deleted: true)
from(author in {"authors", Author}, select: author.id)
|> Repo.all(with_deleted: true)
# Soft-deleted posts AND comments are included
from(post in Post,
join: comment in Comment,
on: comment.post_id == post.id,
select: {post.id, comment.id}
)
|> Repo.all(with_deleted: true)That same with_deleted: true opt applies to schema-aware joined sources, not
just query roots.
For outer joins, Lazarus keeps the soft-delete predicate on the join itself, so outer-join semantics are preserved while soft-deleted joined rows stay hidden.
with_deleted: true only controls row visibility. It does not allow query
shapes Lazarus cannot inspect. Use allow_raw_sql: true for intentional raw
SQL, or allow_schema_less_sources: true for intentional schema-less Ecto
sources.
What gets filtered recursively
When Lazarus is active, it does not stop at the query root. It also recurses into schema-aware subqueries and derived queries used inside the rest of the query.
That includes:
- joined subqueries
whereclauses such asexists(...)andnot exists(...)updateexpressions inRepo.update_all/3selectexpressionsorder_byexpressionsgroup_byexpressionshavingexpressionsdistinctexpressions- window definitions
- CTE definitions
- combination queries such as
union_all
The same with_deleted: true opt disables all of that recursive filtering for
the whole operation, which means soft-deleted records will be included.
`where` / `exists(...)`
Schema-aware subqueries inside where are filtered too:
from(location in Location,
as: :location,
where:
exists(
from(author in Author,
where: author.location_id == parent_as(:location).id,
select: 1
)
),
select: location.id
)
|> Repo.all()In that example, soft-deleted Author rows do not satisfy the exists(...)
check unless you pass with_deleted: true.
`select`, `order_by`, `group_by`, and `having`
The same recursive filtering applies inside expression containers such as
select, order_by, group_by, and having.
has_authors_query =
from(author in Author,
where: author.location_id == parent_as(:location).id,
select: 1
)
from(location in Location,
as: :location,
select: %{
id: location.id,
has_authors: exists(has_authors_query)
},
order_by: [desc: exists(has_authors_query)]
)
|> Repo.all()has_authors_query =
from(author in Author,
where: author.location_id == parent_as(:location).id,
select: 1
)
from(location in Location,
as: :location,
group_by: exists(has_authors_query),
having: exists(has_authors_query),
select: {exists(has_authors_query), count(location.id)}
)
|> Repo.all()That means soft-deleted rows do not silently leak back in just because the
subquery appears in grouping or aggregate-related clauses instead of where.
`distinct` and windows
distinct expressions and window definitions are filtered the same way:
has_authors_query =
from(author in Author,
where: author.location_id == parent_as(:location).id,
select: 1
)
from(location in Location,
as: :location,
distinct: exists(has_authors_query),
windows: [
authors: [
partition_by: exists(has_authors_query),
order_by: [desc: location.id]
]
],
select: {location.id, over(row_number(), :authors)}
)
|> Repo.all()Joined subqueries
If you join a subquery, Lazarus recurses into the subquery and filters it:
author_subquery =
from(author in Author,
select: author
)
from(location in Location,
join: author in subquery(author_subquery),
on: author.location_id == location.id,
select: author.id
)
|> Repo.all()CTEs
CTE-backed raw-looking sources are allowed because Lazarus filters the schema-aware query that defines the CTE:
cte =
from(author in Author,
select: %{id: author.id}
)
from(row in "visible_authors",
select: field(row, :id)
)
|> with_cte("visible_authors", as: ^cte)
|> Repo.all()This is why derived raw sources are allowed on the read path while schema-less tables are not.
Combination queries
Combination queries are filtered recursively too:
query_a =
from(author in Author,
where: author.name == "A",
select: %{id: author.id}
)
query_b =
from(author in Author,
where: author.name == "B",
select: %{id: author.id}
)
from(result in subquery(query_a))
|> union_all(^subquery(query_b))
|> select([result], result.id)
|> Repo.all()Both branches are filtered unless you opt out with with_deleted: true.
Schema-less Sources
Schema-less roots and joins raise by default.
from(row in "authors",
select: field(row, :id)
)
|> Repo.all()
from(post in Post,
join: row in "comments",
on: field(row, :post_id) == post.id,
select: {post.id, field(row, :id)}
)
|> Repo.all()The same rule applies to nested schema-less sources inside subqueries. If
Lazarus is recursing through a query and encounters a schema-less source
anywhere in that nested structure, the read raises unless you pass
allow_schema_less_sources: true or the source is configured as a repo-level
bypass.
Use one of these instead:
- switch to the schema:
from(author in Author) - attach the schema explicitly:
from(author in {"authors", Author}) - acknowledge the schema-less source with:
allow_schema_less_sources: true - configure a third-party source with
bypass_schemasorbypass_tables
allow_schema_less_sources: true is the explicit per-query opt-in for
schema-less sources. It does not allow fragments or direct SQL calls. Repo-level
bypasses are for third-party sources Lazarus should always leave alone. When a
bypassed source is the query root, Lazarus leaves the whole Ecto query alone,
including fragments. Bypassed schema-less joins are allowed, but a bypassed join
does not make unrelated fragments in the rest of the query safe.
Read more about repo-level bypasses in Bypassing schemas and tables.
from(row in "authors",
select: field(row, :id)
)
|> Repo.all(allow_schema_less_sources: true)
from(post in Post,
join: row in "comments",
on: field(row, :post_id) == post.id,
select: {post.id, field(row, :id)}
)
|> Repo.all(allow_schema_less_sources: true)Fragments and direct SQL
Lazarus rejects Ecto fragments by default because it cannot safely inspect arbitrary SQL or attach soft-delete predicates inside SQL it does not understand.
That includes fragments in query expressions, fragment-backed roots and joins, and fragment-backed CTE definitions.
# Raise
from(author in Author,
where: fragment("lower(?) = ?", author.name, ^name)
)
|> Repo.all()Use schema-aware Ecto expressions when possible. If the raw SQL is intentional
and you have no other options, pass allow_raw_sql: true.
# Success
from(author in Author,
where: fragment("lower(?) = ?", author.name, ^name)
)
|> Repo.all(allow_raw_sql: true)Direct SQL calls through the Repo also require allow_raw_sql: true.
Repo.query!("select * from authors", [], allow_raw_sql: true)
Repo.stream("select * from authors", [], allow_raw_sql: true)Direct calls to Ecto.Adapters.SQL.query/4 or Ecto.Adapters.SQL.stream/4
bypass Lazarus entirely.
Bulk Update Queries
Repo.update_all/3 is the update API that accepts Ecto queries. It uses the
same recursive filtering model as reads:
schema-aware roots, joins, subqueries, CTE definitions, combinations, selected
return values, and Ecto update expressions are filtered by default. Passing
with_deleted: true disables that filtering for the whole bulk update.
For single-row Repo.update* and loaded Repo.insert_or_update* behavior, see
Fetch, Update, and Delete APIs.
Recursive filtering
Suppose one post has an active comment and another post has only soft-deleted comments:
active_comment_exists =
from(comment in Comment,
where: comment.post_id == parent_as(:post).id,
select: 1
)
from(post in Post,
as: :post,
where: exists(active_comment_exists)
)
|> Repo.update_all(set: [status: "has_comments"])Only the post with an active comment is updated. Passing with_deleted: true
also lets the post with only soft-deleted comments match.
Schema-less update targets
Schema-less update targets raise by default because Lazarus cannot identify which deleted-at field it would need to check. Use one of these instead:
- switch to the schema:
from(post in Post) - attach the schema explicitly:
from(post in {"posts", Post}) - configure a third-party source with
bypass_schemasorbypass_tables
Only acknowledge a schema-less update target when the query is intentional and it is acceptable for Lazarus to leave that target unfiltered:
from(row in "posts", where: field(row, :author_id) == ^author_id)
|> Repo.update_all([set: [status: "archived"]], allow_schema_less_sources: true)allow_schema_less_sources: true preserves default Ecto behavior for the update
target, allowing schema-less query shape but cannot add deleted_at IS NULL, so
matching soft-deleted rows are updated too, essentially removing soft-deleted
row filtering that Lazarus provides.
Raw SQL in bulk updates
Fragments in Repo.update_all/3 queries raise by default, including fragments
inside Ecto update expressions. Lazarus raises because raw SQL can hide table
references or predicates it cannot inspect, so it cannot guarantee that
soft-deleted rows are skipped.
# Raises by default
from(post in Post,
where: fragment("lower(?) = ?", post.title, ^title)
)
|> Repo.update_all(set: [status: "archived"])Use schema-aware Ecto expressions when possible. If the raw SQL is intentional
and you have no other option, pass allow_raw_sql: true.
from(post in Post,
where: fragment("lower(?) = ?", post.title, ^title)
)
|> Repo.update_all([set: [status: "archived"]], allow_raw_sql: true)The raw SQL opt-in acknowledges the fragment, but Lazarus still skips
soft-deleted rows in schema-aware sources unless with_deleted: true is also
passed.
Bulk Soft-Delete Queries
Repo.soft_delete_all/2 is the soft-delete API that accepts Ecto queries. Its
root must be schema-aware because Lazarus needs schema metadata to know which
deleted-at field and deletion-reason field to update. For that reason,
allow_schema_less_sources: true does not allow a schema-less root:
# Success
Repo.soft_delete_all(Post)
Repo.soft_delete_all(from(post in Post, where: post.author_id == ^author_id))
Repo.soft_delete_all(
from(post in {"posts", Post}, where: post.author_id == ^author_id)
)
# Raises
from(row in "posts", where: field(row, :author_id) == ^author_id)
|> Repo.soft_delete_all()
# Still raises; allow_schema_less_sources does not apply to the root
from(row in "posts", where: field(row, :author_id) == ^author_id)
|> Repo.soft_delete_all(allow_schema_less_sources: true)Prefer schema-aware joins and nested sources for the same reason.
# Success (schema-aware root and join)
from(post in Post,
join: flag in PostFlags,
on: field(flag, :post_id) == post.id,
where: field(flag, :name) == "expired"
)
|> Repo.soft_delete_all()
# Raises because the join is schema-less
from(post in Post,
join: flag in "post_flags",
on: field(flag, :post_id) == post.id,
where: field(flag, :name) == "expired"
)
|> Repo.soft_delete_all()If a schema-less join or nested source is unavoidable,
allow_schema_less_sources: true acknowledges that Lazarus cannot inspect that
source. It does nothing for the root query, but still applies inside a
soft-delete query, so it can acknowledge schema-less joins and nested sources as
long as the root remains schema-aware:
# Success (schema-less join is acknowledged explicitly)
from(post in Post,
join: flag in "post_flags",
on: field(flag, :post_id) == post.id,
where: field(flag, :name) == "expired"
)
|> Repo.soft_delete_all(allow_schema_less_sources: true)Keep in mind that allowing schema-less sources does not make them safe. Lazarus is still unable to inspect and apply soft-delete filtering to them.
Fragments inside a bulk soft-delete query raise by default, just like read and
bulk-update fragments. Pass allow_raw_sql: true only when the fragment is
intentional. Fragment-backed roots still raise even with allow_raw_sql: true
because the root must be schema-aware.
# Success (fragment predicate is acknowledged explicitly)
from(post in Post,
where: fragment("lower(?) = ?", post.title, ^title)
)
|> Repo.soft_delete_all(allow_raw_sql: true)
# Raises because the root is not schema-aware
from(row in fragment("select * from posts"))
|> Repo.soft_delete_all(allow_raw_sql: true)Like Ecto bulk write targets, Repo.soft_delete_all/2 also cannot use a
subquery(...) as the query root:
# Raises
inner =
from(post in Post,
where: post.id == ^id,
select: post
)
from(post in subquery(inner))
|> Repo.soft_delete_all()That raises because the root is a derived subquery instead of the table being
updated. This is only a root-target restriction. Subqueries inside where,
join, exists(...), CTEs, and other nested query positions remain supported
and are filtered recursively.
Bulk Hard-Delete Queries
Repo.hard_delete_all/2 delegates to Ecto's physical bulk-delete path. Lazarus
does not apply soft-delete filtering and does not require schema metadata, so
schema-aware roots and schema-less table roots are allowed as long as Ecto and
the database support the delete query.
from(author in Author, where: author.id == ^id)
|> Repo.hard_delete_all()
from(row in "authors", where: field(row, :id) == ^id)
|> Repo.hard_delete_all()For single-row Repo.hard_delete* behavior, see
Fetch, Update, and Delete APIs.