Ecto.Multi: Atomic Writes Without the Side Effect Leak
Ecto.Multi gives you atomic database writes, but external side effects aren't rolled back. Use a commit-then-act pattern to keep your data consistent.
28 Sept 2025, 03:34 UTC

You wire an order flow with Ecto.Multi: insert the order, decrement inventory, then call a payment API. The payment succeeds, the inventory step hits a constraint, and the transaction rolls back. Your database is clean, but the card was charged. That mismatch is the problem.
The fix is not to make the API call transactional—that is impossible. The fix is to keep Ecto.Multi strictly for database writes and move external side effects to after the commit.
What Ecto.Multi actually guarantees
Ecto.Multi composes several Repo operations into a single transaction. If any step fails, every database change is rolled back. That prevents partial writes, such as an order row existing while inventory was never decremented.
All steps must target the same Repo. Ecto does not provide distributed transactions across multiple databases. If your workflow spans two repos, you need a different consistency strategy.
You can name each step and append operations conditionally, which keeps the pipeline readable. When a step fails, the transaction short-circuits and returns an error tuple that identifies the failed step.
The run/3 trap
Multi.run/3 lets you execute an arbitrary function inside the transaction. Database calls made there are still part of the atomic unit. Non-database side effects—HTTP requests, emails, file writes—are not. They execute immediately and are not rolled back if a later step fails.
Treating an external call as if it were transactional is the most common failure mode. The database rolls back, but the external system has already acted.
Worked example: order, inventory, then notify
Imagine a function that creates an order and decrements stock. The notification email belongs outside the transaction.
defmodule MyApp.Orders do
alias Ecto.Multi
alias MyApp.{Repo, Order, Inventory, Notifier}
def create_order(params) do
multi =
Multi.new()
|> Multi.insert(:order, Order.changeset(%Order{}, params))
|> Multi.run(:inventory, fn _repo, %{order: order} ->
Inventory.decrement_stock(order.product_id, order.quantity)
end)
case Repo.transaction(multi) do
{:ok, %{order: order}} ->
# Committed: safe to notify
Notifier.send_confirmation(order)
{:ok, order}
{:error, step, reason, _changes} ->
{:error, {step, reason}}
end
end
end
In this shape, Notifier.send_confirmation/1 runs only after Repo.transaction/1 returns {:ok, ...}. If the inventory step fails, the email never fires.
Verify the exact error tuple shape against your Ecto version. Callback signatures and error shapes can differ between releases, so check the current documentation and changelog for your project.
Why external calls inside Multi break consistency
External systems do not participate in your database transaction. They cannot be rolled back. If you call a payment gateway, shipping service, or email provider inside a Multi.run/3 step and a later step fails, the side effect persists.
The safe options are:
- Move the side effect after the commit, as shown above.
- Make the side effect idempotent—meaning repeated calls produce the same result—and reconcile later with a background job or deduplication table.
Idempotency keys and outbox tables are common patterns, but they add operational complexity. Post-commit execution is simpler when your workflow can tolerate a brief delay.
Limitations to plan around
Long-running transactions hold locks and increase contention. If an HTTP call inside a Multi step takes three seconds, the database locks are held for three seconds. Keep steps short and move slow work outside the transaction.
Ecto.Multi targets a single Repo. It does not coordinate across multiple databases. If your write path spans repos, you will need application-level compensation or a different tool.
How to check this in your project
- Write a test that forces a later step to fail and assert that earlier database changes are gone. For example, stub the inventory function to return an error and confirm no order row remains.
- Add a fake HTTP client to a
Multi.run/3step, trigger a rollback, and observe that the fake call still happened. That observation confirms the need for post-commit execution. - Benchmark a
Multiwith and without an external call to measure lock time and connection usage. - Check the Ecto documentation and changelog for your version to confirm
Multi.run/3and error tuple shapes.
The takeaway
Use Ecto.Multi for what it is good at: grouping database writes into one atomic unit. Keep HTTP calls, emails, and other irreversible actions out of the transaction. Commit first, then act. If you cannot avoid calling out during the transaction, design for at-least-once delivery and make the operation idempotent.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.