Ecto Changesets: Declarative Validation, Casting, and Change Tracking in Elixir
Learn how Ecto changesets let you centralize validation logic, cast external data, track changes, and handle errors in one place. A practical example, trade‑offs, and best practices are included.
05 May 2026, 04:42 UTC

The Problem
In a Phoenix or generic OTP application you often see the same validation logic duplicated across controllers, contexts, and tests. When the shape of the data changes, every place that validates it must be updated, leading to bugs and maintenance headaches. You also need a consistent way to coerce incoming parameters into the types your schema expects and to know which fields actually changed before a database write.
Thesis: Changesets Centralize Validation, Casting, and Change Tracking
Ecto changesets provide a single, declarative API that handles:
- Type casting of external input.
- Declarative validation rules.
- Tracking of which fields have changed.
- Accumulation of errors that can be surfaced to the user or logged.
By moving all this logic into a changeset function you keep your business rules in one place, reduce boilerplate, and make your code easier to test.
Core Concepts
1. Casting
The Ecto.Changeset.cast/3 function converts a map of parameters (often coming from a web request) into the types defined in your schema. It also filters out any keys that aren’t allowed to change.
2. Validation
After casting you can chain validation helpers such as validate_required/2, validate_length/3, or custom functions that add errors to the changeset. Errors are stored in the :errors list.
3. Change Tracking
Ecto automatically records which fields have changed in the :changes map. The changed?/2 helper lets you check if a particular field was modified. This is useful for optimistic locking or for conditional logic before persisting.
4. Error Accumulation
All validation functions return a new changeset; errors are accumulated across the chain. When you call Ecto.Changeset.apply_changes/1 you get the struct with the validated and casted data, or you can inspect :errors to display messages.
Worked Example: A User Schema
Assume we have a User schema with name, email, and age fields. We want to:
- Require
nameandemail. - Ensure
emailhas a valid format. - Limit
nameto 50 characters. - Validate that
ageis an integer between 13 and 120. - Track changes for optimistic locking.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
timestamps()
end
@doc """Primary changeset used for create and update operations."""
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :age])
|> validate_required([:name, :email])
|> validate_length(:name, max: 50)
|> validate_format(:email, ~r/^[\w.!#$%&’*+/=?^`{|}~-]+@[\w-]+(\.[\w-]+)+$/)
|> validate_number(:age, greater_than_or_equal_to: 13, less_than_or_equal_to: 120)
|> unique_constraint(:email) # relies on a DB unique index
|> optimistic_lock(:lock_version) # assumes a lock_version column
end
end
Usage in a context module:
defmodule MyApp.Accounts do
alias MyApp.Repo
alias MyApp.Accounts.User
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
end
Running mix test on a test that passes invalid data will show the accumulated errors:
iex> MyApp.Accounts.create_user(%{name: "", email: "not-an-email", age: 10})
{:error, #Ecto.Changeset<...>}
Inspect the changeset:
iex> changeset = MyApp.Accounts.create_user(%{name: "", email: "not-an-email", age: 10}).errors
[{:name, {"can't be blank", [validation: :required]}}, {:email, {"has invalid format", [validation: :format]}}, {:age, {"must be greater than or equal to 13", [validation: :number]}}, {:age, {"must be less than or equal to 120", [validation: :number]}}, {:email, {"conflict", [constraint: :unique]}}]
To verify change tracking, you can check changed?/2:
iex> changeset = User.changeset(%User{}, %{name: "Alice", email: "[contact removed]"})
iex> Ecto.Changeset.changed?(changeset, :name)
true
iex> Ecto.Changeset.changed?(changeset, :age)
false
Trade‑Offs and Limitations
- Complexity in a Single Changeset: Packing many validations into one function can make it hard to read and maintain. A common pattern is to split into smaller, reusable functions and compose them.
- Uniqueness Not Enforced Automatically:
unique_constraint/3only works if the database has a unique index. Without it, concurrent inserts may still violate uniqueness. Always add a unique index in the migration. - Performance: For very large schemas, casting and validating every field on each request can be costly. Consider selective casting or custom changeset functions for bulk operations.
- Database Constraints vs. Validation: Validation functions run in Elixir, while constraints run in the database. Relying solely on validation can miss race conditions; always enforce critical constraints at the DB level.
Actionable Closing
- Define a single changeset per schema that covers create and update. Keep it small by delegating to helper functions.
- Add database unique indexes and use
unique_constraint/3so that constraint errors appear as changeset errors. - Use
apply_changes/1in tests to confirm the final struct matches the input after validation. - Leverage
changed?/2for optimistic locking or conditional logic before persisting. - Document changeset behavior in your context modules so that controllers and background jobs use the same validation logic.
By centralizing validation, casting, and change tracking in Ecto changesets you reduce duplication, improve testability, and keep your business rules consistent across the stack.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.