Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: ecto-changeset-patterns description: MANDATORY for ALL changeset work beyond basic CRUD. Invoke before writing multiple changesets, cast_assoc, or conditional validation. file_patterns:
- "**/*.ex"
auto_suggest: true
Ecto Changeset Patterns
RULES — Follow these with no exceptions
- Create separate named changesets per operation —
registration_changeset,email_changeset,password_changeset; never overload a singlechangeset/2 - Never require foreign key fields in `cast_assoc` child changesets — the parent sets them automatically; requiring them causes "can't be blank" errors
- Compose changesets with pipes — each validation step is a separate function for reuse and clarity
- Use `unsafe_validate_unique` paired with `unique_constraint` — never one without the other;
unsafe_validate_uniquegives fast UI feedback,unique_constrainthandles race conditions - Use `update_change/3` for field transformations — trimming, downcasing, slugifying happen in the changeset, never in the controller or context
- Accept `opts \\ []` for conditional validation — allows callers to toggle validation rules without creating yet another changeset function
- Validate at the changeset level, not in context functions — context functions should be thin wrappers around
Repocalls
Separate Changesets Per Operation
Different operations need different validation rules. Don't overload changeset/2.
defmodule MyApp.Accounts.User douse Ecto.Schemaimport Ecto.Changesetschema "users" dofield :email, :stringfield :username, :stringfield :password, :string, virtual: true, redact: truefield :hashed_password, :string, redact: truefield :bio, :stringtimestamps()end# Registration — all fields, password hashingdef registration_changeset(user, attrs, opts \\ []) douser|> cast(attrs, [:email, :username, :password])|> validate_email(opts)|> validate_username()|> validate_password(opts)end# Email change — only email, requires reconfirmationdef email_changeset(user, attrs, opts \\ []) douser|> cast(attrs, [:email])|> validate_email(opts)end# Password change — only passworddef password_changeset(user, attrs, opts \\ []) douser|> cast(attrs, [:password])|> validate_password(opts)|> put_password_hash()end# Profile update — non-sensitive fields onlydef profile_changeset(user, attrs) douser|> cast(attrs, [:username, :bio])|> validate_username()endend
cast_assoc — Critical Pitfall
The most common source of "can't be blank" errors. Foreign keys are set automatically by the parent — never require them in the child changeset.
# Parent schemadefmodule MyApp.Blog.Post doschema "posts" dofield :title, :stringhas_many :ingredients, MyApp.Blog.Ingredienttimestamps()enddef changeset(post, attrs) dopost|> cast(attrs, [:title])|> validate_required([:title])|> cast_assoc(:ingredients, with: &MyApp.Blog.Ingredient.changeset/2)endend# Child schema — DO NOT require :post_iddefmodule MyApp.Blog.Ingredient doschema "ingredients" dofield :name, :stringfield :quantity, :stringbelongs_to :post, MyApp.Blog.Posttimestamps()end# Bad — :post_id is required but set automatically by cast_assocdef changeset(ingredient, attrs) doingredient|> cast(attrs, [:name, :quantity, :post_id])|> validate_required([:name, :post_id]) # Fails!end# Good — only require user-provided fieldsdef changeset(ingredient, attrs) doingredient|> cast(attrs, [:name, :quantity])|> validate_required([:name])endend
Changeset Composition
Break validation into small, reusable functions. Compose with pipes.
defmodule MyApp.Accounts.User do# Reusable validation componentsdefp validate_email(changeset, opts) dochangeset|> validate_required([:email])|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")|> validate_length(:email, max: 160)|> maybe_validate_unique_email(opts)enddefp validate_username(changeset) dochangeset|> validate_required([:username])|> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "only letters, numbers, and underscores")|> validate_length(:username, min: 3, max: 30)|> unsafe_validate_unique(:username, MyApp.Repo)|> unique_constraint(:username)enddefp validate_password(changeset, opts) dochangeset|> validate_required([:password])|> validate_length(:password, min: 8, max: 72)|> maybe_hash_password(opts)enddefp maybe_validate_unique_email(changeset, opts) doif Keyword.get(opts, :validate_email, true) dochangeset|> unsafe_validate_unique(:email, MyApp.Repo)|> unique_constraint(:email)elsechangesetendenddefp maybe_hash_password(changeset, opts) doif Keyword.get(opts, :hash_password, true) && changeset.valid? dochangeset|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(get_change(changeset, :password)))|> delete_change(:password)elsechangesetendendend
Conditional Validation with opts
Use opts to toggle validation behavior from the caller. This avoids creating a new changeset function for every variation.
# In the schema moduledef registration_changeset(user, attrs, opts \\ []) douser|> cast(attrs, [:email, :username, :password])|> validate_email(opts)|> validate_password(opts)end# In the context — normal registrationdef register_user(attrs) do%User{}|> User.registration_changeset(attrs)|> Repo.insert()end# In tests — skip hashing for speeddef register_user_for_test(attrs) do%User{}|> User.registration_changeset(attrs, hash_password: false, validate_email: false)|> Repo.insert()end
Field Transformations with update_change
Transform field values in the changeset, not in the controller or LiveView.
def changeset(user, attrs) douser|> cast(attrs, [:email, :username])|> update_change(:email, &String.downcase/1)|> update_change(:username, &String.trim/1)|> update_change(:username, &String.downcase/1)end# For slugsdef changeset(post, attrs) dopost|> cast(attrs, [:title])|> validate_required([:title])|> generate_slug()enddefp generate_slug(changeset) docase get_change(changeset, :title) donil -> changesettitle ->slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-")put_change(changeset, :slug, slug)endend
Uniqueness Validation
Always pair unsafe_validate_unique with unique_constraint. They serve different purposes.
def changeset(user, attrs) douser|> cast(attrs, [:email, :username])# Fast check — queries DB, gives immediate UI feedback# "unsafe" because another insert could happen between check and insert|> unsafe_validate_unique(:email, MyApp.Repo)|> unsafe_validate_unique(:username, MyApp.Repo)# Constraint check — catches race conditions at insert time# Requires a matching unique index in the database|> unique_constraint(:email)|> unique_constraint(:username)end
Testing Changesets
describe "registration_changeset/2" dotest "valid with all required fields" dochangeset = User.registration_changeset(%User{}, %{email: "test@example.com",username: "testuser",password: "validpassword123"})assert changeset.valid?endtest "invalid without email" dochangeset = User.registration_changeset(%User{}, %{username: "testuser",password: "validpassword123"})refute changeset.valid?assert "can't be blank" in errors_on(changeset).emailendtest "transforms email to lowercase" dochangeset = User.email_changeset(%User{}, %{email: "TEST@Example.COM"})assert get_change(changeset, :email) == "test@example.com"endend
See ecto-essentials skill for schema and migration patterns. See ecto-nested-associations skill for cast_assoc with nested data. See testing-essentials skill for comprehensive testing patterns.