Add Tags with Ecto has_many, through in Phoenix - Tagging part 1

This post was updated 01 May - 2020

has_many phoenix ecto belongs_to

I want to add tags to products. And as usual there are a situation where a product can have many tags and a tag can belong to many products.

However, in my opinion, the best option is to use a has_many :through relationship. So for this, I need a table called tags and a join table called taggings.

In this tutorial, I will go through how would do this.

STEP 1 - Generate the code

So, lets generate these with these commands:

mix phx.gen.context Taggable Tag tags name
mix phx.gen.context Taggable Tagging taggings tag_id:references:tags product_id:references:products

Since I use the same context for these, I will get asked if I really want to use the same context. And in this case, this is exactly what I want. I will delete most of the boilerplate code anyway. So I will answer Y here:

Would you like to proceed? [Yn] Y

With the code is created, the first thing I always to is to edit the migration files. Lets take them in order. Add null: false and create unique_index(:tags, [:name]).

# priv/repo/migrations/20200211160754_create_tags.exs

defmodule Tutorial.Repo.Migrations.CreateTags do
  use Ecto.Migration

  def change do
    create table(:tags) do
      add :name, :string, null: false


    create unique_index(:tags, [:name])

And the same thing in the other migration. But also, make sure to change to ondelete: :deleteall so we dont get orphan records in the database.

# priv/repo/migrations/20200211160936_create_taggings.exs

defmodule Tutorial.Repo.Migrations.CreateTaggings do
  use Ecto.Migration

  def change do
    create table(:taggings) do
      add :tag_id, references(:tags, on_delete: :delete_all)
      add :product_id, references(:products, on_delete: :delete_all)


    create index(:taggings, [:tag_id])
    create index(:taggings, [:product_id])
    create unique_index(:taggings, [:tag_id, :product_id])

When the migrations are edited I can run:

mix ecto.migrate

STEP 2 - Setup the relations

So, to iterate, we created a module called Tag that has many :products through the module Tagging. So Tagging belongs to both Tag and Product.

Lets set that up:

# lib/tutorial/taggable/tagging.ex

defmodule Tutorial.Taggable.Tagging do
  use Ecto.Schema
  import Ecto.Changeset

  schema "taggings" do
    belongs_to :tag, Tutorial.Taggable.Tag
    belongs_to :product, Tutorial.Products.Product


  @doc false
  def changeset(tagging, attrs) do
    |> cast(attrs, [])
    |> unique_constraint(:name, name: :taggings_tag_id_product_id_index)
    |> cast_assoc(:tag)

Note that we have the unique constraint setup and also cast_assoc(:tag)

Next step is to setup the has_many :through in the Tag module.

Pay attention to that this is how we set that up:

has_many :taggings, Tutorial.Taggable.Tagging
has_many :products, through: [:taggings, :post]

So the entire module look like:

# lib/tutorial/taggable/tag.ex

defmodule Tutorial.Taggable.Tag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tags" do
    field :name, :string

    has_many :taggings, Tutorial.Taggable.Tagging
    has_many :products, through: [:taggings, :post]


  @doc false
  def changeset(tag, attrs) do
    |> cast(attrs, [:name])
    |> validate_required([:name])
    |> unique_constraint(:name, name: :tags_name_index)

We also want this to be reflected in the Product. Add this inside the schema definition:

# lib/tutorial/products/product.ex

  schema "products" do
    has_many :tags, through: [:taggings, :tag]


The last step of the code changes is to basically delete everything in the Taggable context and just have 3 functions. listtags/0, tagproduct/2 and deletetagfrom_product/2:

# lib/tutorial/taggable.ex

defmodule Tutorial.Taggable do
  @moduledoc """
  The Taggable context.

  import Ecto.Query, warn: false
  alias Tutorial.Repo

  alias Tutorial.Taggable.Tag
  alias Tutorial.Taggable.Tagging

  def list_tags do

  def tag_product(product, %{tag: tag_attrs} = attrs) do
    tag = create_or_find_tag(tag_attrs)

    |> Ecto.build_assoc(:taggings)
    |> Tagging.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:tag, tag)
    |> Repo.insert()

  defp create_or_find_tag(%{name: "" <> name} = attrs) do
    |> Tag.changeset(attrs)
    |> Repo.insert()
    |> case do
      {:ok, tag} -> tag
      _ -> Repo.get_by(Tag, name: name)
  defp create_or_find_tag(_), do: nil

  def delete_tag_from_product(product, tag) do
    Repo.find_by(Tagging, product_id: product.id, tag_id: tag.id)
    |> case do
      %Tagging{} = tagging -> Repo.delete(tagging)
      nil -> {:ok, %Tagging{}}

STEP 3 - Test the functionality in IEX

Now we are ready to test this in a the console. Open IEX:

iex -S mix

And for convenience att some aliases:

alias Tutorial.Repo
alias Tutorial.Products
alias Tutorial.Taggable

Start with getting one of the products. I already have hundred products generated through the seeds file. Assign one of them to product

product = Products.get_product!(100)

Then create a tag for that product:

Taggable.tag_product(product, %{tag: %{name: "Stout"}})

If we did everything correct, we should see it saved like this:

And I can see that its saved correctly if I run:

product |> Repo.preload(:tags)

Next thing to dry is the delete the product. For that, I need both the tag and the product. Grab the tag we just created by:

%{tags: [tag]} = product |> Repo.preload(:tags)

And the delete it by:

Taggable.delete_tag_from_product(product, tag)

And again, the correct outcome is:


When this is working, the next part of this tutorial is to make a dynamic interface with LiveView so we can add tags to products.

Related Tutorials

Published 03 Feb - 2020
Updated 01 May - 2020

Setup a has_many / belongs_to in Phoenix

Something I do in EVERY project is to setup some sort of relation between resources. And even though Phoenix comes with generators for migrations an..

Published 06 May - 2022

Teams Feature with Phx.Gen.Auth

A very common feature in web applications, especially SAAS applications are the concept of teams where a user can have and belong to multiple teams...