6 min read
On this page

Changesets

The first time you use Ecto, changesets feel like ceremony. Why do I have to wrap a struct just to update a field? Why can't I User.update(user, name: "new name") and call it a day?

The reason is that almost every database write involves the same problems: external data needs to be validated, types need to be coerced, only the actually-changed fields should be sent to the database, errors need to be reported back to the user with field-level granularity, and the whole thing needs to be testable without hitting the database. Changesets solve all of those, in one composable abstraction. Once you internalize the pattern, you'll start using changesets for things that have nothing to do with databases.

What a Changeset Is

A changeset is a struct that holds: the original data, a map of proposed changes, a list of errors, and metadata about which fields are required, which are valid, and what casts have been applied. You build one with cast/3, pipe it through validation and constraint functions, and hand the result to Repo.insert/1 or Repo.update/1.

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :name, :string
    field :age, :integer
    field :password, :string, virtual: true
    field :password_hash, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :name, :age, :password])
    |> validate_required([:email, :name])
    |> validate_format(:email, ~r/@/)
    |> validate_length(:password, min: 8)
    |> validate_number(:age, greater_than_or_equal_to: 13)
    |> unique_constraint(:email)
    |> hash_password()
  end

  defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
    put_change(cs, :password_hash, Bcrypt.hash_pwd_salt(pw))
  end

  defp hash_password(cs), do: cs
end

Calling User.changeset(%User{}, %{"email" => "alice@example.com", "name" => "Alice"}) returns a changeset you can inspect. If valid? is true, it's safe to insert. If not, the errors field tells you what's wrong.

cast/3: Type Coercion and Field Whitelisting

