Ecto Relationships Basic: Mastering Data Associations
With Elixir 1.12 and Phoenix 1.6 bringing significant improvements to the ecosystem, understanding Ecto relationships has become more crucial than ever. Proper data modeling is the foundation of any robust application.
Why Ecto Relationships Matter
Traditional ORMs hide complexity behind magic methods. Ecto takes a different approach—it’s explicit, composable, and gives you complete control over your database interactions.
# Instead of magic methods like user.posts.create()
# You get explicit, composable queries
user
|> Ecto.build_assoc(:posts)
|> Post.changeset(post_params)
|> Repo.insert()
This explicitness leads to better performance, clearer debugging, and more maintainable code.
The Four Essential Relationships
1. Has One
A user has one profile:
# Schema definition
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
has_one :profile, MyApp.Profile
timestamps()
end
end
defmodule MyApp.Profile do
use Ecto.Schema
import Ecto.Changeset
schema "profiles" do
field :bio, :string
field :avatar_url, :string
field :website, :string
belongs_to :user, MyApp.User
timestamps()
end
end
Migration:
defmodule MyApp.Repo.Migrations.CreateProfiles do
use Ecto.Migration
def change do
create table(:profiles) do
add :bio, :text
add :avatar_url, :string
add :website, :string
add :user_id, references(:users, on_delete: :delete_all), null: false
timestamps()
end
create unique_index(:profiles, [:user_id])
end
end
2. Has Many
A user has many posts:
defmodule MyApp.User do
use Ecto.Schema
schema "users" do
field :email, :string
field :name, :string
has_many :posts, MyApp.Post
timestamps()
end
end
defmodule MyApp.Post do
use Ecto.Schema
schema "posts" do
field :title, :string
field :body, :text
field :published_at, :utc_datetime
belongs_to :user, MyApp.User
timestamps()
end
end
Migration:
defmodule MyApp.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string, null: false
add :body, :text
add :published_at, :utc_datetime
add :user_id, references(:users, on_delete: :delete_all), null: false
timestamps()
end
create index(:posts, [:user_id])
create index(:posts, [:published_at])
end
end
3. Belongs To
Already shown above, but here’s the key insight:
# The belongs_to side holds the foreign key
defmodule MyApp.Post do
schema "posts" do
# This creates a user_id field automatically
belongs_to :user, MyApp.User
# Explicit foreign key (optional)
belongs_to :category, MyApp.Category, foreign_key: :cat_id
end
end
4. Many to Many
Posts have many tags, tags have many posts:
defmodule MyApp.Post do
use Ecto.Schema
schema "posts" do
field :title, :string
field :body, :text
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
timestamps()
end
end
defmodule MyApp.Tag do
use Ecto.Schema
schema "tags" do
field :name, :string
field :color, :string
many_to_many :posts, MyApp.Post, join_through: "posts_tags"
timestamps()
end
end
Join Table Migration:
defmodule MyApp.Repo.Migrations.CreatePostsTags do
use Ecto.Migration
def change do
create table(:posts_tags, primary_key: false) do
add :post_id, references(:posts, on_delete: :delete_all), null: false
add :tag_id, references(:tags, on_delete: :delete_all), null: false
end
create unique_index(:posts_tags, [:post_id, :tag_id])
create index(:posts_tags, [:tag_id])
end
end
Advanced: Many to Many with Join Schema
For additional data on the relationship:
defmodule MyApp.PostTag do
use Ecto.Schema
schema "posts_tags" do
field :featured, :boolean, default: false
field :added_by, :string
belongs_to :post, MyApp.Post
belongs_to :tag, MyApp.Tag
timestamps()
end
end
# Update the schemas
defmodule MyApp.Post do
schema "posts" do
# ...
many_to_many :tags, MyApp.Tag, join_through: MyApp.PostTag
has_many :post_tags, MyApp.PostTag
end
end
Querying Relationships
Basic Preloading
# Load user with posts
user = Repo.get!(User, 1) |> Repo.preload(:posts)
# Load posts with user and tags
posts = Repo.all(Post) |> Repo.preload([:user, :tags])
# Nested preloading
users = Repo.all(User) |> Repo.preload([posts: :tags])
Custom Preload Queries
# Only published posts
users =
Repo.all(User)
|> Repo.preload([posts: from(p in Post, where: not is_nil(p.published_at))])
# Recent posts with user
recent_posts_query = from p in Post,
where: p.inserted_at > ago(7, "day"),
order_by: [desc: p.inserted_at]
users =
Repo.all(User)
|> Repo.preload([posts: recent_posts_query])
Join Queries
# Users who have published posts
active_users =
from u in User,
join: p in assoc(u, :posts),
where: not is_nil(p.published_at),
distinct: true,
select: u
# Posts with user information
posts_with_users =
from p in Post,
join: u in assoc(p, :user),
select: {p, u}
# Count posts per user
user_post_counts =
from u in User,
left_join: p in assoc(u, :posts),
group_by: u.id,
select: {u, count(p.id)}
Working with Associations
Creating Associated Records
# Build association
user = Repo.get!(User, 1)
post = Ecto.build_assoc(user, :posts, %{title: "New Post", body: "Content"})
{:ok, created_post} = Repo.insert(post)
# Create with association
{:ok, user} =
User.changeset(%User{}, user_params)
|> Ecto.Changeset.put_assoc(:profile, profile_params)
|> Repo.insert()
Updating Associations
# Replace associations
changeset =
post
|> Repo.preload(:tags)
|> Post.changeset(post_params)
|> Ecto.Changeset.put_assoc(:tags, new_tags)
{:ok, updated_post} = Repo.update(changeset)
# Add to existing associations
new_tag = %Tag{name: "elixir"}
updated_tags = post.tags ++ [new_tag]
changeset =
post
|> Post.changeset(%{})
|> Ecto.Changeset.put_assoc(:tags, updated_tags)
Deleting Associations
# Remove specific associations
remaining_tags = Enum.reject(post.tags, &(&1.id == tag_to_remove.id))
changeset =
post
|> Post.changeset(%{})
|> Ecto.Changeset.put_assoc(:tags, remaining_tags)
{:ok, updated_post} = Repo.update(changeset)
# Clear all associations
changeset =
post
|> Post.changeset(%{})
|> Ecto.Changeset.put_assoc(:tags, [])
Performance Optimization
N+1 Query Prevention
# ❌ N+1 problem
users = Repo.all(User)
Enum.each(users, fn user ->
posts = Repo.all(assoc(user, :posts)) # Separate query for each user!
IO.inspect(length(posts))
end)
# ✅ Efficient preloading
users = Repo.all(User) |> Repo.preload(:posts)
Enum.each(users, fn user ->
IO.inspect(length(user.posts)) # No additional queries
end)
Selective Loading
# Only load what you need
posts =
from p in Post,
join: u in assoc(p, :user),
select: %{
id: p.id,
title: p.title,
author_name: u.name,
created_at: p.inserted_at
}
Counting Efficiently
# ❌ Loading all records to count
user_post_count = length(user.posts)
# ✅ Database-level counting
user_post_count =
from p in Post,
where: p.user_id == ^user.id,
select: count(p.id)
|> Repo.one()
Real-World Example: Blog System
defmodule MyApp.Blog do
alias MyApp.{Repo, User, Post, Tag, Comment}
import Ecto.Query
def get_user_with_stats(user_id) do
from u in User,
where: u.id == ^user_id,
left_join: p in assoc(u, :posts),
left_join: c in assoc(p, :comments),
group_by: u.id,
select: %{
user: u,
post_count: count(p.id, :distinct),
comment_count: count(c.id)
}
|> Repo.one()
end
def get_post_with_everything(post_id) do
Post
|> where(id: ^post_id)
|> preload([
:user,
:tags,
comments: :user
])
|> Repo.one()
end
def get_popular_posts(limit \\ 10) do
from p in Post,
left_join: c in assoc(p, :comments),
group_by: p.id,
order_by: [desc: count(c.id)],
limit: ^limit,
preload: [:user, :tags],
select: p
|> Repo.all()
end
def create_post_with_tags(user, post_params, tag_names) do
tags = get_or_create_tags(tag_names)
user
|> Ecto.build_assoc(:posts)
|> Post.changeset(post_params)
|> Ecto.Changeset.put_assoc(:tags, tags)
|> Repo.insert()
end
defp get_or_create_tags(tag_names) do
existing_tags =
from t in Tag,
where: t.name in ^tag_names,
select: t
|> Repo.all()
existing_names = Enum.map(existing_tags, & &1.name)
new_names = tag_names -- existing_names
new_tags =
Enum.map(new_names, fn name ->
%Tag{name: name}
|> Repo.insert!()
end)
existing_tags ++ new_tags
end
end
Common Patterns and Pitfalls
Pattern: Soft Deletes with Associations
defmodule MyApp.Post do
schema "posts" do
field :deleted_at, :utc_datetime
has_many :comments, MyApp.Comment, where: [deleted_at: nil]
end
def published_posts(query \\ __MODULE__) do
from p in query,
where: is_nil(p.deleted_at) and not is_nil(p.published_at)
end
end
Pitfall: Forgetting Indexes
# Always index foreign keys
create index(:posts, [:user_id])
create index(:comments, [:post_id])
# Index frequently queried combinations
create index(:posts, [:user_id, :published_at])
create index(:posts, [:published_at, :inserted_at])
Pattern: Polymorphic Associations
defmodule MyApp.Comment do
schema "comments" do
field :body, :text
field :commentable_type, :string
field :commentable_id, :integer
belongs_to :user, MyApp.User
end
end
# Usage
def get_comments_for(commentable) do
type = commentable.__struct__ |> Module.split() |> List.last()
from c in Comment,
where: c.commentable_type == ^type and c.commentable_id == ^commentable.id,
preload: :user
|> Repo.all()
end
Testing Relationships
defmodule MyApp.BlogTest do
use MyApp.DataCase
alias MyApp.{User, Post, Tag}
test "creating a post with tags" do
user = insert(:user)
tag1 = insert(:tag, name: "elixir")
tag2 = insert(:tag, name: "phoenix")
{:ok, post} =
user
|> Ecto.build_assoc(:posts)
|> Post.changeset(%{title: "Test", body: "Content"})
|> Ecto.Changeset.put_assoc(:tags, [tag1, tag2])
|> Repo.insert()
post = Repo.preload(post, :tags)
assert length(post.tags) == 2
assert Enum.any?(post.tags, &(&1.name == "elixir"))
end
test "deleting user cascades to posts" do
user = insert(:user)
post = insert(:post, user: user)
Repo.delete!(user)
assert Repo.get(Post, post.id) == nil
end
end
Understanding these relationship patterns will make your Phoenix applications more robust and maintainable. The key is being explicit about your data needs and leveraging Ecto’s composable query system.
Next: Dive into real-time communication with Phoenix WebSockets and Channels.