Stopping Data Corruption with Ecto Changesets
Learn how Ecto Changesets prevent data corruption by decoupling input casting and validation from database persistence in Elixir.
11 Jul 2026, 11:59 UTC

The Risk of Raw Input
When building an Elixir application, it is tempting to pass a map of parameters directly from a web form or an external API into your database. However, raw input is \"dirty.\" It often contains unexpected keys, incorrect types (like a string where an integer is required), or missing required fields. If this data reaches your database without a filter, you risk runtime crashes, corrupted state, or security vulnerabilities like mass assignment.
The solution is the Ecto Changeset. Rather than treating a database record as a simple object to be updated, Ecto uses the changeset as a staging area. It decouples the process of receiving data, validating it, and persisting it, ensuring that only clean, verified data ever touches your storage layer.
Casting: The First Line of Defense
Casting is the process of filtering raw input to ensure only allowed fields are processed and that they match the expected types defined in your schema. In Ecto, this is handled by Ecto.Changeset.cast/3.
By explicitly defining a list of permitted keys, you prevent users from accidentally (or maliciously) updating fields they shouldn't touch, such as an is_admin flag or a user_id. If a key is passed that isn't in the permitted list, Ecto simply ignores it. If a value is provided but cannot be converted to the schema's type, the changeset is marked as invalid.
Building Validation Pipelines
Once data is cast, it enters a validation pipeline. Because Ecto changesets are immutable, you can chain validation functions together using the pipe operator (|>). This creates a declarative set of rules that the data must satisfy before it is considered valid?.
- Required Fields:
validate_required/2ensures essential data is present. - Format and Length:
validate_length/3orvalidate_format/3check for constraints like password strength or email patterns. - Custom Logic: You can write your own functions that take a changeset and return a changeset, allowing for complex business logic.
Worked Example: User Registration
Below is a practical implementation of a User schema and a changeset function. This example assumes you are using Ecto 3.x.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string
field :age, :integer
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password, :age])
|> validate_required([:email, :password])
|> validate_format(:email, ~r/^[^\\s]+@[^\\s]+$/, message: "must be a valid email address")
|> validate_length(:password, min: 8)
end
end
Verification and Diagnostics
To verify the behavior, run the following in iex -S mix. You do not need a running database to test the changeset logic itself:
# Test invalid input
attrs = %{email: "bad-email", password: "123", extra_field: "ignored"}
changeset = MyApp.Accounts.User.registration_changeset(%MyApp.Accounts.User{}, attrs)
# Check results
IO.inspect(changeset.valid?) # Expected: false
IO.inspect(changeset.errors) # Expected: [email: {"must be a valid email address", []}, password: {"should be at least 8 character(s)", []}]
Constraints and Trade-offs
While changesets are powerful, they have a critical limitation: they are not a replacement for database constraints. A changeset can check if an email is formatted correctly, but it cannot guarantee that an email is unique across the entire database without performing a query. To handle uniqueness, you must use unique_constraint/3, which instructs Ecto to watch for a specific error returned by the database (like a Postgres unique index violation) and convert it into a readable changeset error.
Additionally, there is a slight performance overhead in creating intermediate changeset structs compared to raw map manipulation. For the vast majority of applications, this is a negligible cost compared to the safety of guaranteed data integrity.
Practical Implementation Advice
To keep your codebase maintainable, avoid putting complex business logic directly inside your schema modules. Instead, move the call to the changeset into a Context module (e.g., MyApp.Accounts). This keeps your schemas as simple data definitions and allows your context to manage the orchestration between the changeset and the Repo.
Warning: Avoid performing expensive database lookups inside a changeset function. If you are updating 1,000 records in a loop and each changeset triggers a separate query, you will create an N+1 query problem. Perform bulk checks outside the changeset when possible.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.