Skip to content
Development
Skill

/ecto-changeset-patterns

Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-changeset-patterns --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/ecto-changeset-patterns

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness

SKILL.md

ecto-changeset-patterns.SKILL.md
name: ecto-changeset-patterns
description: "Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness checks. Not for parent/child forms or cast_assoc (use ecto-nested-associations)."

Ecto Changeset Patterns

> Verified against Elixir 1.20, Phoenix 1.8, Ecto 3.14 — October 2026

RULES — Follow these with no exceptions

1. **Create separate named changesets per operation** — `email_changeset`, `password_changeset`, `profile_changeset`; never one `changeset/2` that casts everything 2. **Never `cast` foreign keys or privileged fields** (`:user_id`, `:org_id`, `:role`, `:confirmed_at`, `:deleted_at`) — set them with `put_change`/`change` from trusted code after a scope-checked lookup; for owned records use the Phoenix 1.8 `changeset(struct, attrs, scope)` shape 3. **Compose changesets with pipes** — each validation step is a small private function reused across changesets 4. **Pair `unsafe_validate_unique` with `unique_constraint`** — the first gives live form feedback, the second catches the race; both are case-sensitive unless the column is `citext` 5. **Use `update_change/3` for normalisation** (trim, downcase, slug) — in the changeset, never in the controller or LiveView; call it after `validate_required` 6. **Accept `opts \\ []` to skip expensive steps during live validation** — phx.gen.auth's `validate_unique: false` (no DB query per keystroke) and `hash_password: false` (no Bcrypt per keystroke) 7. **Passwords: `min: 12, max: 72`, plus `max: 72, count: :bytes` before hashing** — Bcrypt only reads the first 72 bytes, and multibyte characters pass a character-count check 8. **Validate in the changeset, not in context functions** — contexts stay thin wrappers around `Repo` calls

---

Separate Changesets Per Operation

The Phoenix 1.8 `phx.gen.auth` `User` schema already works this way. Here it is with a `role` added:

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :role, Ecto.Enum, values: [:member, :admin], default: :member
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :utc_datetime
    field :authenticated_at, :utc_datetime, virtual: true

    timestamps(type: :utc_datetime)
  end

  # Generated. 1.8 registration is email-only (magic link) and register_user/1 uses
  # this changeset; to register extra fields, add a registration_changeset/3 that
  # pipes email_changeset/3 (see the phoenix-auth-customization skill).
  def email_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email])
    |> validate_email(opts)
  end

  # Generated
  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end

  # Privileged fields are never in a cast list. Trusted code sets them explicitly.
  # confirm_changeset/1 is generated; role_changeset/2 is yours.
  def confirm_changeset(user), do: change(user, confirmed_at: DateTime.utc_now(:second))

  def role_changeset(user, role) when role in [:member, :admin], do: change(user, role: role)
end

User-editable extras (username, profile fields) belong in their own changesets, `registration_changeset/3` and `profile_changeset/2` in the `phoenix-auth-customization` skill. If such a changeset cast `:role` or `:confirmed_at`, any user could promote or confirm themselves by adding a form field. Call `role_changeset/2` only from an admin-only context function.

For records owned by a user, the 1.8 generators take the scope as the third argument and set the owner themselves:

def changeset(post, attrs, user_scope) do
  post
  |> cast(attrs, [:title, :body])
  |> validate_required([:title, :body])
  |> put_change(:user_id, user_scope.user.id)
end

Mass assignment: anything in a `cast` list can be set from a form or JSON body.

<!-- epg:bad -->

# A user can post user_id of another account, or folder_id of someone else's folder
|> cast(attrs, [:title, :folder_id, :user_id])

A foreign key the user chooses (`folder_id`) is set from a record loaded through the scope: `folder = get_folder!(scope, folder_id)`, then `put_change(:folder_id, folder.id)` (see `move_image/3` in the `ecto-essentials` skill).

---

Changeset Composition

The private validators `phx.gen.auth` generates for the changesets above:

defp validate_email(changeset, opts) do
  changeset =
    changeset
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
      message: "must have the @ sign and no spaces"
    )
    |> validate_length(:email, max: 160)

  if Keyword.get(opts, :validate_unique, true) do
    changeset
    |> unsafe_validate_unique(:email, MyApp.Repo)
    |> unique_constraint(:email)
    |> validate_email_changed()
  else
    changeset
  end
end

defp validate_email_changed(changeset) do
  if get_field(changeset, :email) && get_change(changeset, :email) == nil do
    add_error(changeset, :email, "did not change")
  else
    changeset
  end
end

defp validate_password(changeset, opts) do
  changeset
  |> validate_required([:password])
  |> validate_length(:password, min: 12, max: 72)
  |> maybe_hash_password(opts)
end

defp maybe_hash_password(changeset, opts) do
  hash_password? = Keyword.get(opts, :hash_password, true)
  password = get_change(changeset, :password)

  if hash_password? && password && changeset.valid? do
    changeset
    # Bcrypt truncates at 72 bytes; "é" is 2 bytes, so 72 characters can exceed it
    |> validate_length(:password, max: 72, count: :bytes)
    |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(passwo
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.