Decoupling Validation from Persistence with Ecto Changesets
Stop using fat models for validation. Learn how Ecto Changesets decouple data transformation from persistence to prevent mass-assignment vulnerabilities and handle race conditions.
25 Oct 2025, 09:26 UTC

The Problem: The "Fat Model" Validation Trap
In many traditional ORMs, the data model is responsible for its own validation. This "Active Record" approach often leads to rigid systems where a User model must always be valid to exist, regardless of whether you are updating a password, onboarding a new user, or performing a background administrative migration. When validation is baked into the model, you end up with complex conditional logic inside your entities just to handle different update scenarios.
Ecto solves this by separating the Schema (the data structure) from the Changeset (the transition logic). A Changeset is not a record; it is a description of a change. It tracks what is being changed, whether those changes are valid, and what errors occurred, without touching the database until you explicitly tell it to.
The Security Boundary: Casting and Filtering
One of the most critical functions in Ecto is cast/3. In many frameworks, passing a raw HTTP request map directly into a database update creates a "mass-assignment" vulnerability, where a malicious user could send {"is_admin": true} in a request to escalate their privileges.
cast/3 acts as a security filter. It takes the current data, the new parameters, and a list of permitted keys. Any key in the input map not explicitly listed in the permitted keys is silently ignored. This ensures that the application, not the user, controls which fields are mutable.
Building a Validation Pipeline
Changesets are designed to be piped. You start with the raw data, cast it, apply required field checks, and then layer on specific business rules. This pipeline approach makes the data flow predictable and easy to test without needing a running database.
Worked Example: User Registration
Assume you are using Ecto 3.x. Here is a practical implementation of a registration changeset. Run this logic within your context module (e.g., Accounts) with the necessary Ecto.Schema defined.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string
field :username, :string
field :role, :string, default: "user"
end
def registration_changeset(user, attrs)">
user
# 1. Filter input: only allow email, password, and username
|> cast(attrs, [:email, :password, :username])
# 2. Ensure essential fields are present
|> validate_required([:email, :password])
# 3. Enforce business rules
|> validate_length(:password, min: 8)
|> validate_format(:email, ~r/@/)
end
end
To verify this without a database, you can call the function in iex:
# Run in IEx
attrs = %{ "email" => "bad-email", "password" => "123" }
changeset = MyApp.Accounts.User.registration_changeset(%MyApp.Accounts.User{}, attrs)
# Check the validity
changeset.valid? # Expected: false
changeset.errors # Expected: [email: {"has invalid format", ...}, password: {"should be at least 8 characters", ...}]
The Database Gap: Constraints vs. Validations
A common mistake is assuming validate_required or validate_length replaces database constraints. These functions are client-side checks; they happen in the Elixir VM before a SQL query is even generated.
Race conditions can still occur. For example, if you validate that an email is unique by querying the database, another process could insert that same email in the millisecond between your check and your insert. To handle this, Ecto uses unique_constraint/3. This does not validate the data immediately; instead, it tells Ecto: "If the database returns a unique constraint violation error for this field, convert that database error into a clean Changeset error message."
Comparison: Validation vs. Constraint
| Feature | validate_* |
unique_constraint |
|---|---|---|
| Execution Time | Before DB call | After DB response |
| Purpose | Format & Presence | Data Integrity/Concurrency |
| Requirement | Pure Elixir logic | DB-level Index/Constraint |
Practical Limitations
While Changesets are powerful, avoid putting heavy business logic (like calling external APIs) inside them. Changesets should remain pure transformations of data. If you need to perform complex external checks, do so in your Context module before passing the data to the changeset, or use prepare_changes/2 for modifications that must happen immediately before the transaction commits.
Actionable Summary
To implement this pattern effectively:
- Define multiple changeset functions per schema (e.g.,
registration_changesetvsprofile_update_changeset) to maintain strict control over mutable fields. - Always use
cast/3to prevent mass-assignment vulnerabilities. - Pair
validate_functions with DB constraints to ensure both a good user experience and absolute data integrity. - Test changesets in isolation by checking the
valid?flag anderrorslist without callingRepo.insert.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.