Mastering Ecto.Multi: Atomic Transactions for Complex Business Logic
Ecto.Multi lets you chain inserts, updates, deletes, and custom functions into a single atomic transaction. Learn how to build a Multi, handle errors, use nested transactions, and avoid common pitfalls with clear code examples.
23 Oct 2025, 11:05 UTC

Why Use Ecto.Multi?
When a feature requires several database changes that must all succeed or fail together, Ecto.Multi provides a declarative way to group those operations. Instead of writing separate Repo.insert or Repo.update calls and manually rolling back on error, you build a named map of steps and let the library handle the transaction boundaries.
Building a Multi – The Core Pattern
A Multi is created with Multi.new/0 and then populated with steps. Each step is a function that receives the current state map and returns {:ok, value} or {:error, reason}. The final step is executed by Repo.transaction/1, which returns either {:ok, results} or {:error, failed_step, failed_value, changes_so_far}.
# schemas/user.ex
schema "users" do
field :email, :string
field :name, :string
timestamps()
end
# schemas/profile.ex
schema "profiles" do
field :user_id, :integer
field :bio, :string
timestamps()
end
# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
alias MyApp.{Repo, User, Profile}
import Ecto.Multi
def register_user(attrs) do
user_changeset = User.changeset(%User{}, attrs)
profile_changeset = Profile.changeset(%Profile{}, %{user_id: nil, bio: ""})
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.update(:profile, profile_changeset)
|> Multi.run(:notify, fn _repo, %{user: user, profile: profile} ->
Notification.send(user, profile)
{:ok, :sent}
end)
|> Repo.transaction()
end
end
In this example the :profile update references user_id that is only known after the user is inserted. To propagate that value you would normally use a Multi.run step to fetch the inserted user ID and update the profile changeset before the update step. The key point is that the Repo.transaction/1 call guarantees atomicity: if any step fails, all previous changes are rolled back.
Handling Failure Results
When a step fails, Repo.transaction/1 returns a four‑tuple: {:error, failed_step, failed_value, changes_so_far}. The changes_so_far map contains the results of all successful steps up to the point of failure.
case MyApp.Accounts.register_user(%{email: "[contact removed]", name: "Alice"}) do
{:ok, results} -> IO.puts("All good: #{inspect(results)}")
{:error, step, reason, changes} ->
IO.warn("Step #{step} failed: #{inspect(reason)}")
IO.inspect(changes, label: "Rolled back changes")
end
Common mistake: ignoring the {:error, …} tuple and assuming the transaction succeeded. Always pattern‑match on the result to handle errors gracefully.
Nested Transactions with :join
Some adapters (e.g., PostgreSQL) support true nested transactions via savepoints. Ecto exposes this through the Repo.transaction/2 options. The :join option creates a nested transaction that can be rolled back independently.
Repo.transaction(fn ->
# outer transaction code
Repo.transaction(fn ->
# inner transaction code
Repo.rollback(:inner_failed)
end, join: true)
# outer transaction continues
end)
If the inner transaction rolls back, the outer transaction can still commit. However, adapters that do not support savepoints will ignore :join, resulting in a single flat transaction. Verify adapter support by checking the documentation or running a test that uses Repo.transaction with join: true and inspecting the error tuple.
Common Mistakes and How to Avoid Them
- Side‑effects in
Multi.run: Functions that perform external I/O (e.g., sending emails, writing files) will execute regardless of the transaction outcome. If the transaction rolls back, those side effects are not undone. Keep side effects separate or wrap them in idempotent logic. - Assuming step order: Steps run in the order they are added. Adding a step that depends on a previous step’s result but placing it earlier will cause a failure. Keep the logical sequence clear.
- Ignoring
Repo.transaction/2options: Options liketimeoutorread_concernare only applied to the outer transaction. Nested transactions inherit these unless overridden. - Large bulk operations: Using
Multi.insert_allinside a Multi can lock tables for extended periods. Monitor lock contention and consider batching or background jobs.
Limitations to Keep in Mind
- Ecto.Multi does not wrap non‑database functions in a transaction. Any side effect is outside the atomic boundary.
- Nested transactions are only true savepoints on adapters that support them. On others,
join: trueis a no‑op. - Adapters that emulate transactions may not support all Multi operations (e.g.,
Multi.delete_all).
Practical Verification Checklist
- Create a simple
usersandprofilesschema. - Build a Multi that inserts a user, updates a profile, and intentionally violates a unique constraint.
- Run
Repo.transactionand confirm the return tuple indicates failure on the offending step. - Check the database to ensure no rows were persisted.
- Add a
Multi.runstep that writes to a log file; after a rollback, verify the log entry still exists. - Test nested transactions on PostgreSQL with
join: trueand confirm inner rollback does not affect outer commit.
By following these patterns and pitfalls, you can harness Ecto.Multi to build reliable, maintainable, and atomic database workflows in your Elixir applications.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.