Subscribe to access all episodes. View plans →
Published September 1, 2025
Phoenix 1.8
Ecto belongs_to
Follow along with the episode starter on GitHub
Here in our music app, we have an “Artist” column and if we go to edit an album, we can see in the form that it’s a string field, which might seem fine at first, but comes with some limitations. For example, it’s easy to misspell the artist name on different albums or what if we want to show all albums by a specific artist? That becomes a bit more difficult when these are just strings.
In this episode, we’ll refactor the string artist
field on albums
into a proper Artist
schema and link albums to artists using database associations. We’ll model a one-to-many relationship: an artist can have many albums, and each album belongs to exactly one artist. We’ll add a belongs_to :artist
association and artist_id
foreign key to the Album
schema, and a matching has_many :albums
to the Artist
schema. Finally, we’ll update the album form to work with the new association so you can select an artist when creating or editing an album, and that relationship will be persisted in the database.
Alright, let’s get started by creating our Artist schema. We’ll go to the command line and use mix phx.gen.schema
to generate an ecto schema named Artist
with a database table name artists
and we’ll give it a single field of name.
$ mix phx.gen.schema Recordings.Artist artists name:string
...
Our schema and migration were created - let’s open the migration. This looks good, but let’s add a unique_index
to our table to prevent artists with the same name from being created.
# priv/repo/migrations/{timestamp}_create_artists.exs
defmodule Teacher.Repo.Migrations.CreateArtists do
use Ecto.Migration
def change do
create table(:artists) do
add :name, :string
timestamps(type: :utc_datetime)
end
create unique_index(:artists, [:name])
end
end
With that let’s open our Artist
module and update the schema so it has_many :albums
.
We’ll also update our changeset to include the unique_constraint
function - this will catch the database error, if there’s a artist with a duplicate name added - and add a user friendly error message for us.
# lib/teacher/recordings/artist.ex
defmodule Teacher.Recordings.Artist do
use Ecto.Schema
import Ecto.Changeset
schema "artists" do
field :name, :string
has_many :albums, Teacher.Recordings.Album
timestamps(type: :utc_datetime)
end
@doc false
# Validates artist data ensuring name is present and unique
def changeset(artist, attrs) do
artist
|> cast(attrs, [:name])
|> validate_required([:name])
|> unique_constraint(:name)
end
end
With our Artist schema added, we need to modify our existing albums table to link to artists instead of storing the artist name as text. To do that let’s generate an empty migration.
$ mix ecto.gen.migration add_artist_id_to_albums
...
Because this migration file is empty, we need to fill it in ourselves. We’re going to do two things. First, let’s update our “albums” table with a new “artist_id” column. We’ll use the Ecto references
function to define a foreign key - linking our “artist_id” column to the “artists” table. We’ll also add the on_delete: :restrict
option to prevent us from deleting an artist that still has associated albums. With these changes we won’t need the old “artist” field, so let’s remove it. And let’s add a database index on artist_id
so that queries filtering or joining on this column, like finding all albums by an artist, will be faster.
# priv/repo/migrations/{timestamp}_add_artist_id_to_albums.exs
defmodule Teacher.Repo.Migrations.AddArtistIdToAlbums do
use Ecto.Migration
def change do
alter table(:albums) do
add :artist_id, references(:artists, on_delete: :restrict)
remove :artist
end
create index(:albums, [:artist_id])
end
end
Now let’s update our Album schema to work with this new relationship. We can remove our old “artist” string field and then let’s update it to include the belongs_to :artist
association.
In the changeset, we’re now working with an artist_id
field so let’s add that and then let’s add a foreign_key_constraint
with the artist_id
. This ensures that when someone tries to create an album, the artist_id they provide actually exists in our artists table. Then we can remove the artist
field.
# lib/teacher/recordings/album.ex
defmodule Teacher.Recordings.Album do
...
schema "albums" do
...
belongs_to :artist, Teacher.Recordings.Artist
...
end
...
def changeset(album, attrs) do
album
|> cast(attrs, [:summary, :title, :year, :artist_id])
|> validate_required([:summary, :title, :year, :artist_id])
|> foreign_key_constraint(:artist_id)
end
end
With that we can run our migrations to update the database.
$ mix ecto.migrate
...
Now we need to update our application code to actually use these new associations. Let’s open up our Recordings context module - this is where we handle all the business logic for our albums and artists. And we’ll add an alias for our Artist
schema.
In our list_albums
function we’re grabbing all the albums from our database. Let’s update this to preload the “artist” association, so when we work with each album, its associated artist is already loaded.
Then let’s go to the get_album
function and update it to preload the “artist” there too and in the create_album
function. Let’s pattern match on a successfully created album and preload the “artist”. Now that we have our “album”-specific functions updated, let’s add a function to create and artist and then we’ll need a function to return all of our artists so let’s create one named list_artists
.
# lib/teacher/recordings.ex
defmodule Teacher.Recordings do
...
alias Teacher.Recordings.{Artist, Album}
...
def list_albums do
Album
|> order_by([album], desc: album.id)
|> preload(:artist)
|> Repo.all()
end
...
def get_album!(id) do
Album
|> preload(:artist)
|> Repo.get!(id)
end
...
def create_album(attrs) do
%Album{}
|> Album.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, album} -> {:ok, Repo.preload(album, :artist)}
error -> error
end
end
...
def create_artist(attrs) do
%Artist{}
|> Artist.changeset(attrs)
|> Repo.insert()
end
def list_artists do
Artist
|> order_by(:name)
|> Repo.all()
end
...
end
Now that we’ve updated the Recordings context to use our new Artist schema, we need to update our album LiveViews. Let’s start with AlbumLive.Form
- we’ll update this to display a dropdown to select an artist, rather than a text input.
We’ll update the field from “artist” to “artist_id” then we’ll change the “type” to select. For the dropdown options we’ll call a function we need to add named artist_options
that takes a list of artists and then we’ll give it a prompt - “Select an artist”. Now let’s add the private artist_options
function.
This will transform our list of artist structs into tuples that the select input understands - it shows the artist name to the user, but sends the artist ID when the form is submitted.
To display our artists we’ll want to update our mount
callback to assign them to the socket and to get the from the database we’ll use the Recordings.list_artists
function we wrote.
# lib/teacher_web/live/album_live/form.ex
...
<.input
field={@form[:artist_id]}
type="select"
label="Artist"
options={artist_options(@artists)}
prompt="Select an artist"
/>
...
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:artists, Recordings.list_artists())
|> apply_action(socket.assigns.live_action, params)}
end
defp artist_options(artists) do
Enum.map(artists, &{&1.name, &1.id})
end
...
Then let’s open the AlbumLive.Index module. Let’s also update our album list page to show the artist name. Here I’m using &&
- so it only tries to access album.artist.name
if album.artist exists
. This prevents errors if an album doesn’t have an artist set yet.
# lib/teacher_web/live/album_live/index.ex
...
<:col :let={{_id, album}} label="Artist">
{album.artist && album.artist.name}
</:col>
...
We’ll also need to update the album show page.
# lib/teacher_web/live/album_live/show.ex
...
{@album.title} by {(@album.artist && @album.artist.name)}
...
Now that we’ve moved our Artist field to its own table and removed it from the album, we need to populate the database with some artists. Let’s create a script to populate it with some sample data, but first, let’s tell our formatter about this new script file. We’ll call it populate_artists.exs
.
...
inputs: [
...
"priv/*/populate_artists.exs"
]
...
Then let’s add our script and I’ll go ahead and paste in the code we’ll need. It pasted over with some weird formatting, but let’s go over the code before we fix that
Here we’re first adding an alias for our Teacher.Recordings
module. Then we have a list of artist names. After that we’re printing a message with how many artists we’re creating. Here we loop through them using Enum.each, creating each one in our database. The IO.puts
statements give us some feedback so we can see what’s happening as the script runs. Once we’re done we’re adding another message that our artist creation is complete!
Because we added this script to our formatter, all we need to do to fix the formatting is to save our file - and it’s formatted!
# priv/repo/populate_artists.exs
alias Teacher.Recordings
# Create sample artists
artists_data = [
"Bob Dylan",
"The Doors",
"The Jimi Hendrix Experience",
"Bruce Springsteen",
"The Beatles",
"The Who",
"Led Zeppelin",
"Miles Davis",
"Pink Floyd",
"Queen"
]
IO.puts("Creating #{length(artists_data)} artists...")
Enum.each(artists_data, fn name ->
case Recordings.create_artist(%{name: name}) do
{:ok, artist} ->
IO.puts("Created artist: #{artist.name}")
end
end)
IO.puts("\nArtist creation complete!")
Great, now let’s go to the command line and run our script.
$ mix run priv/repo/populate_artists.exs
...
Artist creation complete!
Then let’s check it out in the browser. We’ll start it up with mix phx.server
.
$ mix phx.server
...
When we go to our browser we see that none of our albums have an “Artist” so let’s edit an album. Great - we see our artist dropdown is displayed. So let’s select an artist. Perfect! It’s saved and displayed correctly along with the album.
We can now update existing albums with an associated artist. Let’s also try to create a new album to ensure it’s working as well. we see our artist dropdown and if we fill out some details for an album and then save it our new album was create successfully and is displayed!
With this refactor our codebase is in a much better place. “artist” is now a proper database relationship and not a string field and we no longer have to worry about duplicate or misspelled artists.