Mastering Atomicity in Elixir: Ecto.Multi for Clean, Testable Transactions
Ecto.Multi lets you group database ops into a single, atomic transaction. Learn how to declare, execute, test, and debug Multi chains—plus common pitfalls and best‑practice tips.
08 Jan 2026, 10:47 UTC

Why Ecto.Multi Matters
When you need several database changes to happen together—like creating a user and immediately updating a related profile—you could nest Repo.transaction blocks. That quickly leads to deep indentation, duplicated error handling, and hard‑to‑reason‑about code. Ecto.Multi solves this by letting you declare a sequence of operations in a flat, data‑structure style, then execute them atomically in a single call.
How Multi Works
At its core, Ecto.Multi is a map where each key is a symbolic name and each value is a function or a pre‑built operation. When you call Repo.transaction(multi), Ecto:
- Opens a database transaction.
- Executes each step in the order they were added.
- Passes the result of a step to the next via a closure.
- Rolls back the entire transaction if any step returns
{:error, reason}or raises. - Returns a map of step results on success.
Because the transaction logic is separated from execution, you can compose Multi objects, test them in isolation, and keep your business logic readable.
Concrete Example: Creating a User and Updating a Profile
Assume we have two schemas: User and Profile, where a profile belongs to a user. The following Multi inserts a user, then updates the profile with the newly created user’s ID.
defmodule MyApp.Accounts do
alias MyApp.{Repo, User, Profile}
import Ecto.Multi
def create_user_with_profile(attrs, profile_attrs) do
multi =
Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, attrs))
|> Multi.update(:profile, fn %{user: user} ->
Profile.changeset(%Profile{}, Map.put(profile_attrs, :user_id, user.id))
end)
case Repo.transaction(multi) do
{:ok, %{user: user, profile: profile}} -> {:ok, user, profile}
{:error, _step, reason, _changes_so_far} -> {:error, reason}
end
end
end
Key points:
Multi.insertandMulti.updateare just helpers that wrap the underlyingEcto.Repofunctions.- The anonymous function for the profile step receives a map of all previous step results—here we extract
userto get itsid. - If the profile update fails (e.g., validation error), the user insert is automatically rolled back.
Testing and Debugging a Multi
Because the Multi is pure data, you can test it without hitting the database:
defmodule MyApp.AccountsTest do
use ExUnit.Case, async: true
alias MyApp.Accounts
test "creates user and profile atomically" do
attrs = %{email: "[contact removed]", password: "secret"}
profile_attrs = %{bio: "Hello"}
assert {:ok, _user, _profile} = Accounts.create_user_with_profile(attrs, profile_attrs)
end
end
When debugging a failing Multi, the error tuple contains the step name that failed, making it easy to pinpoint the problem.
Trade‑Offs and Limitations
- Long‑running side effects inside a transaction are risky. If you call an external API as part of a step, you hold a database lock for the entire duration, potentially exhausting the connection pool. Keep side effects outside the Multi or wrap them in a separate transaction.
- Complex dependency graphs can become opaque. If you have many steps that depend on each other, the closure syntax can become hard to read. Document the data flow or split the Multi into smaller, composable parts.
- Multi only guarantees atomicity for database operations. If your business logic requires external state changes, you’ll need compensating actions or out‑of‑band processing.
Actionable Takeaway
Use Ecto.Multi whenever:
- You need to group several database writes into a single, all‑or‑nothing operation.
- You want to keep your transaction logic declarative and testable.
- You can keep side effects outside the transaction to avoid holding locks.
Next steps: experiment with Ecto.Multi in a small feature, add a failing step to see the rollback in action, and measure how the connection pool behaves under load. This will give you confidence that your application can handle complex data changes safely.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.