Mastering Ecto.Multi: Atomic Transactions in Elixir
Learn how to group Repo operations with Ecto.Multi, capture validation errors, avoid pool exhaustion, and build reliable transactional workflows in Elixir apps.
10 Jan 2026, 06:54 UTC

Why Ecto.Multi matters
When an Elixir application needs to perform several database operations that must either all succeed or all fail, a single Repo.transaction/1 call is not enough. The Ecto.Multi struct lets you compose those operations, capture validation errors, and keep the code readable. The key idea is that every step in the transaction is named, and the final result is a map keyed by those names. This gives you deterministic access to each operation’s return value and a clean error path.
Core API walk‑through
The API is intentionally small:
Multi.new/0– creates an empty struct.Multi.insert/4,Multi.update/4,Multi.delete/4– queue a DML operation. The fourth argument is aChangesetor a struct; if the changeset is invalid the operation is skipped and the error is recorded.Multi.run/3– injects arbitrary logic. It receives a function that must return either{:ok, result}or{:error, reason}. The:returnoption (since 3.5) lets you return the raw value instead of wrapping it in a tuple.Multi.merge/2– combine twoMultistructs. Duplicate names in the second argument overwrite the first.
After building the Multi, call Repo.transaction(multi). The return value is a two‑tuple:
{:ok, %{op_name1: result1, op_name2: result2, ...}}
{:error, failed_op, failed_result, changes_so_far}
The changes_so_far map contains the results of all operations that ran before the failure. This is useful for logging or compensating actions.
Worked example: user sign‑up with notification
Below is a typical pattern you might put in a context module. Replace MyApp.Repo, MyApp.Accounts.User, and MyApp.Accounts.Profile with your own modules.
# lib/my_app/accounts.ex
alias MyApp.Repo
alias MyApp.Accounts.{User, Profile}
def register_user(attrs) do
user_changeset = User.changeset(%User{}, attrs)
profile_changeset = Profile.changeset(%Profile{}, %{user_id: nil})
multi =
Multi.new()
|> Multi.insert(:create_user, user_changeset)
|> Multi.update(:attach_profile, fn %{create_user: user} ->
profile_changeset
|> Ecto.Changeset.put_change(:user_id, user.id)
end)
|> Multi.run(:notify, fn %{create_user: user} ->
case MyApp.Notifier.send_welcome(user.email) do
:ok -> {:ok, :sent}
{:error, reason} -> {:error, reason}
end
end, return: :ok)
case Repo.transaction(multi) do
{:ok, %{create_user: user, attach_profile: _profile, notify: _}} ->
{:ok, user}
{:error, failed_op, failed_result, changes} ->
# Log or handle the failure
{:error, failed_op, failed_result, changes}
end
end
Key points:
- The
attach_profilestep receives the result ofcreate_uservia thefnargument. This lets you reference the newly inserted user ID. - The
notifystep is a side‑effect. By usingreturn: :okyou keep the result map clean: the value will betrueinstead of{:ok, :sent}. - If the user changeset is invalid, the entire transaction rolls back and you get an
{:error, :create_user, changeset, %{}}result.
Trade‑offs and limitations
- Connection pool exhaustion: All operations in a
Multirun on the same DB connection. Long‑running:runfunctions (HTTP calls, heavy math) hold the connection and can deplete the pool. Move heavy I/O outside the transaction or useTask.asyncwith explicit compensation logic. - No automatic retry: PostgreSQL deadlocks or serialization failures are not retried by
Repo.transaction/1. Wrap the call in a retry loop or use a library likedb_connection’s retry options. - Manual error aggregation: Errors from
:runsteps are not merged into the changeset’serrorsmap. If you need unified validation feedback, callEcto.Changeset.add_error/3manually. - No nested transactions: Only the outermost
Repo.transactioncreates a real DB transaction. Calls toMulti.mergesimply flatten the operations. - Custom SQL inside
:run: UsingRepo.query/3bypasses Ecto’s type safety and logging. Keep such calls minimal and document them clearly.
Practical checklist before deploying
- Test the flow: In
iex -S mix, build aMultithat inserts a user, fails on the notification step, and verify the rollback andchanges_so_farmap. - Monitor pool usage: In production, enable
pool_sizemetrics (e.g.,telemetry) to detect exhaustion spikes when:runsteps are heavy. - Add retry logic: Wrap
Repo.transactionin a retry loop that catches{:error, :deadlock_detected, _}and retries a limited number of times. - Document side‑effects: For any
:runthat performs external calls, keep a separate log and consider idempotency keys so that a retry does not create duplicates. - Keep operations atomic: If you need to call an external API that must succeed before the DB commit, consider moving the call outside the transaction and using a compensating transaction on failure.
Takeaway
Ecto.Multi gives you a clean, declarative way to bundle multiple Repo calls into a single atomic transaction, with structured error handling and a deterministic result map. By respecting its limitations—especially connection pool usage and the lack of automatic retries—you can build robust, maintainable workflows in any Elixir application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.