Building Safe Input Pipelines with Ecto Changesets
Learn how Ecto Changesets filter input, validate data, and handle database constraints safely in Elixir applications.
11 Jul 2026, 22:47 UTC

The Problem: Unfiltered Input Reaches the Database
When an Elixir application receives a JSON payload, it's easy to pass the raw map straight into a changeset and assume everything is fine. If the payload contains extra fields—like an :is_admin flag—or missing required data, the database may reject the insert with a vague constraint error, or worse, store unintended data.
The takeaway is that Ecto.Changeset provides a two‑stage pipeline: first filter the input with cast/3, then apply validation functions that collect errors without raising exceptions. This keeps only explicitly allowed data moving toward persistence.
Stage 1: Casting – the Security Filter
cast/3 receives a schema, a map of parameters, and a list of permitted keys. Any key not in that list is dropped, preventing mass‑assignment attacks. For example, a user updating their profile should not be able to change :role unless it appears in the permitted list.
Stage 2: Validation – the Integrity Check
After casting, functions like validate_required/2, validate_length/3, and validate_format/3 run on the filtered map. They do not halt execution; instead, they push error tuples into the changeset’s errors field and set valid? to false. This lets you collect all problems before deciding what to return to the caller.
Worked Example: User Registration Changeset
The following module shows a typical registration flow for an Ecto 3.x schema.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :username, :string
field :email, :string
field :password, :string
field :role, :string, default: "user"
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:username, :email, :password])
|> validate_required([:username, :email, :password])
|> validate_length(:password, min: 8)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
end
Notice that :role is omitted from the cast list, so even if a client sends %{role: "admin"} it will be stripped out.
Verification Without a Database
You can inspect the changeset in an iex session to confirm the pipeline works as expected.
# In iex -S mix
user = %MyApp.Accounts.User{}
params = %{
"username" => "alice",
"email" => "[contact removed]",
"password" => "123", # too short
"role" => "admin" # should be removed
}
changeset = MyApp.Accounts.User.registration_changeset(user, params)
IO.inspect(changeset.valid?) # => false
IO.inspect(changeset.errors) # => [password: {"should be at least %{count} character(s)", [min: 8]}]
IO.inspect(changeset.changes) # => %{role: nil} (role filtered out)
The :role key does not appear in changes because it was removed by cast/3. The password error is collected, and the changeset is marked invalid.
Trade‑off: Database‑Level Uniqueness
Ecto’s validate_* functions run in Elixir and cannot guarantee uniqueness because of race conditions. The unique_constraint/3 function does not query the database upfront; instead, it tells Ecto to convert a unique‑violation error from the repo into a changeset error. You still need a unique index on the column in the database.
Limitations and Best Practices
- Keeping all changeset logic inside the schema can lead to "fat" schemas as the application grows. Consider moving registration or update functions into a context module (e.g.,
Accounts.register_user/2) that builds and returns the changeset. - When using
cast_assoc/3orcast_embed/3with large lists, each nested changeset allocates its own struct, which can increase memory usage. Profile with:observerorexprofif you notice high memory consumption.
Actionable Closing
To build a reliable data pipeline:
- Start with
cast/3to whitelist fields. - Add presence checks with
validate_required/2. - Apply format, length, or custom validators.
- Finish with
unique_constraint/3(or other DB‑derived constraints) for race‑safe integrity. - Use
Ecto.Changeset.apply_action/2in tests to validate without hitting the repo.
Following this order ensures that only safe, validated data ever reaches your database.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.