Using Ecto.Multi to Safely Create Related Records in Elixir
Learn how to compose atomic database operations with Ecto.Multi, see a worked example of user‑profile creation, and understand its limits.
05 Aug 2026, 20:05 UTC

When you need to persist several related rows together—like a user account and its profile—you want the whole operation to succeed or fail as a unit. Writing separate Repo calls and manually handling rollbacks quickly becomes error‑prone, especially when later steps depend on data from earlier ones. Ecto.Multi gives you a declarative way to build that pipeline inside a single transaction, automatically rolling back if any step fails.
What Ecto.Multi actually does
Ecto.Multi is a struct that holds a list of database actions. Each action gets a unique name and can be one of:
insert/3,update/3,delete/3for schema changesrun/3for arbitrary Elixir code that receives the results of previous steps
When you call Repo.transaction/2 with a Multi, Ecto runs the actions in order inside a PostgreSQL transaction. If any step returns an error tuple ({:error, reason}), the whole transaction is rolled back and the error is returned.
Worked example: user‑profile creation with email uniqueness
Assume you have two schemas:
defmodule MyApp.Accounts.User do
use Ecto.Schema
schema "users" do
field :email, :string
field :hashed_password, :string
timestamps()
end
end
defmodule MyApp.Accounts.Profile do
use Ecto.Schema
schema "profiles" do
field :bio, :string
belongs_to :user, MyApp.Accounts.User
timestamps()
end
end
We want to insert a user only if the email does not already exist, then insert a profile that references the new user. The uniqueness check can be done inside a run step that receives the inserted user.
def create_user_and_profile(email, password, bio) do
# 1. Insert the user (will fail on duplicate email due to DB unique index)
user_change = %MyApp.Accounts.User{email: email, hashed_password: hash_pass(password)}
# 2. Build the Multi
multi =
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_change)
# run receives a map of previously successful steps
|> Ecto.Multi.run(:profile, fn %{user: user} ->
# Only create a profile if the user email is still unique
# (the unique index on users.email guarantees this)
profile_change = %MyApp.Accounts.Profile{bio: bio, user_id: user.id}
{:ok, insert(profile_change)}
end)
# 3. Execute inside a transaction
Repo.transaction(multi)
end
To try this in an IEx session:
iex -S mixalias MyApp.Repoalias MyApp.AccountsAccounts.create_user_and_profile("alice@example.com", "secret", "Hello world")
If the email already exists, the insert step returns {:error, %Ecto.ConstraintError{}}. Ecto aborts the Multi, rolls back the transaction, and you receive the error tuple. No profile row is left behind.
Trade‑offs and limitations
Ecto.Multi shines when you need conditional logic or multiple related changes. For a single‑record update, the added indirection can feel heavyweight. More importantly, the rollback only covers actions that happen inside the database transaction. External side‑effects—such as sending a welcome email, pushing a message to a queue, or writing a file—are not undone automatically. You must compensate for those yourself, either by:
- Delaying the side‑effect until after the transaction commits (e.g., using
after_commit/3), or - Implementing a manual rollback routine if the side‑effect has already occurred.
Actionable next steps
Start by adding a unique index on any column whose uniqueness you want to enforce (e.g., create unique_index(:users, [:email]) in a migration). Then refactor any multi‑step data‑mutation code into a Multi, using run for steps that need to inspect previous results. Test the failure path by deliberately violating a constraint and confirming that the database shows no partial rows (Repo.all(User) and Repo.all(Profile) return empty lists for the attempted insert).
When you’re comfortable with the pattern, consider wrapping common Multi constructions in helper functions (like the example above) to keep your context modules clean and transaction‑safe.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.