Skip to main content

guides/overcoming-limitations.md

# Overcoming Limitations

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: true` is passed
- `Repo.update/2`, `Repo.update!/2`, `Repo.update_all/3`, and loaded
  `Repo.insert_or_update*` calls skip soft-deleted rows unless
  `with_deleted: true` is passed
- direct `Repo.delete*` and `Repo.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:

```elixir
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](repo-module-setup.md#bypassing-schemas-and-tables) 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:

```elixir
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
end
```

This 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: true` is passed
- that source is [bypassed](repo-module-setup.md#bypassing-schemas-and-tables)
- that source's schema has no `deleted_at` field

The Lazarus-level escape hatches here are `with_deleted: true` for intentional
deleted-row visibility and
[source bypasses](repo-module-setup.md#bypassing-schemas-and-tables) 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/2`
- `Repo.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: true` is passed (to allow updating soft-deleted records)
- the schema is [bypassed](repo-module-setup.md#bypassing-schemas-and-tables)
- the schema has no `deleted_at` field
- the changeset is invalid
- the changeset is unchanged and `force: true` is not passed
- the loaded struct already has `deleted_at` set, 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:

```elixir
Repo.soft_delete(post, reload_after_delete: true)
```

See [Fetch and Delete APIs](fetch-and-delete-apis.md#soft-delete-one-row) 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: true` is 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:

```elixir
Repo.soft_delete_all(query, cascade: true)
```

Skip specific branches when the association should be handled separately:

```elixir
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_at` field
- the parent schema lists the association in `@hard_delete_on_replace`
- the association does not use a delete-triggering `on_replace` strategy

See [Assoc Replace guide](assoc-replace.md) 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:

```elixir
from(post in Post, where: post.title == ^title)
```

When raw SQL is intentional, opt in per call:

```elixir
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](#use-a-separate-repo-module).

### Schema-less sources are limited

Ecto allows bare table strings:

```elixir
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:

```elixir
from(post in Post)
```

Or attach a schema to an explicit table source:

```elixir
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:

```elixir
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](query-support.md) 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:

```elixir
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](#repo-behavior-changes) for both patterns.