Robin van der Vleuten

Your Ecto changeset is allowed to be boring

I've written changesets that were trying very hard to be impressive.

You know the type. A schema has a changeset/2, then a registration_changeset/2, then an admin_changeset/2. After that comes a little module that builds errors for the frontend, another module that knows which fields are visible on which screen, and one more helper because the embedded form is "a bit special."

At some point the code stops feeling like validation and starts feeling like a tiny form framework that nobody remembers designing.

Annoyingly, the boring version was usually enough.

Ecto changesets already have a clear job. They take data you do not trust yet, cast the fields you are willing to accept, validate the result, and keep the errors close to the data. That is enough for most forms.

The temptation to build a form system

The slippery slope rarely starts with a grand architecture plan. It starts with a reasonable request.

"This field is required on signup, but not when an admin edits the user."

Fine. Add a second changeset.

Then another one appears.

"This API endpoint needs a slightly different error shape."

Still reasonable. Add a helper.

Then a third.

"This embedded form needs to validate three fields before we create the actual record."

Before long, the changeset is no longer the source of truth. It is one stop in a pipeline of mapping, translating, grouping, decorating, and massaging.

The code still works, which is part of the problem. Every new field has to be added in four places. Every error message has a little journey before anyone sees it.

That is usually when I try to ask the dull question again: what does Ecto already give me?

More than I usually remember when I'm halfway through inventing a tiny framework.

Start with the fields you accept

The most important line in many changesets is the least exciting one:

elixir
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :timezone])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
end

cast/4 is the boundary. It says: these are the fields this operation accepts from the outside world.

That line is worth keeping boring and explicit.

If a form posts role: "admin" and :role is not in the cast list, it does not become a change. You do not need a custom sanitizer for that. The changeset already has the list of fields you meant to allow.

There is a nice side effect too: the code reads like the form.

Name, email, timezone. Name and email required. Email shaped like an email.

Nothing clever is hiding behind a private DSL with vocabulary you have to relearn six months later.

Different operation, different changeset

Not every operation has the same rules, and that is fine. The mistake is pretending one changeset has to cover every path.

User registration is not the same as an account settings update:

elixir
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password, :timezone])
|> validate_required([:name, :email, :password])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
|> validate_length(:password, min: 12)
|> unique_constraint(:email)
end
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:name, :timezone])
|> validate_required([:name])
end

Two changesets. Two names. Two clear reasons to exist.

That beats one generic changeset/3 with a context option that slowly grows a small command language:

elixir
# This starts neat, then somehow becomes the new framework.
User.changeset(user, attrs, mode: :registration, validate_email: true, require_password: true)

Options are not bad. But if the option changes what the operation is, a separate function is usually easier to read, test, and delete later.

Put internal changes where they happen

Some data should not come from the user at all.

Maybe you normalize an email address. Maybe you store who invited the user. Maybe you stamp a derived value the form should never be allowed to submit.

That is where put_change/3 is useful:

elixir
def registration_changeset(user, attrs, inviter) do
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> update_change(:email, &String.downcase/1)
|> put_change(:invited_by_id, inviter.id)
end

The split matters:

  • cast/4 handles data from outside.
  • put_change/3 handles data your application decided.

Keeping that difference visible makes the changeset easier to trust. The cast list tells you what the user controlled. The later steps tell you what the system added.

Constraints belong next to validations

Some rules cannot be checked honestly until the database gets involved.

Email uniqueness is the classic example. You can query first, but another request can create the same email between your query and your insert. The database constraint is the thing that actually protects you.

Ecto lets the changeset know about that:

elixir
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
|> unique_constraint(:email)
end

The important part is that unique_constraint/3 does not replace a database index. It turns a database error into a changeset error.

That is a good trade: the database keeps the data safe, and the changeset keeps the feedback useful.

Error messages do not need a separate universe

Frontend code often wants errors as a map. Fair enough.

That does not mean the application needs a whole error formatting layer before it has earned one.

Ecto gives you traverse_errors/2:

elixir
def translate_errors(changeset) do
traverse_errors(changeset, fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end

Given a changeset with a missing email and a short password, you get a boring map back:

elixir
%{
email: ["can't be blank"],
password: ["should be at least 12 character(s)"]
}

That is not the final answer for every product. You may need translations, error codes, nested structures, or field-specific copy later.

But start here.

The plain map is enough for most forms, tests, and JSON responses. More importantly, it keeps the validation language close to Ecto instead of inventing another error system beside it on day one.

Keep the boring parts boring

The changesets I like working with have a few things in common:

The cast list is explicit. You can see what outside data is allowed to change.

The function name describes the operation. registration_changeset/2 tells you more than changeset/3 with a bag of options.

Application-owned values are added after casting. User input and system decisions do not get mixed together.

Database constraints stay in the database. The changeset names them so errors come back in the right shape.

Errors are formatted late. Keep the changeset useful for as long as possible before turning it into whatever the controller or API needs.

None of this is advanced. That is the point.

When a changeset is boring, you can change the form without spelunking through a private abstraction. You can add a field and see where it belongs. You can test one operation without setting up the entire product in miniature.

And when the rules really do get complicated, the boring version gives you a better place to start. You will know which part became painful because there was not already a framework-shaped blanket over the whole thing.

So yes, extract when the duplication becomes real. Add helpers when they remove noise. Build a richer error layer when the product asks for it.

But let the changeset be boring first.