Overcoming Limitations

Copy Markdown View Source

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:

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
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
  • 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 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
  • 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:

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

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_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 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.