Help us improve
Share bugs, ideas, or general feedback.
From elixir-phoenix-guide
Guides Ecto best practices for nested associations: cast_assoc for has_many/has_one, Ecto.Multi for multi-table ops, preloads before updates, FK indexes, on_replace: :delete.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideHow this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix-guide:ecto-nested-associationsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Use `cast_assoc/3` for has_many/has_one** — never manually insert children in a separate step; let Ecto manage the relationship
Provides Ecto patterns for schemas, changesets, queries, migrations, Multi, associations, preloads, upserts. Activates when editing Repo calls, Ecto.Query, or schema fields; skips Ash.
Guide for Ecto schemas, changesets, queries, migrations, and Multi. Use when writing Ecto migrations, querying schemas, designing changesets, or choosing between Ecto DSL and raw SQL.
Enforces Ecto changeset best practices: separate changesets per operation, cast_assoc without foreign keys, pipe composition, unsafe_validate_unique with unique_constraint, field transformations, conditional opts. For complex CRUD.
Share bugs, ideas, or general feedback.
cast_assoc/3 for has_many/has_one — never manually insert children in a separate step; let Ecto manage the relationshipEcto.Multi for operations spanning multiple unrelated tables — not nested changesets; Multi provides explicit rollback controlon_delete explicitly in migrations — :delete_all for owned children, :nothing for references to independent entitieson_replace: :delete in cast_assoc for list management — allows removing items by omitting them from the inputcast_assoc compares against currently loaded data; unloaded associations cause silent data lossCreate parent and children in a single operation. Ecto sets foreign keys automatically.
# Schema definitions
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
has_many :comments, MyApp.Blog.Comment
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title])
|> validate_required([:title])
|> cast_assoc(:comments, with: &MyApp.Blog.Comment.changeset/2)
end
end
defmodule MyApp.Blog.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :body, :string
belongs_to :post, MyApp.Blog.Post
timestamps()
end
# Do NOT require :post_id — cast_assoc sets it automatically
def changeset(comment, attrs) do
comment
|> cast(attrs, [:body])
|> validate_required([:body])
end
end
# Usage — create post with comments in one operation
Blog.create_post(%{
title: "My Post",
comments: [
%{body: "First comment"},
%{body: "Second comment"}
]
})
When updating a has_many, on_replace: :delete removes children that are omitted from the input.
defmodule MyApp.Recipes.Recipe do
schema "recipes" do
field :name, :string
has_many :ingredients, MyApp.Recipes.Ingredient, on_replace: :delete
timestamps()
end
def changeset(recipe, attrs) do
recipe
|> cast(attrs, [:name])
|> validate_required([:name])
|> cast_assoc(:ingredients, with: &MyApp.Recipes.Ingredient.changeset/2)
end
end
# Update — send the full list; omitted items are deleted
def update_recipe(recipe, attrs) do
recipe
|> Repo.preload(:ingredients) # MUST preload before cast_assoc
|> Recipe.changeset(attrs)
|> Repo.update()
end
# Example: recipe has ingredients A, B, C
# Sending %{ingredients: [%{id: a.id, name: "A"}, %{name: "D"}]}
# Result: A is updated, B and C are deleted, D is created
# Bad — ingredients not preloaded, cast_assoc can't compare
recipe = Repo.get!(Recipe, id)
Recipe.changeset(recipe, attrs) # ingredients is %Ecto.Association.NotLoaded{}
|> Repo.update() # Silently ignores association changes!
# Good — preload before updating
recipe = Repo.get!(Recipe, id) |> Repo.preload(:ingredients)
Recipe.changeset(recipe, attrs) # ingredients is [%Ingredient{}, ...]
|> Repo.update() # Correctly diffs and applies changes
Use cast_embed for data stored as JSON in a single column (no separate table).
defmodule MyApp.Profiles.Profile do
use Ecto.Schema
import Ecto.Changeset
schema "profiles" do
field :name, :string
embeds_many :social_links, SocialLink, on_replace: :delete
timestamps()
end
def changeset(profile, attrs) do
profile
|> cast(attrs, [:name])
|> cast_embed(:social_links, with: &SocialLink.changeset/2)
end
end
defmodule MyApp.Profiles.Profile.SocialLink do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :platform, :string
field :url, :string
end
def changeset(link, attrs) do
link
|> cast(attrs, [:platform, :url])
|> validate_required([:platform, :url])
|> validate_format(:url, ~r/^https?:\/\//)
end
end
When operations span unrelated tables or need explicit control over transaction steps:
defmodule MyApp.Orders do
alias Ecto.Multi
def place_order(user, cart_items) do
Multi.new()
|> Multi.insert(:order, build_order(user))
|> Multi.insert_all(:line_items, LineItem, fn %{order: order} ->
Enum.map(cart_items, fn item ->
%{
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
price: item.price,
inserted_at: DateTime.utc_now(:second),
updated_at: DateTime.utc_now(:second)
}
end)
end)
|> Multi.update(:decrement_stock, fn %{order: _order} ->
decrement_stock_changeset(cart_items)
end)
|> Repo.transaction()
end
end
# Handling Multi results
case Orders.place_order(user, cart_items) do
{:ok, %{order: order, line_items: {count, _}, decrement_stock: _}} ->
# All operations succeeded
{:ok, order}
{:error, :order, changeset, _changes_so_far} ->
# Order insert failed — nothing committed
{:error, changeset}
{:error, :decrement_stock, changeset, _changes_so_far} ->
# Stock update failed — order and line items rolled back
{:error, changeset}
end
Multi.new()
|> Multi.run(:validate_stock, fn _repo, _changes ->
if sufficient_stock?(cart_items) do
{:ok, :valid}
else
{:error, :insufficient_stock}
end
end)
|> Multi.insert(:order, fn %{validate_stock: :valid} ->
build_order(user)
end)
|> Repo.transaction()
defmodule MyApp.Repo.Migrations.CreateComments do
use Ecto.Migration
def change do
create table(:comments) do
add :body, :text, null: false
# Child — cascade delete when parent is deleted
add :post_id, references(:posts, on_delete: :delete_all), null: false
# Reference — don't cascade (user deletion shouldn't delete comments)
add :user_id, references(:users, on_delete: :nothing), null: false
timestamps()
end
# Always index foreign keys
create index(:comments, [:post_id])
create index(:comments, [:user_id])
end
end
# :delete_all — child cannot exist without parent
add :comment_id, references(:comments, on_delete: :delete_all) # Reply → Comment
add :line_item_id, references(:orders, on_delete: :delete_all) # LineItem → Order
add :ingredient_id, references(:recipes, on_delete: :delete_all) # Ingredient → Recipe
# :nothing — resource is referenced but independent
add :user_id, references(:users, on_delete: :nothing) # Post → User
add :category_id, references(:categories, on_delete: :nothing) # Post → Category
# :nilify_all — remove reference but keep the record
add :team_id, references(:teams, on_delete: :nilify_all) # User → Team (user keeps account)
Every references() column needs an index. Without it, deleting a parent scans the entire child table.
# Bad — foreign key without index
create table(:comments) do
add :post_id, references(:posts, on_delete: :delete_all)
end
# Deleting a post requires full table scan of comments to find children
# Good — always add an index
create table(:comments) do
add :post_id, references(:posts, on_delete: :delete_all)
end
create index(:comments, [:post_id])
describe "create_post/1 with comments" do
test "creates post with nested comments" do
attrs = %{
title: "My Post",
comments: [
%{body: "Comment 1"},
%{body: "Comment 2"}
]
}
assert {:ok, post} = Blog.create_post(attrs)
assert post.title == "My Post"
post = Repo.preload(post, :comments)
assert length(post.comments) == 2
assert Enum.any?(post.comments, &(&1.body == "Comment 1"))
end
test "rejects invalid nested comments" do
attrs = %{
title: "My Post",
comments: [%{body: nil}]
}
assert {:error, changeset} = Blog.create_post(attrs)
assert errors_on(changeset)[:comments]
end
end
describe "update_recipe/2 with on_replace: :delete" do
test "removes omitted ingredients" do
recipe = recipe_fixture(ingredients: [%{name: "Salt"}, %{name: "Pepper"}])
recipe = Repo.preload(recipe, :ingredients)
# Only send Salt — Pepper should be deleted
attrs = %{ingredients: [%{id: hd(recipe.ingredients).id, name: "Salt"}]}
assert {:ok, updated} = Recipes.update_recipe(recipe, attrs)
updated = Repo.preload(updated, :ingredients, force: true)
assert length(updated.ingredients) == 1
assert hd(updated.ingredients).name == "Salt"
end
end
describe "place_order/2 with Ecto.Multi" do
test "creates order and line items atomically" do
user = user_fixture()
product = product_fixture(stock: 10)
items = [%{product_id: product.id, quantity: 2, price: 999}]
assert {:ok, %{order: order, line_items: {1, _}}} =
Orders.place_order(user, items)
assert order.user_id == user.id
end
test "rolls back on failure" do
user = user_fixture()
items = [%{product_id: -1, quantity: 2, price: 999}]
assert {:error, _step, _changeset, _changes} =
Orders.place_order(user, items)
end
end
See ecto-essentials skill for schema and migration fundamentals.
See ecto-changeset-patterns skill for changeset composition and validation.
See testing-essentials skill for comprehensive testing patterns.