igmarin/elixir-phoenix-skills

ecto-changeset-patterns

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.

First seen Jun 20, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill ecto-changeset-patterns

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 8,742 B
  • docs SUMMARY.md 434 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 21 installs

SKILL.md

Ecto Changeset Patterns

Canonical FP bar: [docs/fcis-engineering-rules.md](../../docs/fcis-engineering-rules.md) — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Build changesets/Multi in pure-ish functions; run Repo once at the context edge.

RULES — Follow these with no exceptions

1. Define separate named changesets per operation — registrationchangeset, emailchangeset, passwordchangeset, etc. 2. Never require foreign key fields in castassoc child changesets — castassoc sets them automatically 3. Compose changesets with pipes — each validation step is a separate, reusable function 4. Always pair unsafevalidateunique with uniqueconstraint — 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()
end

Workflow: Building a New Schema

When adding changesets to a new schema, apply patterns in this order:

  1. Define separate named changesets for each operation (registration, update, password change, etc.)
  2. Compose validations into small, reusable private functions piped together
  3. Add uniqueness validation — pair unsafevalidateunique with unique_constraint
  4. Apply field transformations via update_change/3 in the changeset, not in controllers
  5. Verify by testing in iex -S mix or 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)
end

Separate 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
end

Common Pitfalls

❌ Don't ✅ Do
Require :postid in a castassoc child changeset Cast only user fields; cast_assoc sets the FK
Reuse one changeset for every operation Define separate named changesets per operation
Use unsafevalidateunique 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 putpasswordhash/1 in the password changeset

cast_assoc — Critical Pitfall

❌ Bad — :postid is required but set automatically by castassoc:

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])
end

Changeset 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)
end

Conditional 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()
end

Field 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
end

Uniqueness Validation

Always pair unsafevalidateunique 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)
end

Integration

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 foundations
  • ecto-nested-associations — cast_assoc for deeply nested data structures
  • testing-essentials — changeset tests with errors_on/1 helpers
  • apply-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.