Ecto Changesets: Building Bulletproof Data Validation in Elixir
Ecto changesets provide a functional, composable approach to data validation. They cast raw input, enforce type constraints, and accumulate errors in a structured way. This guide covers core validation functions, custom business logic, and a worked example for user registration.
02 Mar 2026, 11:56 UTC

The Problem: Why Your Data Keeps Breaking
When user input hits your Elixir application, it's rarely in the shape you need. Missing fields, wrong types, and malformed data cause runtime crashes or silent data corruption. You need a systematic way to validate and sanitize data before it touches your database.
The Solution: Ecto Changesets as a Validation Pipeline
Ecto changesets provide a functional, composable approach to data validation. They cast raw input, enforce type constraints, and accumulate errors in a structured way. The key insight is that validation isn't just about rejecting bad data—it's about providing clear feedback about what went wrong.
Core Validation Functions
Start with cast/3 to whitelist allowed fields and apply type casting:
def changeset(changeset, attrs) do
changeset
|> cast(attrs, [:name, :email, :age])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, min: 0, max: 150)
end
When validation fails, the changeset returns {:error, changes} with field-specific errors:
%Ecto.Changeset{changes: %{}, errors: [email: {"is invalid", [validation: :format]}], valid?: false}
Custom Business Logic Validation
For domain-specific rules like password strength, use validate_change/3:
def password_strength(changeset, :password, password) do
if String.length(password) < 8 do
add_error(changeset, :password, "must be at least 8 characters")
else
changeset
end
end
This function receives the entire changeset, allowing cross-field validation:
def validate_password_confirmation(changeset) do
case {get_field(changeset, :password), get_field(changeset, :password_confirmation)} do
{pwd, pwd} when pwd != nil -> changeset
{_pwd, confirm} when confirm != nil -> add_error(changeset, :password_confirmation, "does not match password")
_ -> changeset
end
end
Worked Example: User Registration Flow
Consider a Phoenix controller handling user registration:
def create(conn, %"user" => user_params) do
case Accounts.register_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "User created successfully!")
|> redirect(to: "/users/#{user.id}")
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> render(:new, changeset: changeset)
end
end
The register_user/1 function combines casting and validation:
def register_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
In your view template, display errors next to each field:
<%= f = form_for @changeset, "#users", [as: :user] %>
<%= text_input f, :email %>
<%= error_tag f, :email %>
Trade-offs and Limitations
Changesets provide excellent client-side validation feedback, but they're not a substitute for database constraints. A user could bypass your API and insert invalid data directly. Always define database constraints as your final line of defense.
Version differences between Ecto 2.x and 3.x affect error message formatting and type casting behavior. Code written for one version may need adjustments when upgrading.
Custom validation functions must handle edge cases carefully. Accessing non-existent fields or performing unsafe operations can cause runtime crashes.
Verification: Testing Your Validation
Run your application in interactive mode to test validation behavior:
iex -S mix
Then in the IEx session:
alias MyApp.Accounts
attrs = %"email" => "invalid", "name" => ""
{:error, changeset} = Accounts.register_user(attrs)
changeset.errors
Actionable Next Steps
- Define your schema with appropriate field types and constraints
- Create a base changeset function that casts and validates common fields
- Add custom validation functions for business rules
- Test validation failures in IEx to verify error messages
- Add database constraints to catch any validation bypass attempts
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.