cast(struct_or_changeset, params, allowed_fields) is where external data enters Ecto. It does three things: filters params to only the listed fields (so a malicious client can't sneak admin: true into a request), coerces strings to the right types ("42" becomes 42, "true" becomes true, ISO datetime strings become DateTime structs), and stores the result in changes.

%User{} |> cast(%{"age" => "30", "name" => "Alice", "admin" => true}, [:age, :name])
# changes: %{age: 30, name: "Alice"}, admin filtered out

If a cast fails (e.g., "thirty" can't become an integer), the changeset gets an error on that field and valid? becomes false.

cast/3 is also where the Phoenix form pattern starts: a controller receives string-keyed params from a form, calls User.changeset/2, and the changeset is what the template renders to show errors. Failed inserts return a changeset with errors, and the form re-renders with the user's input intact.

Validations

Ecto ships a validation function for every common case:

|> validate_required([:email, :name])
|> validate_length(:email, max: 160)
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0, less_than: 150)
|> validate_inclusion(:role, ~w(admin user guest))
|> validate_exclusion(:username, ~w(root admin system))
|> validate_acceptance(:terms_of_service)
|> validate_confirmation(:password)
|> validate_subset(:tags, ~w(elixir phoenix ecto))

Custom validations are functions that take a changeset and return a changeset:

defp validate_email_domain(changeset, allowed_domains) do
  validate_change(changeset, :email, fn :email, email ->
    domain = email |> String.split("@") |> List.last()
    if domain in allowed_domains, do: [], else: [email: "domain not allowed"]
  end)
end

validate_change/3 only runs if the field actually changed, which is the right default — you don't want to re-validate untouched fields on every update.

Database Constraints vs Validations

There's a subtle but important distinction: validations run before the database, constraints run after. A unique index on email can't be checked without asking the database — there's a race condition between checking and inserting. Ecto handles this by letting you declare the constraint:

|> unique_constraint(:email)

If the insert fails with a Postgres unique violation on the email index, Ecto translates that into a changeset error on the email field. You get the same error shape whether the duplicate was caught by validation (rare, requires a pre-check) or by the database (the common path).

Other constraint helpers:

|> foreign_key_constraint(:company_id)
|> check_constraint(:age, name: :age_positive)
|> exclusion_constraint(:room_booking, name: :no_overlap)

The constraint name in the migration must match the helper, or you have to pass name: explicitly. This is one of those details that bites you the first time.

Virtual Fields

field :password, :string, virtual: true declares a field on the schema that doesn't exist in the database. It exists on the struct, can be cast, can be validated, and is dropped before the SQL goes out.

This is how you handle things like raw passwords (cast and validate, then transform into password_hash before insert), confirmation fields (password_confirmation for validate_confirmation), and any other "input only" data.

schema "users" do
  field :email, :string
  field :password, :string, virtual: true
  field :password_confirmation, :string, virtual: true
  field :password_hash, :string
end

def changeset(user, attrs) do
  user
  |> cast(attrs, [:email, :password, :password_confirmation])
  |> validate_required([:email, :password])
  |> validate_confirmation(:password)
  |> put_password_hash()
end

defp put_password_hash(%{valid?: true, changes: %{password: pw}} = cs) do
  cs
  |> put_change(:password_hash, Bcrypt.hash_pwd_salt(pw))
  |> delete_change(:password)
end

defp put_password_hash(cs), do: cs

Embedded Schemas

Sometimes you want a structured field — say, an address with street, city, zip — without making it a separate table. embeds_one and embeds_many map a JSONB column to a schema-like struct.

defmodule MyApp.Accounts.Address do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :street, :string
    field :city, :string
    field :zip, :string
  end

  def changeset(address, attrs) do
    address
    |> cast(attrs, [:street, :city, :zip])
    |> validate_required([:street, :city, :zip])
  end
end

defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :email, :string
    embeds_one :address, MyApp.Accounts.Address
    embeds_many :phone_numbers, MyApp.Accounts.PhoneNumber
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> cast_embed(:address)
    |> cast_embed(:phone_numbers)
  end
end

cast_embed calls the embedded schema's changeset function. Errors bubble up with field paths, so a Phoenix form can render user[address][street] errors correctly.

Embedded schemas are also useful for non-database use cases. Need to validate a JSON payload from a webhook? Define an embedded schema, call cast with the body, and use Ecto's validation machinery without ever touching a Repo.

Changesets Without Tables

This is the move that makes changesets click. They don't have to map to a table. A "search form" or "settings update" or "API request validation" can be a changeset.

defmodule MyApp.SearchParams do
  import Ecto.Changeset

  @types %{
    query: :string,
    min_price: :decimal,
    max_price: :decimal,
    category: :string,
    sort: :string
  }

  def changeset(params) do
    {%{}, @types}
    |> cast(params, Map.keys(@types))
    |> validate_inclusion(:sort, ~w(price newest popular))
    |> validate_number(:min_price, greater_than_or_equal_to: 0)
  end
end

The {%{}, @types} form creates a "data + types" pair that acts like a schema. You get all the cast and validate machinery without a database table.

This is why I said you'd start using changesets for things unrelated to databases. Validating a Stripe webhook payload, parsing a CSV row, normalizing user input from a JSON API — all of it benefits from cast plus validation plus error reporting.

Inspecting Errors

When a changeset is invalid, errors live in cs.errors. The shape is [{field, {message, opts}}].

%{errors: [email: {"has invalid format", [validation: :format]}]} = cs

For rendering, use Ecto.Changeset.traverse_errors/2:

def errors_on(changeset) do
  Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
    Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
      opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
    end)
  end)
end

Phoenix forms call this for you and render <.error> components with the right message per field.

Common Pitfalls

Casting a field but not adding it to validate_required. The cast happens, the changeset is valid, you insert a row with null in a column that was supposed to require a value.

Forgetting that cast filters fields by the third argument. If a new field shows up in the schema but you don't add it to the cast list, it's silently ignored. This is a feature for security, but it bites you when you're adding fields.

Confusing virtual fields with the absence of validation. Virtual fields still get cast and validated; they just don't get persisted.

Adding unique_constraint without a matching unique index in the database. The constraint helper translates database errors into changeset errors — if there's no unique index, there's no error to translate, and duplicates slip in.

Wrapping the same struct in multiple changesets in a single transaction without using Ecto.Multi. If you need atomicity across multiple writes, use Multi, not nested changesets.

Putting business logic in the changeset that should be in the context. Changesets are for casting, validating, and tracking changes. Sending an email, charging a card, calling a webhook — those go in the context module that calls Repo.insert/1.

Key Takeaways

Changesets are the funnel for any data entering your system: they cast strings to types, filter unsafe fields, validate business rules, and translate database constraint errors into form errors. Use virtual fields for input-only data like raw passwords. Use embedded schemas for structured fields without tables. Use schemaless changesets ({%{}, @types}) for validating API payloads, search forms, and any other "external data, structured" problem. The pattern transfers — once you see changesets clearly, you'll reach for them outside of database code too.