Subscribe to access all episodes. View plans →

#200: Using belongs_to Associations with Phoenix Forms

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.

© 2024 HEXMONSTER LLC