`Ecto.Enum` maps atoms to integer columns

almirsarajcic

almirsarajcic

2 hours ago

0 comments

Status columns tend to rot the same way everywhere: an integer in the database, a string in the params, an atom in the business logic, and String.to_atom/1 calls scattered across three contexts to bridge them. Ecto.Enum collapses all of it into the field definition — atoms in Elixir, integers in Postgres, with the conversion and the validation handled for you.

defmodule MyApp.Blog.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :status, Ecto.Enum, values: [draft: 0, review: 1, published: 2]
  end

  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :status])
    |> validate_required([:title, :status])
  end
end

The keyword-list form is the one worth knowing. values: [:draft, :review, :published] stores strings; values: [draft: 0, review: 1, published: 2] stores integers while your code still sees atoms:

changeset = Article.changeset(%Article{}, %{"title" => "Hi", "status" => "published"})
changeset.changes
# => %{status: :published, title: "Hi"}

Ecto.Type.dump(Article.__schema__(:type, :status), :published)
# => {:ok, 2}

A form posts "published", the changeset holds :published, the column stores 2. No conversion code in between.

Validation comes free

An unknown value is rejected by cast/3 itself — you do not need validate_inclusion/3:

changeset = Article.changeset(%Article{}, %{"title" => "Hi", "status" => "archived"})

changeset.valid?
# => false

changeset.errors
# => [status: {"is invalid", ...}]

That check also protects you from the atom-exhaustion problem the naive version has, because no atom is ever created from user input — the value is looked up against the declared set.

The introspection functions

This is the part that pays off in templates and tests:

Ecto.Enum.values(Article, :status)
# => [:draft, :review, :published]

Ecto.Enum.mappings(Article, :status)
# => [draft: 0, review: 1, published: 2]

Ecto.Enum.dump_values(Article, :status)
# => [0, 1, 2]

values/2 builds your select options straight from the schema, so adding a status to the field definition adds it to every form at once:

<.input field={@form[:status]} type="select" options={Ecto.Enum.values(Article, :status)} />

For a translated label per status, keep the atoms as the source of truth and translate at the edge rather than storing display strings in the column.

The migration side stays ordinary — the column is just an integer:

def change do
  alter table(:articles) do
    add :status, :integer, null: false, default: 0
  end
end

One thing to plan for: the integers are a storage contract. Reordering the keyword list silently rewrites the meaning of every existing row, so append new values with new numbers rather than renumbering the existing ones.

Ecto.Enum docs

Comments (0)

Sign in with GitHub to join the discussion