Ecto.Multi: Composing Transactional Operations Without Callback Hell
Ecto.Multi replaces nested transaction callbacks with a declarative pipeline of named steps that share an accumulator, giving you atomic rollback, precise error tuples, and testable operations — without callback hell.
07 Nov 2025, 21:21 UTC

The problem with raw transactions
Elixir developers often reach for Repo.transaction/1 when they need multiple database operations to succeed or fail together. The callback style works for two or three steps, but it quickly becomes a pyramid of nested case statements. Each step must manually pass IDs or changes to the next, and error handling turns into pattern-matching on constraint violation strings. The result is code that's hard to read, harder to test, and fragile when requirements change.
How Ecto.Multi changes the model
Ecto.Multi replaces the callback with a declarative pipeline. You start with Ecto.Multi.new() and chain named operations — insert, update, delete, or custom run functions. Each step returns a new Multi struct, so the pipeline is immutable and can be inspected or unit-tested without a database connection.
The key mechanism is the accumulator: a map of %{operation_name: result} that threads through every step. A run function receives this accumulator as its second argument, letting later steps reference IDs or changes from earlier inserts without global state or process dictionaries.
When you finally call Repo.transaction(multi), Ecto executes all steps in a single database transaction. If any step returns {:error, value} or raises, the whole transaction rolls back and you get a precise error tuple: {:error, failed_operation_name, failed_value, accumulated_changes}. No more parsing PostgreSQL error codes to tell the user what went wrong.
Worked example: user registration with profile and welcome email
Imagine a registration flow that creates a User, a Profile linked to that user, and sends a welcome email — all atomically. With raw Repo.transaction you'd nest three callbacks and manually pass the user ID. With Ecto.Multi it looks like this:
def register_user(attrs) do
multi =
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.changeset(%User{}, attrs))
|> Ecto.Multi.insert(:profile, fn %{user: user} ->
Profile.changeset(%Profile{}, user_id: user.id, %{})
end)
|> Ecto.Multi.run(:email, fn %{user: user, profile: _profile} ->
case Email.deliver_welcome(user.email) do
:ok -> {:ok, :sent}
{:error, reason} -> {:error, reason}
end
end)
Repo.transaction(multi)
end
Notice three things:
- The
:profileinsert uses a function that receives the accumulator, so it can pulluser.idfrom the:userstep. - The
:emailstep is arunfunction that also receives the accumulator. It can accessuser.emaildirectly. - If
Email.deliver_welcome/1returns{:error, :smtp_down}, the transaction rolls back the user and profile inserts automatically. The caller receives{:error, :email, :smtp_down, %{user: %User{}, profile: %Profile{}}}— enough context to show a meaningful message or retry later.
Trade-offs and limitations
Connection pooling: All operations in a Multi share one database connection. A slow run function (e.g., an HTTP call to an external API) holds that connection for the entire duration. With the default pool size of 10, a handful of concurrent registrations that each spend 500 ms in the email step will exhaust the pool. Move slow I/O outside the transaction, or use a separate process with a compensation strategy (e.g., a background job that sends the email and records its status).
No automatic retries: Multi does not retry deadlocks or serialization failures. If your workload is contention-prone, wrap Repo.transaction in a retry loop or use a library like :retry.
Exceptions are not caught: A run function that raises will roll back the transaction, but the exception propagates uncaught. Convert expected failures to {:error, reason} tuples; let truly exceptional crashes bubble up.
Not a saga orchestrator: Ecto.Multi provides atomicity within a single database transaction only. For distributed transactions across services, you need a separate saga/compensation pattern — Multi won't help you roll back a payment gateway call.
When to reach for Multi
Use Ecto.Multi when you have two or more database operations that must succeed together, especially when later steps depend on data produced by earlier ones. It shines for:
- Creating parent and child records in one go (orders + line items, users + profiles).
- Complex updates that touch multiple tables with conditional logic.
- Any flow where you want precise, structured error information without parsing database error strings.
Stick with raw Repo.transaction for simple one-liners or when you need fine-grained control over savepoints. And always benchmark connection usage if your run steps do I/O — a quick mix run --no-halt with a simulated load will reveal pool exhaustion before it hits production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.