Elixir: Data & Ecto
Ecto is not an ORM in the ActiveRecord sense — it is a functional data toolkit: explicit
schemas, validations collected in changesets, composable queries, and a repository that owns the
database connection. The flow is always: schema → changeset → repo.
The Ecto Flow
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :role, Ecto.Enum, values: [:user, :admin], default: :user
field :visits, :integer, default: 0
timestamps() # inserted_at / updated_at
end
# A changeset is a pipeline: it either validates or accumulates errors.
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :role]) # whitelist input — nothing else lands
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
|> validate_length(:email, max: 160)
|> unique_constraint(:email) # enforced by a DB index, not memory
end
end
# Using the changeset — invalid input is a VALUE you inspect.
%MyApp.Accounts.User{}
|> MyApp.Accounts.User.changeset(%{"email" => "not-an-email"})
|> Repo.insert()
#=> {:error, %Ecto.Changeset{errors: [email: {"has invalid format", []}]}}
# render `changeset.errors` straight to the API client.
Queries
Ecto.Query is a macro DSL that is compiled against your schema — typos in field names
are compile errors, and there is no string concatenation to inject.
import Ecto.Query
# Keyword syntax — compile-checked against the schema.
query =
from u in MyApp.Accounts.User,
where: u.role == :user and u.visits > ^min_visits, # ^ pins a value
order_by: [desc: u.visits],
limit: 10
Repo.all(query)
# Pipe syntax — composable pieces for dynamic conditions.
MyApp.Accounts.User
|> where([u], u.role == ^role)
|> select([u], {u.id, u.email})
|> Repo.all()
Migrations and Transactions
mix ecto.create # create the database
mix ecto.gen.migration add_visits_index
mix ecto.migrate # apply pending migrations
# A migration — forward AND down, reviewable in git.
defmodule MyApp.Repo.Migrations.AddVisitsIndex do
use Ecto.Migration
def change do
create index(:users, [:visits])
create unique_index(:users, [:email]) # backs unique_constraint/2
end
end
# Transactions: Repo.transaction takes either a function or a Multi.
MyApp.Repo.transaction(fn ->
{:ok, user} = MyApp.Repo.insert(changeset)
{:ok, _} = create_profile(user)
user
end)
# Multi composes dependent steps with named results — better than
# a function when the steps have complex error handling.
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, changeset)
|> Ecto.Multi.insert(:audit, fn %{user: user} -> audit_changeset(user) end)
|> MyApp.Repo.transaction()
Embedded Schemas
Not every shape needs a table. embedded_schema gives you struct + changeset machinery
for form objects, JSON payloads, or values embedded in a JSON column.
defmodule MyApp.Search.Filters do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :query, :string
field :min_price, :integer, default: 0
end
def changeset(filters \\ %__MODULE__{}, attrs) do
filters
|> cast(attrs, [:query, :min_price])
|> validate_number(:min_price, greater_than_or_equal_to: 0)
end
end
# Parse and validate request params without touching the database.
filters = MyApp.Search.Filters.changeset(%{"query" => "elixir", "min_price" => "-5"})
filters.valid? #=> false — validation ran, no DB involved
Practice
- Model a
Postwith abelongs_toauthor, then write a query that preloads comments with a count. - Demonstrate a
unique_constraintfiring on a duplicate email and render the error message. - Replace one multi-step transaction function with an
Ecto.Multiand compare error reporting.
Next: Phoenix Web Framework