ecto-changeset-patterns
Skill4 starsChanged 4 months agoMITView on GitHub
MANDATORY for ALL changeset work beyond basic CRUD. Invoke before writing multiple changesets, cast_assoc, or conditional validation. Covers separate changesets per operation, cast_assoc pitfalls, composition, conditional validation with opts, field transformations, and uniqueness validation. Trigger words: changeset, cast_assoc, validation, separate changesets, conditional validation, update_change.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- apply-phoenix-controller-conventionsSkill
- ash-frameworkSkill
- benchee-profilingSkill
- broadway-data-pipelinesSkill
- cachex-cachingSkill
- code-qualitySkill
- code-reviewSkill
- deployment-gotchasSkill
- ecto-essentialsSkill
- ecto-migrationSkill
- ecto-nested-associationsSkill
- elixir-essentialsSkill
- gettext-i18nSkill
- liveview-streamsSkill
- mix-tasks-generatorsSkill
- oban-essentialsSkill
- otp-essentialsSkill
- phoenix-authenticationSkill
- phoenix-authorization-patternsSkill
- phoenix-channels-essentialsSkill
- phoenix-json-apiSkill
- phoenix-liveview-essentialsSkill
- phoenix-pubsub-patternsSkill
- phoenix-uploadsSkill
- property-based-testingSkill
- refactor-codeSkill
- req-http-clientSkill
- respond-to-reviewSkill
- security-essentialsSkill
- swoosh-emailsSkill
- telemetry-essentialsSkill
- testing-essentialsSkill
- typespec-dialyzerSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 65
When adding changesets to a new schema, apply patterns in this order:
- Very specific: line 236
Always pair unsafe_validate_unique with unique_constraint:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Ecto Changeset Patterns
RULES — Follow these with no exceptions
1. Define separate named changesets per operation — registration_changeset, email_changeset, password_changeset, etc.
2. Never require foreign key fields in cast_assoc child changesets — cast_assoc sets them automatically
3. Compose changesets with pipes — each validation step is a separate, reusable function
4. Always pair unsafe_validate_unique with unique_constraint — fast UI feedback plus race-safe enforcement
5. Use update_change/3 for field transformations — trim, downcase, and slugify inside the changeset
6. Accept opts \\ [] for conditional validation — toggle hashing or uniqueness checks per call site
7. Validate at the changeset level, not in context functions — keep validation next to the schema
FCIS at this boundary
Changeset functions should be pure data transformers (attrs in → changeset out). Persist with Repo in the context, not inside the schema module beyond optional helpers.
❌ Bad: schema function performs inserts
def create(attrs) do
%Post{}
|> changeset(attrs)
|> Repo.insert()
end✅ Good: schema builds; context persists
# schema
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title, :body])
end
# context shell
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
endWorkflow: Building a New Schema
When adding changesets to a new schema, apply patterns in this order:
- Define separate named changesets for each operation (registration, update, password change, etc.)
- Compose validations into small, reusable private functions piped together
- Add uniqueness validation — pair
unsafe_validate_uniquewithunique_constraint - Apply field transformations via
update_change/3in the changeset, not in controllers - Verify by testing in
iex -S mixor running your changeset tests. A quick iex smoke test:
# In iex -S mix
MyApp.Accounts.User.registration_changeset(%MyApp.Accounts.User{}, %{email: "bad", username: ""})
# => Inspect .valid? and .errors to confirm validations fire as expected
# Or a minimal ExUnit test
test "registration_changeset requires email and username" do
changeset = User.registration_changeset(%User{}, %{})
assert %{email: ["can't be blank"], username: ["can't be blank"]} = errors_on(changeset)
endSeparate Changesets Per Operation
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :username, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :bio, :string
timestamps()
end
# Registration — all fields, password hashing
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :username, :password])
|> validate_email(opts)
|> validate_username()
|> validate_password(opts)
end
# Email change — only email
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
# Password change — only password
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_password(opts)
|> put_password_hash()
end
# Profile update — non-sensitive fields only
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:username, :bio])
|> validate_username()
end
endCommon Pitfalls
| ❌ Don't | ✅ Do |
Require :post_id in a cast_assoc child changeset | Cast only user fields; cast_assoc sets the FK |
| Reuse one changeset for every operation | Define separate named changesets per operation |
Use unsafe_validate_unique alone | Pair it with unique_constraint for race safety |
| Trim/downcase fields in the controller | Transform in the changeset with update_change/3 |
| Hardcode hashing/uniqueness behavior | Accept opts \\ [] for conditional validation |
| Validate inside context functions | Validate at the changeset level, next to the schema |
Cast the virtual :password and forget to hash | Hash via put_password_hash/1 in the password changeset |
cast_assoc — Critical Pitfall
❌ Bad — :post_id is required but set automatically by cast_assoc:
def changeset(ingredient, attrs) do
ingredient
|> cast(attrs, [:name, :quantity, :post_id])
|> validate_required([:name, :post_id]) # Fails!
end✅ Good — only require user-provided fields:
def changeset(ingredient, attrs) do
ingredient
|> cast(attrs, [:name, :quantity])
|> validate_required([:name])
endChangeset Composition
defp validate_email(changeset, opts) do
changeset
|> 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)
end
defp validate_username(changeset) do
changeset
|> 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)
endConditional Validation with opts
# Normal registration
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
# In tests — skip hashing for speed
def register_user_for_test(attrs) do
%User{}
|> User.registration_changeset(attrs, hash_password: false, validate_email: false)
|> Repo.insert()
endField Transformations with update_change
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username])
|> update_change(:email, &String.downcase/1)
|> update_change(:username, &String.trim/1)
|> update_change(:username, &String.downcase/1)
end
# For slugs
defp generate_slug(changeset) do
case get_change(changeset, :title) do
nil -> changeset
title ->
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-")
put_change(changeset, :slug, slug)
end
endUniqueness Validation
Always pair unsafe_validate_unique with unique_constraint:
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username])
# Fast check — queries DB, gives immediate UI feedback
|> unsafe_validate_unique(:email, MyApp.Repo)
|> unsafe_validate_unique(:username, MyApp.Repo)
# Constraint check — catches race conditions at insert time
|> unique_constraint(:email)
|> unique_constraint(:username)
endIntegration
| Predecessor | This Skill | Successor |
| ecto-essentials | ecto-changeset-patterns | ecto-nested-associations |
| ecto-essentials | ecto-changeset-patterns | testing-essentials |
Companion skills:
ecto-essentials— schema, query, and migration foundationsecto-nested-associations—cast_assocfor deeply nested data structurestesting-essentials— changeset tests witherrors_on/1helpersapply-ecto-conventions— enforce changeset conventions on existing code
Related Skills
| Skill | When to Use |
ecto-essentials | Start here for schema definitions and migration patterns before writing changesets |
ecto-nested-associations | Use when cast_assoc involves deeply nested data structures |
testing-essentials | Use after this skill to write changeset tests with errors_on/1 helpers |
Each skill can be used independently if companion files are not present.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

