Ecto.Multi: Transactions as Composable Data Structures
Ecto.Multi models database transactions as explicit, composable data structures instead of callback blocks. This post walks through a registration pipeline, shows how steps share results via an accumulator, and explains why keeping side effects out of the transaction matters.
01 Feb 2026, 09:34 UTC

The Problem: Implicit Transaction Logic
Most ORMs treat transactions as callback blocks: you open a transaction, run a series of operations, and hope nothing fails halfway through. Debugging a failed multi-step operation means adding logging at each step or reproducing the exact failure state. Elixir's Ecto.Multi takes a different approach: it models a transaction as an explicit, inspectable data structure — a pipeline of named operations that you build, pass around, and execute atomically.
How Ecto.Multi Works
An Ecto.Multi is a struct (%Ecto.Multi{}) that holds a list of operations. Each operation has a unique name (an atom) and a function that receives an accumulator map containing results from previous steps. You add steps with functions like Multi.insert/3, Multi.update/3, Multi.delete/3, and Multi.run/3 for arbitrary code. The pipeline executes inside Repo.transaction/1, which returns {:ok, results} on success or {:error, failed_op, failed_value, changes_so_far} on failure — no exceptions for control flow.
Worked Example: User Registration Flow
Consider a registration that creates a User, a Profile, sends a welcome email, and logs an AuditEntry — all atomically. The email step needs the generated user_id from the insert.
defmodule Accounts.Registration do
alias Ecto.Multi
alias MyApp.{Repo, User, Profile, AuditEntry}
alias MyApp.Mailer
def register_user(attrs) do
multi = Multi.new()
|> Multi.insert(:insert_user, User.registration_changeset(%User{}, attrs))
|> Multi.insert(:insert_profile, fn %{insert_user: user} ->
Profile.changeset(%Profile{}, user_id: user.id, prefs: %{})
end)
|> Multi.run(:send_welcome_email, fn %{insert_user: user} ->
case Mailer.deliver_welcome(user.email, user.id) do
:ok -> {:ok, :email_sent}
{:error, reason} -> {:error, reason}
end
end)
|> Multi.insert(:log_audit, fn %{insert_user: user} ->
AuditEntry.changeset(%AuditEntry{},
user_id: user.id,
action: :registration,
metadata: %{}
)
end)
Repo.transaction(multi)
end
end
Each step accesses prior results via pattern matching on the accumulator. If send_welcome_email returns an error tuple, the entire transaction rolls back — no user, no profile, no audit row.
Why Data-Structure Transactions Matter
- Dynamic composition: Build pipelines conditionally — add an
:assign_rolestep only for admin invitations. - Testability: Inspect the
Multistruct before execution; assert on step names and dependencies without a database. - Reusability: Extract common fragments (e.g.,
audit_multi/1) and compose them into larger pipelines. - Observability: Since Ecto 3.0, telemetry events
[:ecto, :multi, :run]emit duration, operation name, and result for each step.
Trade-offs and Limitations
No savepoints or nested transactions. A failure rolls back the entire pipeline. If you need partial commit semantics, split into separate Repo.transaction calls.
Long-running I/O in run steps blocks the DB connection. An HTTP call to an email service holds the transaction open, increasing lock contention and pool exhaustion risk. The recommended pattern: keep side effects minimal inside the transaction, or use the transactional outbox — write an OutboxEvent row in the same transaction, then have a background worker publish events reliably.
Name collisions overwrite silently. The accumulator keys are the operation names. Use a consistent prefix convention (:insert_user, :create_profile) to avoid accidental overwrites.
Database constraints remain the source of truth. Ecto.Multi does not replace unique indexes, foreign keys, or check constraints. Validation via Ecto.Changeset surfaces errors cleanly, but the database still enforces integrity.
Practical Verification
- Create a test module that builds the registration
Multiand runs it viaRepo.transaction/1. - Force a failure in a later step (e.g., invalid email changeset) and assert no rows persist in
users,profiles, oraudit_entries. - Attach a telemetry handler to
[:ecto, :multi, :run]and verify each step emits a measurement withoperationandduration. - Run tests under
Ecto.Adapters.SQL.Sandboxto confirm isolation across concurrent tests.
Closing Takeaway
Ecto.Multi shifts transaction logic from implicit callbacks to explicit, composable data. That makes pipelines inspectable, testable, and reusable — but it demands discipline: keep side effects out of the critical path, name steps deliberately, and rely on database constraints for the final word on integrity. Start by converting one complex callback-based transaction into a Multi pipeline; the difference in debuggability is immediate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.