View on Github

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

This post was updated 01 May - 2020


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 on_delete: :delete_all 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)

Nest 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. list_tags/0, tag_product/2 and delete_tag_from_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.

Phoenix Bolerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Subscribe for $39/mo to geat ahead!

Learn More

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

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...