27 KiB
Ecto.Multi Patterns
Patterns extracted from Ecto's Ecto.Multi source code.
Contents
Multi.new() |> Multi.insert/update/delete— Named Operation PipelineMulti.run/3— Arbitrary Code in a Transaction- Dependent Operations with Function Variants
Multi.merge/2— Dynamic Transaction CompositionMulti.append/2/Multi.prepend/2— Static Multi Composition- Tuple Keys — Dynamic Collections of Operations
Multi.to_list/1— Testing Without a Database
1. Multi.new() |> Multi.insert/update/delete — Named Operation Pipeline
Source: lib/ecto/multi.ex#L58
def reset(account, params) do
Multi.new()
|> Multi.update(:account, Account.password_reset_changeset(account, params))
|> Multi.insert(:log, Log.password_reset_changeset(account, params))
|> Multi.delete_all(:sessions, Ecto.assoc(account, :sessions))
end
# Execute:
case Repo.transaction(PasswordManager.reset(account, params)) do
{:ok, %{account: account, log: log}} -> # success
{:error, :account, changeset, _} -> # account step failed
end
Why: Each operation is named. On success, Repo.transaction returns {:ok, results_map} where each key is the name given to that operation. On failure, it returns {:error, failed_name, failed_value, changes_so_far}, making it immediately clear which step aborted the transaction and why. This is more precise than a bare transaction function where you'd have to inspect the return value to guess which step failed.
Anti-pattern: Using an anonymous function with bare case statements inside a transaction, where failure attribution is implicit:
# BAD — no way to know which operation failed from the return value alone
Repo.transaction(fn ->
case Repo.update(Account.password_reset_changeset(account, params)) do
{:ok, account} ->
case Repo.insert(Log.password_reset_changeset(account, params)) do
{:ok, log} -> {:ok, %{account: account, log: log}}
{:error, changeset} -> Repo.rollback(changeset)
end
{:error, changeset} -> Repo.rollback(changeset)
end
end)
When to Use
Triggers:
- You have 2+ database operations that must all succeed or all roll back
- The set of operations is known at compile time (not dynamically generated)
- The caller needs to know which specific operation failed
Example — before:
def create_user_with_profile(params) do
Repo.transaction(fn ->
case Repo.insert(User.changeset(params)) do
{:ok, user} ->
case Repo.insert(Profile.changeset(user, params)) do
{:ok, profile} -> {:ok, {user, profile}}
{:error, cs} -> Repo.rollback(cs)
end
{:error, cs} -> Repo.rollback(cs)
end
end)
end
Example — after:
def create_user_with_profile(params) do
Multi.new()
|> Multi.insert(:user, User.changeset(params))
|> Multi.insert(:profile, fn %{user: user} ->
Profile.changeset(user, params)
end)
|> Repo.transaction()
end
# Caller:
case create_user_with_profile(params) do
{:ok, %{user: user, profile: profile}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
{:error, :profile, changeset, _} -> {:error, changeset}
end
When NOT to Use
Don't use this when:
- You have a single database operation (just call
Repo.insert/update/deletedirectly) - Operations are simple and sequential with no branching (a plain
Repo.transaction(fn -> ... end)is more readable) - The overhead of building a Multi struct is not justified by the number of operations
Over-application example:
# Overkill for a single operation
Multi.new()
|> Multi.insert(:user, User.changeset(params))
|> Repo.transaction()
Better alternative:
Repo.insert(User.changeset(params))
Why: Ecto.Multi introduces indirection. For simple cases, calling Repo functions directly or using Repo.transaction(fn -> ... end) is clearer. The named-pipeline form pays off when 3+ operations are involved and failure attribution matters.
2. Multi.run/3 — Arbitrary Code in a Transaction
Source: lib/ecto/multi.ex#L39
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.run(:welcome_email, fn repo, %{user: user} ->
case Mailer.send_welcome(user) do
:ok -> {:ok, :sent}
{:error, reason} -> {:error, reason}
end
end)
Why: Multi.run is the escape hatch for operations that don't fit the standard insert/update/delete API. The callback receives (repo, changes_so_far) — the repo argument means you can issue raw queries using the same transaction connection. Returning {:error, value} from the function aborts the whole transaction and surfaces the value in the {:error, :welcome_email, value, _} result. Returning {:ok, value} stores the value under :welcome_email in the success map.
Anti-pattern: Embedding operations in Multi.run that don't need transaction context or that don't return the required {:ok, value} / {:error, value} shape:
# BAD — run used just to transform data, no transaction context needed
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.run(:formatted_name, fn _repo, %{user: user} ->
# This has no side effects and doesn't need a transaction
{:ok, String.upcase(user.name)}
end)
When to Use
Triggers:
- You need to perform an operation that isn't a standard Ecto schema action (e.g., calling an external service, running a raw query, computing a value that might fail)
- The operation must participate in the transaction rollback on failure
- The result of the operation is needed by a subsequent step in the Multi
Example — before:
def register_user(params) do
Repo.transaction(fn ->
{:ok, user} = Repo.insert(User.changeset(params))
# This runs outside Multi — if it fails, user was already inserted
case ExternalService.provision(user.id) do
{:ok, token} -> {:ok, %{user: user, token: token}}
{:error, reason} -> Repo.rollback(reason)
end
end)
end
Example — after:
def register_user(params) do
Multi.new()
|> Multi.insert(:user, User.changeset(params))
|> Multi.run(:provision, fn _repo, %{user: user} ->
ExternalService.provision(user.id)
end)
|> Repo.transaction()
end
When NOT to Use
Don't use this when:
- The operation is a standard Ecto schema action — use
Multi.insert,Multi.update,Multi.delete, orMulti.delete_allinstead - The callback only transforms data without any side effects or failure modes
- You need to insert/update using a changeset that depends on prior results — use the function variant of
Multi.insert/update(see Pattern 3) instead
Over-application example:
# Multi.run adds overhead when Multi.insert's function variant covers this
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.run(:comment, fn _repo, %{post: post} ->
Repo.insert(Ecto.build_assoc(post, :comments, body: "first"))
end)
Better alternative:
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.insert(:comment, fn %{post: post} ->
Ecto.build_assoc(post, :comments, body: "first")
end)
Why: Multi.run requires you to return a tagged {:ok, value} / {:error, value} tuple. When the operation is a plain Ecto changeset, the function variants of Multi.insert/update/delete accept a function that returns a changeset and handle the wrapping internally. Reserve Multi.run for operations with their own failure semantics.
3. Dependent Operations with Function Variants
Source: lib/ecto/multi.ex#L298
Multi.new()
|> Multi.insert(:post, %Post{title: "first"})
|> Multi.insert(:comment, fn %{post: post} ->
Ecto.build_assoc(post, :comments, body: "first comment")
end)
Why: Each of the standard Multi operations (insert, update, delete, delete_all, insert_or_update) accepts either a static changeset/struct or a one-argument function fn changes -> changeset end. When an operation depends on the result of a previous step, pass the function form. The function receives the accumulated changes map up to that point, giving you access to all prior results. This keeps operations in a single pipeline without introducing a separate Multi.run call.
Anti-pattern: Using Multi.run when a function variant of Multi.insert/update suffices:
# BAD — Multi.run is more verbose and requires explicit {:ok, value} wrapping
Multi.new()
|> Multi.insert(:post, %Post{title: "first"})
|> Multi.run(:comment, fn _repo, %{post: post} ->
Repo.insert(Ecto.build_assoc(post, :comments, body: "first comment"))
end)
When to Use
Triggers:
- An operation's changeset or struct must be constructed using the result of a prior operation
- You need an association ID, a foreign key, or any field that isn't available until a prior step runs
- You want to keep the pipeline as
Multi.insert/update/deletecalls without dropping intoMulti.run
Example — before:
def create_order_with_items(cart, user) do
Multi.new()
|> Multi.insert(:order, Order.changeset(cart, user))
|> Multi.run(:items, fn _repo, %{order: order} ->
items = Enum.map(cart.items, &Repo.insert(OrderItem.changeset(&1, order)))
errors = Enum.filter(items, &match?({:error, _}, &1))
if errors == [], do: {:ok, items}, else: {:error, errors}
end)
end
Example — after:
def create_order_with_items(cart, user) do
cart.items
|> Enum.with_index()
|> Enum.reduce(Multi.new() |> Multi.insert(:order, Order.changeset(cart, user)),
fn {item, idx}, multi ->
Multi.insert(multi, {:item, idx}, fn %{order: order} ->
OrderItem.changeset(item, order)
end)
end)
end
When NOT to Use
Don't use this when:
- The changeset does not depend on any prior operation — pass it directly as a static value
- The dependent operation has complex failure logic that requires returning
{:ok, value}/{:error, value}— useMulti.runin that case - The function would need to perform multiple Repo calls — the function variant only accepts a changeset/struct return, not arbitrary DB operations
Over-application example:
# Function variant used when the changeset doesn't depend on prior results
Multi.new()
|> Multi.insert(:user, fn _changes ->
User.changeset(%User{}, params) # No dependency on changes — pass directly
end)
Better alternative:
Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, params))
Why: The function variant defers changeset construction to transaction execution time, which means it can't be inspected with Multi.to_list/1 until the Multi runs. For static changesets, passing the value directly keeps the Multi inspectable and testable without executing the transaction.
4. Multi.merge/2 — Dynamic Transaction Composition
Source: lib/ecto/multi.ex#L239
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.merge(fn %{post: post} ->
if post.requires_approval do
Multi.new()
|> Multi.insert(:approval_request, ApprovalRequest.changeset(post))
else
Multi.new()
end
end)
Why: Multi.merge/2 accepts a function that receives the changes accumulated so far and must return another Ecto.Multi. The returned Multi's operations are appended to the pipeline at execution time. This is the correct tool when the set of operations to add — not just the changesets — depends on prior results. A function variant of Multi.insert can swap in a different changeset; merge can add or remove entire operations.
Anti-pattern: Using merge when append or a function variant of insert/update would suffice — merge defers the entire sub-pipeline to runtime, making it harder to inspect:
# BAD — merge used just to pass a different changeset, not to change which operations run
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.merge(fn %{post: post} ->
Multi.new()
|> Multi.update(:post_meta, PostMeta.changeset(post))
end)
When to Use
Triggers:
- Whether certain operations should be included at all depends on runtime data
- A prior step's result determines which of several alternative sub-pipelines to execute
- You have a reusable function that takes results and returns a configured
Ecto.Multi
Example — before:
def publish_article(article, user) do
multi = Multi.new()
|> Multi.update(:article, Article.publish_changeset(article))
# Tacking on conditonal operations awkwardly outside the pipeline
multi =
if article.notify_subscribers do
Multi.run(multi, :notifications, fn _repo, %{article: article} ->
send_notifications(article, user)
end)
else
multi
end
multi
end
Example — after:
def publish_article(article, user) do
Multi.new()
|> Multi.update(:article, Article.publish_changeset(article))
|> Multi.merge(fn %{article: published} ->
if published.notify_subscribers do
Multi.new()
|> Multi.run(:notifications, fn _repo, _changes ->
send_notifications(published, user)
end)
else
Multi.new()
end
end)
end
When NOT to Use
Don't use this when:
- The set of operations is fixed — use
Multi.append/2to combine two pre-built Multis instead - Only the changeset values vary between operations — use the function variant of
Multi.insert/update - The merge function is trivially always the same Multi (just use
Multi.append)
Over-application example:
# merge used when append would do — the sub-multi never varies
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.merge(fn _changes ->
Multi.new() |> Multi.insert(:audit_log, AuditLog.create())
end)
Better alternative:
audit_multi = Multi.new() |> Multi.insert(:audit_log, AuditLog.create())
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.append(audit_multi)
Why: Multi.merge with a constant-returning function is just Multi.append with extra indirection. Use merge only when the function body actually branches on the changes argument.
5. Multi.append/2 / Multi.prepend/2 — Static Multi Composition
Source: lib/ecto/multi.ex#L183
def audit_multi(entity, user) do
Multi.new()
|> Multi.insert(:audit_log, AuditLog.changeset(entity, user))
end
def create_post(params, user) do
post_multi = Multi.new() |> Multi.insert(:post, Post.changeset(params))
Multi.append(post_multi, audit_multi(:post, user))
end
Why: append and prepend combine two fully-built Ecto.Multi structs into one. All operation names across both Multis must be unique — a conflict raises at composition time, not at execution time. This makes transaction fragments reusable: audit_multi/2 can be appended to any business operation without duplicating the audit logic. prepend puts the second Multi's operations first; append adds them at the end.
Anti-pattern: Re-defining the same operations in every Multi instead of extracting reusable fragments:
# BAD — audit logic duplicated across every operation
def create_post(params, user) do
Multi.new()
|> Multi.insert(:post, Post.changeset(params))
|> Multi.insert(:audit_log, AuditLog.changeset(:post, user))
end
def update_post(post, params, user) do
Multi.new()
|> Multi.update(:post, Post.changeset(post, params))
|> Multi.insert(:audit_log, AuditLog.changeset(:post, user)) # copy-paste
end
When to Use
Triggers:
- You have a recurring group of operations (auditing, notifications, cleanup) that should attach to multiple different transactions
- Two independently-defined Multis must run in the same transaction
- You want to compose transaction fragments from different modules without either module knowing about the other's internals
Example — before:
def transfer_funds(from, to, amount) do
Multi.new()
|> Multi.update(:debit, Account.debit_changeset(from, amount))
|> Multi.update(:credit, Account.credit_changeset(to, amount))
|> Multi.insert(:ledger_entry, LedgerEntry.changeset(from, to, amount))
|> Multi.insert(:audit_log, AuditLog.changeset(:transfer, %{from: from, to: to}))
end
Example — after:
def audit_multi(action, context) do
Multi.new()
|> Multi.insert(:audit_log, AuditLog.changeset(action, context))
end
def transfer_funds(from, to, amount) do
transfer =
Multi.new()
|> Multi.update(:debit, Account.debit_changeset(from, amount))
|> Multi.update(:credit, Account.credit_changeset(to, amount))
|> Multi.insert(:ledger_entry, LedgerEntry.changeset(from, to, amount))
Multi.append(transfer, audit_multi(:transfer, %{from: from, to: to}))
end
When NOT to Use
Don't use this when:
- Which operations to add depends on the results of earlier operations — use
Multi.merge/2instead - The two Multis share operation names — this will raise at composition time and requires renaming
- The combination is only used once — just build a single Multi inline
Over-application example:
# Splitting a single logical operation into two Multis for no benefit
user_part = Multi.new() |> Multi.insert(:user, user_cs)
profile_part = Multi.new() |> Multi.insert(:profile, profile_cs)
Multi.append(user_part, profile_part)
Better alternative:
Multi.new()
|> Multi.insert(:user, user_cs)
|> Multi.insert(:profile, profile_cs)
Why: append and prepend are tools for reuse. When operations are only ever combined in one place, defining them as a single pipeline is simpler. Extract fragments only when the same group of operations genuinely recurs across different transactions.
6. Tuple Keys — Dynamic Collections of Operations
Source: lib/ecto/multi.ex#L109
Enum.reduce(accounts, Multi.new(), fn account, multi ->
Multi.update(
multi,
{:account, account.id},
Account.password_reset_changeset(account, params)
)
end)
# Error pattern-matching:
case Repo.transaction(multi) do
{:ok, results} -> Map.keys(results) # [{:account, 1}, {:account, 2}, ...]
{:error, {:account, id}, changeset, _} -> "account #{id} failed"
end
Why: Multi operation names can be any term, not just atoms. Tuple keys like {:account, account.id} give each operation in a dynamically-generated collection a unique, structured name. On failure, the name in {:error, name, value, _} tells you exactly which item failed — without this, all accounts would compete for the same atom name, which would raise a duplicate name error.
Anti-pattern: Using a bare atom for all iterations, which raises at composition time:
# BAD — all operations have the same name :account — raises DuplicateNameError
Enum.reduce(accounts, Multi.new(), fn account, multi ->
Multi.update(multi, :account, Account.password_reset_changeset(account, params))
end)
When to Use
Triggers:
- You're building a Multi by reducing over a collection and need each item to be a separate named operation
- You need to identify which specific item in a collection caused the transaction to fail
- The result map keys must be inspectable to determine which items succeeded
Example — before:
# Forced to use Multi.run and handle errors manually
def reset_passwords(accounts, params) do
Multi.new()
|> Multi.run(:all_accounts, fn _repo, _changes ->
results = Enum.map(accounts, fn account ->
Repo.update(Account.password_reset_changeset(account, params))
end)
errors = Enum.filter(results, &match?({:error, _}, &1))
if errors == [], do: {:ok, results}, else: {:error, hd(errors)}
end)
end
Example — after:
def reset_passwords(accounts, params) do
Enum.reduce(accounts, Multi.new(), fn account, multi ->
Multi.update(
multi,
{:account, account.id},
Account.password_reset_changeset(account, params)
)
end)
end
# Caller knows exactly which account failed:
case Repo.transaction(reset_passwords(accounts, params)) do
{:ok, _} -> :ok
{:error, {:account, id}, changeset, _} ->
Logger.error("Failed to reset account #{id}: #{inspect(changeset.errors)}")
end
When NOT to Use
Don't use this when:
- The collection has a fixed, known size — just name each operation with a distinct atom instead
- You don't need per-item failure attribution — a
Multi.runthat processes all items may be simpler - All items should be processed regardless of individual failures — use a
Multi.runthat collects partial results
Over-application example:
# Tuple keys on a fixed two-item "collection"
[:primary, :secondary]
|> Enum.reduce(Multi.new(), fn role, multi ->
Multi.insert(multi, {:membership, role}, Membership.changeset(user, role))
end)
Better alternative:
Multi.new()
|> Multi.insert(:primary_membership, Membership.changeset(user, :primary))
|> Multi.insert(:secondary_membership, Membership.changeset(user, :secondary))
Why: Tuple keys shine for truly dynamic collections where the size is not known at compile time. For small, fixed sets, distinct atom names are more readable and produce clearer error messages.
7. Multi.to_list/1 — Testing Without a Database
Source: lib/ecto/multi.ex#L88
test "dry run password reset" do
account = %Account{password: "letmein"}
multi = PasswordManager.reset(account, params)
assert [
{:account, {:update, account_changeset, []}},
{:log, {:insert, log_changeset, []}},
{:sessions, {:delete_all, query, []}}
] = Ecto.Multi.to_list(multi)
assert account_changeset.valid?
end
Why: Multi.to_list/1 returns the list of operations in the Multi as {name, {operation_type, changeset_or_query, opts}} tuples. This allows you to assert on changeset validity, query structure, and operation order without running the transaction against a database. Tests that previously required database setup and teardown can become pure unit tests — faster and fully isolated.
Anti-pattern: Always running the transaction in tests even when only changeset validity is being checked:
# BAD — hits the database just to validate a changeset
test "password reset changeset is valid" do
account = %Account{password: "letmein"}
{:ok, %{account: updated}} =
PasswordManager.reset(account, valid_params)
|> Repo.transaction()
# The changeset validity was the whole point, not the DB state
assert updated.password != account.password
end
When to Use
Triggers:
- You want to unit-test changeset validity or query construction without a database connection
- Your test environment cannot or should not touch the database for a given test
- You want to assert on operation names, order, or count in a Multi pipeline
Example — before:
# Tests require database + ExUnit.DataCase + fixtures
@tag :integration
test "creates user with profile" do
{:ok, %{user: user, profile: profile}} =
UserRegistration.multi(valid_params())
|> Repo.transaction()
assert user.email == "test@example.com"
assert profile.user_id == user.id
end
Example — after:
# Pure unit test — no database, no fixtures, no DataCase
test "multi contains user insert with valid changeset" do
multi = UserRegistration.multi(valid_params())
assert [
{:user, {:insert, user_changeset, []}},
{:profile, _}
] = Ecto.Multi.to_list(multi)
assert user_changeset.valid?
assert Ecto.Changeset.get_change(user_changeset, :email) == "test@example.com"
end
When NOT to Use
Don't use this when:
- Operations use the function variant (e.g.,
Multi.insert(:comment, fn %{post: post} -> ... end)) — those deferred functions are opaque into_listoutput until the transaction runs - You need to test that the operations actually succeed against a real database (constraint checks, triggers, concurrent writes)
- The Multi uses
Multi.runwith side effects —to_listcannot execute those callbacks
Over-application example:
# to_list can't help here — the changeset is hidden inside a function
multi =
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.insert(:comment, fn %{post: post} ->
Comment.changeset(post, params) # This fn is opaque to to_list
end)
# This assertion will fail or be meaningless
[{:comment, {:insert, cs, _}}] = Enum.drop(Ecto.Multi.to_list(multi), 1)
assert cs.valid? # cs is a function, not a changeset
Better alternative:
# Test the function-variant changesets in isolation
test "comment changeset is valid given a post" do
post = %Post{id: 1, title: "first"}
changeset = Comment.changeset(post, valid_comment_params())
assert changeset.valid?
end
Why: Multi.to_list/1 reflects the state of the pipeline at build time. Deferred values (function variants, Multi.run callbacks, Multi.merge functions) are not evaluated until Repo.transaction runs them. Test those deferred pieces independently rather than trying to inspect them through to_list.
Decision Tree
- If operations are static and ordered →
Multi.new() |> Multi.insert/update/delete - If an operation depends on a prior operation's result → use function variant
fn changes -> changeset end - If later operations depend on runtime data to decide what to include →
Multi.merge/2with anonymous function - If you have reusable Multi fragments to combine →
Multi.append/2orMulti.prepend/2 - If you're updating a dynamic collection → tuple keys
{:operation, id} - If you want to validate changesets without hitting the DB →
Multi.to_list/1in tests - If operations are simple and static (no dynamic branching) → consider
Repo.transaction(fn -> ... end)instead