Ecto Patterns Reference
Reference for working with Ecto schemas, queries, and migrations.
Iron Laws — Never Violate These
- CHANGESETS ARE FOR EXTERNAL DATA — Use
cast/4 for user/API input, change/2 or put_change/3 for internal trusted data
- NEVER USE
:float FOR MONEY — Always use :decimal or :integer (cents)
- NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS — They break foreign key constraints; use multiple nullable FKs or separate join tables
- ALWAYS PIN VALUES IN QUERIES —
u.name == ^user_input is safe, string interpolation causes SQL injection
- PRELOAD COLLECTIONS, NOT INDIVIDUALS — Preloading in loops = N+1 queries
- CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS — Validations provide quick feedback, constraints provide DB-level safety
- SEPARATE QUERIES FOR
has_many, JOIN FOR belongs_to — Avoids row multiplication
- NO IMPLICIT CROSS JOINS —
from(a in A, b in B) without on: creates Cartesian product
- DEDUP BEFORE
cast_assoc WITH SHARED DATA — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset
Quick Schema Template
defmodule MyApp.Context.Entity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "entities" do
field :name, :string
field :status, Ecto.Enum, values: [:draft, :active, :archived]
field :amount_cents, :integer # Never :float for money!
belongs_to :user, MyApp.Accounts.User
timestamps(type: :utc_datetime_usec)
end
def changeset(entity, attrs) do
entity
|> cast(attrs, [:name, :status, :amount_cents])
|> validate_required([:name])
|> foreign_key_constraint(:user_id)
end
end
Quick Decisions
cast vs put_change vs change
| Function |
Use When |
cast/4 |
External data (user input, API) |
put_change/3 |
Internal trusted data (timestamps, computed) |
change/2 |
Internal data from existing struct |
Preload Strategy
| Relationship |
Strategy |
belongs_to |
JOIN (single query) |
has_many |
Separate queries (avoid row multiplication) |
Common Anti-patterns
| Wrong |
Right |
field :amount, :float |
field :amount_cents, :integer |
"SELECT * WHERE name = '#{name}'" |
from(u in User, where: u.name == ^name) |
Repo.all(User) |> Enum.filter(& &1.active) |
from(u in User, where: u.active) |
| Preloading in loops |
Repo.preload(posts, :comments) |
Repo.get!(User, user_id) with user input |
Repo.get(User, id) + handle nil |
{:ok, _} = Repo.update(cs) inside Repo.transaction |
case/with + Repo.rollback(cs) (see transactions.md) |
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/changesets.md - cast vs put_change, custom validations, prepare_changes
${CLAUDE_SKILL_DIR}/references/queries.md - Composable queries, dynamic, subqueries, preloading
${CLAUDE_SKILL_DIR}/references/migrations.md - Safe migrations, concurrent indexes, NOT NULL
${CLAUDE_SKILL_DIR}/references/transactions.md - Repo.transact, Ecto.Multi, upserts
1---2name: ecto-patterns-23description: "Ecto patterns — schemas, changesets, queries, migrations, Multi, associations, preloads, upserts. Use when editing Repo calls, Ecto.Query, or schema fields. Skip for Ash."4effort: medium5user-invocable: false6paths:7 - "**/migrations/*.exs"8 - "**/*_schema.ex"9 - "**/*changeset*.ex"10---1112# Ecto Patterns Reference1314Reference for working with Ecto schemas, queries, and migrations.1516## Iron Laws — Never Violate These17181. **CHANGESETS ARE FOR EXTERNAL DATA** — Use `cast/4` for user/API input, `change/2` or `put_change/3` for internal trusted data192. **NEVER USE `:float` FOR MONEY** — Always use `:decimal` or `:integer` (cents)203. **NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS** — They break foreign key constraints; use multiple nullable FKs or separate join tables214. **ALWAYS PIN VALUES IN QUERIES** — `u.name == ^user_input` is safe, string interpolation causes SQL injection225. **PRELOAD COLLECTIONS, NOT INDIVIDUALS** — Preloading in loops = N+1 queries236. **CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS** — Validations provide quick feedback, constraints provide DB-level safety247. **SEPARATE QUERIES FOR `has_many`, JOIN FOR `belongs_to`** — Avoids row multiplication258. **NO IMPLICIT CROSS JOINS** — `from(a in A, b in B)` without `on:` creates Cartesian product269. **DEDUP BEFORE `cast_assoc` WITH SHARED DATA** — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset2728## Quick Schema Template2930```elixir31defmodule MyApp.Context.Entity do32 use Ecto.Schema33 import Ecto.Changeset3435 @primary_key {:id, :binary_id, autogenerate: true}36 @foreign_key_type :binary_id3738 schema "entities" do39 field :name, :string40 field :status, Ecto.Enum, values: [:draft, :active, :archived]41 field :amount_cents, :integer # Never :float for money!42 belongs_to :user, MyApp.Accounts.User43 timestamps(type: :utc_datetime_usec)44 end4546 def changeset(entity, attrs) do47 entity48 |> cast(attrs, [:name, :status, :amount_cents])49 |> validate_required([:name])50 |> foreign_key_constraint(:user_id)51 end52end53```5455## Quick Decisions5657### cast vs put_change vs change5859| Function | Use When |60|----------|----------|61| `cast/4` | External data (user input, API) |62| `put_change/3` | Internal trusted data (timestamps, computed) |63| `change/2` | Internal data from existing struct |6465### Preload Strategy6667| Relationship | Strategy |68|--------------|----------|69| `belongs_to` | JOIN (single query) |70| `has_many` | Separate queries (avoid row multiplication) |7172## Common Anti-patterns7374| Wrong | Right |75|-------|-------|76| `field :amount, :float` | `field :amount_cents, :integer` |77| `"SELECT * WHERE name = '#{name}'"` | `from(u in User, where: u.name == ^name)` |78| `Repo.all(User) \|> Enum.filter(& &1.active)` | `from(u in User, where: u.active)` |79| Preloading in loops | `Repo.preload(posts, :comments)` |80| `Repo.get!(User, user_id)` with user input | `Repo.get(User, id)` + handle nil |81| `{:ok, _} = Repo.update(cs)` inside `Repo.transaction` | `case`/`with` + `Repo.rollback(cs)` (see transactions.md) |8283## References8485For detailed patterns, see:8687- `${CLAUDE_SKILL_DIR}/references/changesets.md` - cast vs put_change, custom validations, prepare_changes88- `${CLAUDE_SKILL_DIR}/references/queries.md` - Composable queries, dynamic, subqueries, preloading89- `${CLAUDE_SKILL_DIR}/references/migrations.md` - Safe migrations, concurrent indexes, NOT NULL90- `${CLAUDE_SKILL_DIR}/references/transactions.md` - Repo.transact, Ecto.Multi, upserts