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
References
For detailed patterns, see:
-
references/changesets.md
-
cast vs put_change, custom validations, prepare_changes
-
references/queries.md
-
Composable queries, dynamic, subqueries, preloading
-
references/migrations.md
-
Safe migrations, concurrent indexes, NOT NULL
-
references/transactions.md
-
Repo.transact, Ecto.Multi, upserts