If you’ve ever created a Phoenix application that communicates with a database, you’ve used Ecto, which is the standard database interface tool for Phoenix applications. Ecto provides rich functionalities and is divided into four parts:
Ecto.Repo: Repositories provide an abstraction on the data store. We can create, update, destroy, and query existing entries through the repositoryEcto.Schema: Schemas are used to convert external data into structs for Elixir. They are often used for mapping database tables to Elixir dataEcto.Query: Queries enable us to retrieve information from a given repository. Ecto queries are secure and composableEcto.Changeset: Changesets enable changes to be tracked and verified prior to being applied to the dataIn this article, we’ll learn about Ecto and its different parts, their functionality, and how to use them properly in your project.
Jump ahead:
Ecto.RepoEcto.ChangesetThe Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
In this tutorial, we’re building a simple e-commerce application that helps users access products from different stores. Our database will have the following tables:
Our first step is to create a Phoenix application. We can do so by running the following command:
mix phx.new catalog
Make sure to have a database instance running, which you can check out in this tutorial for how to run PostgreSQL on Docker Once the application is created, cd catalog, edit the configuration at config/dev.exs to match your database credentials. Finally, run ecto.create.
Database migrations are a way of telling the database what changes we need to make. These changes are usually made incrementally so they can be reversed at any point in time.
Ecto provides a simple command for creating migrations. The command has the following structure:
mix ecto.gen.migration <migration_name>
To create our store migration, we use the following command:
mix ecto.gen.migration create_store
The newly created migration can be found in the priv/repo/migrations directory. Migration names usually take the format of <timestamp>_<migration_name>.exs.
In the create_store migrate file, we are simply creating a stores table, then we add the name field and timestamps:
defmodule Catalog.Repo.Migrations.CreateStore do
use Ecto.Migration
def change do
create table("stores") do
add :name, :string
timestamps()
end
end
end
Following the steps above, we create two more migrations: create_user and create_product. Then, we’ll add the following code to the create_user migration file:
defmodule Catalog.Repo.Migrations.User do
use Ecto.Migration
def change do
create table("users") do
add :name, :string
timestamps()
end
end
end
Next, add the code below to the create_product migration file:
defmodule Catalog.Repo.Migrations.Product do
use Ecto.Migration
def change do
create table("products") do
add :name, :string
add :price, :float
add :description, :string
timestamps()
end
end
end
After the migrations are created, run the mix ecto.migrate command to make any changes to the database.
Schemas are used to map external data into Elixir structs. This means that these schemas allow us to have Elixir representations of external data (e.g., database tables). To create our schemas, replicate the commands below:
mix phx.gen.schema User users name:string --no-migration mix phx.gen.schema Store stores name:string --no-migration mix phx.gen.schema Product products name:string description:string price:float --no-migration
The first argument to the phx.gen.schema is the schema module, followed by its plural name, which should be the same as the table name. The other arguments are the fields we want to create. The --no-migrate flag signals phx.gen.schema not to create a migration file, because we already created them.
The generated schema files can be found in the lib/catalog directory if we look at the schema generated in the store.ex file:
defmodule Catalog.Store do
use Ecto.Schema
import Ecto.Changeset
schema "stores" do
field :name, :string
timestamps()
end
def changeset(store, attrs) do
store
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
Notice that the schema has the same fields as our migration file. This is what we mean when we say that schemas are used to map external data into Elixir structs. Schemas lets us know what fields are present in an external data object. We’ll have a look at the changeset function soon, but for now, let’s test the schema so far.
Ecto.RepoTo test our schema, run iex -S mix in your terminal. This command spins up an interactive shell where we can test out our schema and carry out some other operations:
iex(1)> alias Catalog.Repo
Catalog.Repo
iex(2)> alias Catalog.{User, Store, Product}
[Catalog.User, Catalog.Store, Catalog.Product]
The alias keyword allows us to create aliases. Therefore, we can use Repo rather than Catalog.Repo to keep it short and simple. Using Repo, or repository, we can create, update, destroy, and query existing entries:
iex(3)> {:ok, newStore} = Repo.insert(%Store{name: "Logan Spark Store"})
...
{:ok,
%Catalog.Store{
__meta__: #Ecto.Schema.Metadata<:loaded, "stores">,
id: 1,
name: "Logan Spark Store",
inserted_at: ~N[2023-04-17 01:47:01],
updated_at: ~N[2023-04-17 01:47:01]
}}
{:ok, newProduct} = Repo.insert(%Product{name: "Spark Armour", price: 200.99, description: "The latest sneakers from the Logan Spark collection"})
...
{:ok,
%Catalog.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: 1,
description: "The latest sneakers from the Logan Spark collection",
name: "Spark Armour",
price: 200.99,
inserted_at: ~N[2023-04-17 02:12:32],
updated_at: ~N[2023-04-17 02:12:32]
}}
Repo.insert creates data in the given table. In the code snippet above, we created a store with the name “Logan Spark Store.” Using Repo, we can also perform other operations, as shown below:
iex(5)> Repo.all(Store)
...
[
%Catalog.Store{
__meta__: #Ecto.Schema.Metadata<:loaded, "stores">,
id: 1,
name: "Logan Spark Store",
inserted_at: ~N[2023-04-17 02:10:34],
updated_at: ~N[2023-04-17 02:10:34]
}
]
Repo.get(Product, newProduct.id)
...
%Catalog.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: 1,
description: "The latest sneakers from the Logan Spark collection",
name: "Spark Armour",
price: 200.99,
inserted_at: ~N[2023-04-17 02:12:32],
updated_at: ~N[2023-04-17 02:12:32]
}
Ecto.ChangesetChangesets allow filtering, casting, validation, and defining constraints when working with structs. Changesets tell Ecto how to change your data. Here’s a changeset definition we saw previously in the schema section:
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :description, :price])
|> validate_required([:name, :description, :price])
end
Let’s go into our interactive shell to see how Ecto changesets work:
iex -S mix
We can create a changeset with our Product schema:
iex(9)> changeset = Product.changeset(%Product{}, %{name: "Mcatty tulip", description: "Tulips from the Mcatty gardens", price: 100.00})
#Ecto.Changeset<
action: nil,
changes: %{
description: "Tulips from the Mcatty gardens",
name: "Mcatty tulip",
price: 100.0
},
errors: [],
data: #Catalog.Product<>,
valid?: true
>
|> cast(attrs, [:name, :description, :price])
The attributes listed in cast in the changeset function definition above are the only attributes that make it into the changes property in our changeset. If we add another property that isn’t listed in cast, then the changeset ignores such values. We can see this in the example below:
iex(9)> changeset = Product.changeset(%Product{}, %{name: "Mcatty tulip", description: "Tulips from the Mcatty gardens", price: 100.00, inStock: true})
#Ecto.Changeset<
action: nil,
changes: %{
description: "Tulips from the Mcatty gardens",
name: "Mcatty tulip",
price: 100.0
},
errors: [],
data: #Catalog.Product<>,
valid?: true
>
Despite adding the inStock property to the changeset, it doesn’t appear in the changes field.
Changesets are also used for validation purposes:
|> validate_required([:name, :description, :price])
In validate_required, we declared some fields as required. Now, let’s see what happens when we don’t include one or more of those fields in our changeset:
iex(10)> changeset = Product.changeset(%Product{}, %{name: "Mcatty tulip", description: "Tulips from the Mcatty gardens"})
#Ecto.Changeset<
action: nil,
changes: %{
description: "Tulips from the Mcatty gardens",
name: "Mcatty tulip"
},
errors: [price: {"can't be blank", [validation: :required]}],
data: #Catalog.Product<>,
valid?: false
>
The changeset returns an error. We can check the error from the changeset:
iex(11)> changeset.errors
[price: {"can't be blank", [validation: :required]}]
We can also check if the changeset is valid:
iex(12)> changeset.valid? false
There are many more transformations and validations that can be carried out using changesets.
Associations help to establish relationships between schemas. With associations, we can embed documents. Ecto associations might take one of the following forms, the latter of which does not have an application in the app we’re building in this tutorial:
Has many/belongs toHas one/belongs toMany-to-manyhas many/belongs to Ecto associationIn the case of our application, a store has many products and every product must belong to a store. Let’s create an association between the stores schema and the product schema. We’ll also use this as an opportunity to see how to update our database tables using migrations.
First, we have to alter the products table to have an association with the stores table. We can do that with migrations:
mix ecto.gen.migration add_store_product_association
In the add_store_product_association migration file:
defmodule Catalog.Repo.Migrations.AddStoreProductAssociation do
use Ecto.Migration
def change do
alter table("products") do
add :store_id, references(:stores)
end
end
end
Then, run mix ecto.migrate to update the database. We’ll also update the products schema by adding the belongs_to association:
schema "products" do
...
belongs_to :store, Catalog.Store
timestamps()
end
Update the stores schema by adding the has_many association:
schema "stores" do
...
has_many :products, Catalog.Product
timestamps()
end
has one/belongs to Ecto associationThe association between users and stores follows this pattern. A user has one store and a store belongs to a user. To work with this association, first generate the migration:
mix ecto.gen.migration add_user_store_association
Then, in the add_user_store_association migration file, we’ll add the following code:
defmodule Catalog.Repo.Migrations.AddUserStoreAssociation do
use Ecto.Migration
def change do
alter table("stores") do
add :user_id, references(:users)
end
end
end
Next, we’ll run mix ecto.migrate to update the database, and update the stores schema by adding the belongs_to association:
schema "stores" do
...
belongs_to :user, Catalog.User
timestamps()
end
Finally, we’ll update the users schema by adding the has_one association:
schema "users" do
...
has_one :store, Catalog.Store
timestamps()
end
Let’s see the relationships between our schemas in our interactive Elixir shell. Run iex -S mix. Next, we can recompile our code and insert a new user into the database:
iex(11)> recompile()
Compiling 2 files (.ex)
:ok
iex(12)> Repo.insert(%User{name: "Alex"})
...
{:ok,
%Catalog.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
name: "Alex",
store: #Ecto.Association.NotLoaded<association :store is not loaded>,
inserted_at: ~N[2023-04-19 17:55:26],
updated_at: ~N[2023-04-19 17:55:26]
}}
We can see that there’s a store field on our User struct. Ecto added that for us, because we specified the store field as a relationship on the User schemas.
Let’s create some items that implement our newly created associations:
iex(13)> {:ok, store} = Repo.insert(%Store{name: "Alex store", user: user})
...
iex(14)> {:ok, product} = Repo.insert(%Product{name: "Alex turtle necks", price: 20.99, description: "Cheap turtle necks to make you comfortable", store: store})
...
Now, #Ecto.Association.NotLoaded<association :store is not loaded simply tells us that the association is not loaded. We can fix that with Repo.preload:
iex(15)> Repo.get(User, 1) |> Repo.preload([:store])
...
%Catalog.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
name: "Alex",
store: %Catalog.Store{
__meta__: #Ecto.Schema.Metadata<:loaded, "stores">,
id: 2,
name: "Alex store",
products: #Ecto.Association.NotLoaded<association :products is not loaded>,
user_id: 1,
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
inserted_at: ~N[2023-04-19 20:16:28],
updated_at: ~N[2023-04-19 20:16:28]
},
inserted_at: ~N[2023-04-19 17:55:26],
updated_at: ~N[2023-04-19 17:55:26]
}
We can perform nested preload as follows:
iex(16)> Repo.get(User, 1) |> Repo.preload([store: :user])
...
%Catalog.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
name: "Alex",
store: %Catalog.Store{
__meta__: #Ecto.Schema.Metadata<:loaded, "stores">,
id: 2,
name: "Alex store",
products: #Ecto.Association.NotLoaded<association :products is not loaded>,
user_id: 1,
user: %Catalog.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
name: "Alex",
store: #Ecto.Association.NotLoaded<association :store is not loaded>,
inserted_at: ~N[2023-04-19 17:55:26],
updated_at: ~N[2023-04-19 17:55:26]
},
inserted_at: ~N[2023-04-19 20:16:28],
updated_at: ~N[2023-04-19 20:16:28]
},
inserted_at: ~N[2023-04-19 17:55:26],
updated_at: ~N[2023-04-19 17:55:26]
}
In this introduction to Ecto, we learned about the different parts of the database interface tool, including Ecto.Repo, Ecto.Schema, and Ecto.Changeset. Using appropriate code examples and the interactive Elixir shell, we had some hands-on experience with the different parts of Ecto. Don’t forget to check out the GitHub repository for this project.
Although this article was meant to serve as an introduction, I hope you now have the confidence to take on more advanced Ecto concepts and use these concepts to create amazing applications. For more Phoenix projects using Ecto, check out Authentication with Phoenix and Building a REST API with Elixir and Phoenix.

Promise.all still relevant in 2025?In 2025, async JavaScript looks very different. With tools like Promise.any, Promise.allSettled, and Array.fromAsync, many developers wonder if Promise.all is still worth it. The short answer is yes — but only if you know when and why to use it.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 29th issue.

Learn about the new features in the Next.js 16 release: why they matter, how they impact your workflow, and how to start using them.

Test out Meta’s AI model, Llama, on a real CRUD frontend projects, compare it with competing models, and walk through the setup process.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now