Many-to-Many Relationships in Ruby on Rails: Complete Guide

Many-to-Many Relationships in Rails – Complete Guide with Examples

πŸ”— Many-to-Many Relationships in Ruby on Rails

🧾 Detailed Explanation – Many-to-Many Relationships

A many-to-many relationship in Rails means that:

  • One record in a table can be linked to many records in another table.
  • And vice versa – both sides have multiple connections.

For example:

  • A Student can enroll in many Courses.
  • Each Course can have many Students.

Rails handles this using a third table (called a join table) that connects the two models. This is done using the has_many :through or has_and_belongs_to_many associations.

Why use a join table?

  • It keeps your data clean and organized.
  • You can add extra details like enrolled_on date or grade in the join table.

There are two main ways to create many-to-many relationships in Rails:

  1. has_many :through – Recommended, allows custom logic and fields.
  2. has_and_belongs_to_many (HABTM) – Simpler, but limited (no validations or extra fields).

βœ… Use has_many :through when you need full control over the relationship.

πŸ”€ Different Ways to Set Up Many-to-Many Relationships in Rails

1. βœ… has_many :through (Recommended)

This is the most flexible and powerful way to create a many-to-many relationship.

  • You create a separate join model (e.g., Enrollment).
  • You can add extra columns like grade, joined_on, etc.
  • You can add validations, scopes, and custom methods.
class Student < ApplicationRecord
    has_many :enrollments
    has_many :courses, through: :enrollments
  end
  
  class Course < ApplicationRecord
    has_many :enrollments
    has_many :students, through: :enrollments
  end
  
  class Enrollment < ApplicationRecord
    belongs_to :student
    belongs_to :course
  end

2. ⚑ has_and_belongs_to_many (Shortcut)

This is a simpler and older method that skips the join model.

  • Good for quick associations when no extra data or logic is needed.
  • Cannot add validations or methods to the join.
  • You must manually create a join table.
# Migration
  create_table :courses_students, id: false do |t|
    t.belongs_to :student
    t.belongs_to :course
  end
  
  # Models
  class Student < ApplicationRecord
    has_and_belongs_to_many :courses
  end
  
  class Course < ApplicationRecord
    has_and_belongs_to_many :students
  end

3. πŸ§ͺ Serialized Array (Not Recommended for Associations)

Store IDs as arrays inside a column using serialize or JSON.

  • Fast to implement but hard to query.
  • Breaks relational database features like joins and constraints.
  • Best used for storing tags or settings, not full model associations.
class Product < ApplicationRecord
    serialize :category_ids, Array
  end

4. 🧰 PostgreSQL Array or JSON Column

Store a list of IDs using PostgreSQL’s native array or jsonb types.

  • Faster and more flexible than serialized strings.
  • Still not ideal for real associations that need validations or joins.
add_column :products, :tag_ids, :integer, array: true, default: []

πŸš€ Best Practice

Always use has_many :through when:

  • You want clean, scalable, and maintainable code.
  • You need to track extra info in the relationship.
  • You want to use scopes, validations, or callbacks on the join model.

πŸ“˜ Key Terms and Concepts – Many-to-Many in Rails

TermDescription
has_manyAssociates a model with multiple records of another model.
has_many :throughDefines a many-to-many association with a join model that can include additional attributes and logic.
has_and_belongs_to_many (HABTM)Simplified many-to-many association without a join model, less flexible.
Join TableA separate table that connects two models in a many-to-many relationship (e.g., enrollments).
Join ModelA model (with its own logic/validations) that maps to the join table, used in has_many :through.
Foreign KeyA column in a table that links to the id of another table (e.g., student_id).
Nested AttributesAllows you to save attributes on associated records through the parent model.
ValidationEnsures data in the join model (like Enrollment) meets specific conditions before saving.
Eager LoadingImproves performance by loading associations in advance using .includes.
Polymorphic AssociationAllows a model to belong to more than one other model using a single association (not common for many-to-many but related conceptually).

πŸ”„ Flow of Many-to-Many Relationship in Rails

Let’s walk through the step-by-step flow of how a many-to-many relationship works in Rails using has_many :through:

πŸ“Œ Flow Process:

  1. Define two main models that will be connected (e.g., Student and Course).
  2. Create a join model that connects them (e.g., Enrollment with student_id and course_id).
  3. Set up associations in all three models:
    • Student has_many :courses through :enrollments
    • Course has_many :students through :enrollments
    • Enrollment belongs_to both :student and :course
  4. Use Rails helpers like student.courses << course to create relationships.
  5. Optionally add fields like grade or enrolled_on to the join model.
  6. Query data from either side using simple ActiveRecord associations.

πŸ›  Where We Use Many-to-Many

This relationship is commonly used in situations where both sides can have multiple associations:

  • πŸ“š Education: Students & Courses (via Enrollments)
  • πŸ‘¨β€πŸ’Ό Roles & Permissions: Users & Roles (via UserRoles)
  • πŸ“° Content Tagging: Posts & Tags (via Taggings)
  • 🎬 Entertainment: Actors & Movies (via Castings)
  • πŸ§‘β€πŸ”¬ Research: Authors & Publications (via Authorships)
  • 🏒 Project Management: Developers & Projects (via Assignments)
  • πŸ› E-commerce: Products & Categories (via Categorizations)
  • πŸŽ“ Certifications: Users & Badges (via Achievements)
  • πŸ’¬ Messaging: Users & Chat Groups (via Memberships)
  • πŸ‘¨β€πŸ« Teaching: Teachers & Classes (via Teachings)

πŸ’‘ Summary: Use a many-to-many setup when both models need to reference each other with flexibility, especially when extra data or logic is needed in the connection.

πŸ’Ž Useful Gems and Libraries for Many-to-Many in Rails

While Rails has built-in support for many-to-many relationships using has_many :through and has_and_belongs_to_many, the following gems can enhance or simplify your experience when working with complex associations:

Gem / LibraryPurpose / Description
cocoonAdds nested form support for dynamic add/remove of many-to-many join records in forms.
rails_adminAdmin panel generator that auto-detects many-to-many relationships and provides UI to manage them.
simple_formMakes it easier to build complex forms with associated fields for join models.
formtasticAnother advanced form builder that handles nested many-to-many associations cleanly.
annotateAdds schema comments to your models, which helps visualize relationships quickly, including join tables.
factory_bot_railsUsed in tests to easily create many-to-many relationships with factories.
acts_as_taggable_onProvides ready-to-use tagging system using many-to-many under the hood (good real-world reference).

πŸ’‘ Tip: You don’t need any gem to set up many-to-many relationships – Rails supports this out of the box. But these tools make development, testing, and UI much smoother.

πŸ— Best Implementations for All Many-to-Many Types

βœ… 1. has_many :through – Best Practice

This is the most robust and recommended way when you need additional data or logic in the join model.

Use Case: Students enrolling in Courses with a grade.

# Terminal
  rails g model Student name:string
  rails g model Course name:string
  rails g model Enrollment student:references course:references grade:string
  rails db:migrate
    
# app/models/student.rb
  class Student < ApplicationRecord
    has_many :enrollments
    has_many :courses, through: :enrollments
  end
  
  # app/models/course.rb
  class Course < ApplicationRecord
    has_many :enrollments
    has_many :students, through: :enrollments
  end
  
  # app/models/enrollment.rb
  class Enrollment < ApplicationRecord
    belongs_to :student
    belongs_to :course
    validates :grade, presence: true
  end
    

Usage:


  student = Student.create(name: "Ali")
  course = Course.create(name: "Ruby 101")
  Enrollment.create(student: student, course: course, grade: "A")

⚑ 2. has_and_belongs_to_many – Quick & Simple

Use Case: Posts with Tags (no extra logic needed).

# Migration (no model needed)
  create_table :posts_tags, id: false do |t|
    t.belongs_to :post
    t.belongs_to :tag
  end
    
# app/models/post.rb
  class Post < ApplicationRecord
    has_and_belongs_to_many :tags
  end
  
  # app/models/tag.rb
  class Tag < ApplicationRecord
    has_and_belongs_to_many :posts
  end
    

Usage:


  post = Post.create(title: "Rails Tutorial")
  tag = Tag.create(name: "Ruby")
  post.tags << tag

Limitations: No validations, scopes, or timestamps on the join.

πŸ§ͺ 3. Serialized Array Field – Fast but Not Relational

Use Case: Storing internal category IDs without complex querying.

# Migration
  add_column :products, :category_ids, :text
  
  # app/models/product.rb
  class Product < ApplicationRecord
    serialize :category_ids, Array
  end
    

Usage:


  product = Product.create(name: "Chair", category_ids: [1, 2, 3])
  product.category_ids.push(4)
  product.save

Limitations: Cannot join, index, or validate IDs. Not scalable.

🧰 4. PostgreSQL Native Arrays – Structured but Limited

Use Case: Tags stored as PostgreSQL integer arrays.

# Migration (PostgreSQL only)
  add_column :articles, :tag_ids, :integer, array: true, default: []
  
  # app/models/article.rb
  class Article < ApplicationRecord
    # No special serialization needed
  end
    

Usage:


  article = Article.create(title: "Intro to GraphQL", tag_ids: [2, 3])
  article.tag_ids << 4
  article.save

Limitations: Great for analytics, but no join table = no validation or Rails associations.


πŸ” Summary:

  • Use has_many :through – If you need data like timestamps, validations, logic.
  • Use HABTM – If you need quick many-to-many with no extra fields.
  • Use serialize or array types – Only for internal usage, not for full relationships.

πŸ“¦ 10 Real-World Many-to-Many Examples with Duplicate Prevention

Each example shows how to implement a many-to-many relationship in Rails using has_many :through with a validation to prevent duplicate records in the join table.

  1. Students ↔ Courses (via Enrollments)
    A student should not be enrolled in the same course twice.
    
      class Enrollment < ApplicationRecord
        belongs_to :student
        belongs_to :course
        validates :student_id, uniqueness: { scope: :course_id }
      end
            
  2. Users ↔ Roles (via UserRoles)
    A user should not have the same role assigned multiple times.
    
      class UserRole < ApplicationRecord
        belongs_to :user
        belongs_to :role
        validates :user_id, uniqueness: { scope: :role_id }
      end
            
  3. Posts ↔ Tags (via Taggings)
    A post shouldn’t have the same tag added twice.
    
      class Tagging < ApplicationRecord
        belongs_to :post
        belongs_to :tag
        validates :post_id, uniqueness: { scope: :tag_id }
      end
            
  4. Books ↔ Authors (via Authorships)
    A book should not be authored by the same person twice.
    
      class Authorship < ApplicationRecord
        belongs_to :book
        belongs_to :author
        validates :book_id, uniqueness: { scope: :author_id }
      end
            
  5. Doctors ↔ Patients (via Appointments)
    A doctor should not have duplicate appointments for the same patient at the same time.
    
      class Appointment < ApplicationRecord
        belongs_to :doctor
        belongs_to :patient
        validates :doctor_id, uniqueness: { scope: [:patient_id, :scheduled_at] }
      end
            
  6. Projects ↔ Developers (via Assignments)
    A developer should not be assigned to the same project more than once.
    
      class Assignment < ApplicationRecord
        belongs_to :project
        belongs_to :developer
        validates :developer_id, uniqueness: { scope: :project_id }
      end
            
  7. Products ↔ Categories (via Categorizations)
    A product should not be linked to the same category more than once.
    
      class Categorization < ApplicationRecord
        belongs_to :product
        belongs_to :category
        validates :product_id, uniqueness: { scope: :category_id }
      end
            
  8. Actors ↔ Movies (via Castings)
    An actor should not be cast multiple times in the same movie.
    
      class Casting < ApplicationRecord
        belongs_to :actor
        belongs_to :movie
        validates :actor_id, uniqueness: { scope: :movie_id }
      end
            
  9. Teachers ↔ Classes (via Teachings)
    A teacher should not teach the same class multiple times.
    
      class Teaching < ApplicationRecord
        belongs_to :teacher
        belongs_to :klass
        validates :teacher_id, uniqueness: { scope: :klass_id }
      end
            
  10. Users ↔ Chat Groups (via Memberships)
    A user should not be added to the same chat group more than once.
    
      class Membership < ApplicationRecord
        belongs_to :user
        belongs_to :chat_group
        validates :user_id, uniqueness: { scope: :chat_group_id }
      end
            

βœ… Tip: Always add a unique index in the database to ensure true safety:


  add_index :enrollments, [:student_id, :course_id], unique: true
    

πŸ§ͺ Technical Questions & Answers – Many-to-Many in Rails

  1. Q1: What is the difference between has_many :through and has_and_belongs_to_many?
    A:
    • has_many :through uses a join model (with its own validations, timestamps, and methods).
    • has_and_belongs_to_many is a simpler shortcut without a join model – limited flexibility.
    
      # has_many :through
      has_many :enrollments
      has_many :courses, through: :enrollments
      
      # HABTM
      has_and_belongs_to_many :courses
            
  2. Q2: How do you prevent duplicate records in a join table?
    A: Add a model-level uniqueness validation and a DB index.
    
      class Enrollment < ApplicationRecord
        validates :student_id, uniqueness: { scope: :course_id }
      end
      
      # DB migration
      add_index :enrollments, [:student_id, :course_id], unique: true
            
  3. Q3: How do you add extra attributes (e.g. grade or date) in a many-to-many relationship?
    A: Use has_many :through with a join model and add fields to that model.
    
      rails g model Enrollment student:references course:references grade:string
            
  4. Q4: Can you validate fields inside the join model?
    A: Yes. You can treat the join model like any normal model.
    
      class Enrollment < ApplicationRecord
        validates :grade, presence: true
      end
            
  5. Q5: How do you fetch all associated records from a model?
    A: Use the association method.
    
      student.courses
      course.students
            
  6. Q6: How do you associate a new course to a student?
    A: Use << or create the join record directly.
    
      student.courses << Course.find(3)
      # or
      Enrollment.create(student_id: 1, course_id: 3)
            
  7. Q7: How do you delete a relationship without deleting the main records?
    A: Use .delete on the association.
    
      student.courses.delete(course)
            
  8. Q8: How do you ensure that deleting a student also removes enrollments?
    A: Add dependent: :destroy to the parent model.
    
      has_many :enrollments, dependent: :destroy
            
  9. Q9: How do you eager load many-to-many associations to avoid N+1 queries?
    A: Use .includes in your query.
    
      Student.includes(:courses).each do |s|
        puts s.courses.map(&:name)
      end
            
  10. Q10: What if you want to scope a many-to-many association?
    A: You can define custom associations or use a lambda.
    
      has_many :passed_courses, -> { where("grade = 'A'") }, through: :enrollments, source: :course
            

βœ… Best Practices for Many-to-Many Relationships in Rails

1. Prefer has_many :through over has_and_belongs_to_many

Why: More control, supports validations, timestamps, scopes, and additional fields.

# Good
  has_many :enrollments
  has_many :courses, through: :enrollments
  
  # Avoid unless extremely simple
  has_and_belongs_to_many :courses

2. Add validates_uniqueness_of to prevent duplicates

Always validate uniqueness in the join model to avoid multiple records for the same pair.


  class Enrollment < ApplicationRecord
    validates :student_id, uniqueness: { scope: :course_id }
  end
    

3. Add unique DB index on join table

Why: Model validations are not enoughβ€”ensure data integrity at the DB level.


  add_index :enrollments, [:student_id, :course_id], unique: true
    

4. Use dependent: :destroy to clean up join records

Prevent orphaned records when the parent record is deleted.


  class Student < ApplicationRecord
    has_many :enrollments, dependent: :destroy
  end
    

5. Avoid business logic in models you’re connecting

Keep logic related to the relationship (e.g., status, progress) inside the join model.


  # Good
  class Enrollment < ApplicationRecord
    def active?
      enrolled_on > 30.days.ago
    end
  end
    

6. Use nested attributes only when necessary

Why: Avoid over-complicating forms. Use accepts_nested_attributes_for only if you need it.


  class Student < ApplicationRecord
    accepts_nested_attributes_for :enrollments
  end
    

7. Scope your join models for easier querying


  class Enrollment < ApplicationRecord
    scope :active, -> { where(status: "active") }
  end
    

8. Use .includes to avoid N+1 queries

Why: Speeds up queries involving multiple records and associations.


  Student.includes(:courses).each do |s|
    puts s.courses.map(&:name)
  end
    

9. Name your join models clearly

Good: Enrollment, Membership | Avoid: StudentCourse unless generic.

10. Seed unique records in development

When seeding data, ensure you don’t create duplicates.


  Enrollment.find_or_create_by(student_id: sid, course_id: cid)
    

🧠 Tip: Many-to-many setups often grow complex. Keep relationships lean, use scopes and validations, and test them thoroughly.

🏒 Real-World Case Study

App: Online Course Platform

An online education app uses many-to-many relationships to manage course enrollments.

  • Each Student enrolls in multiple Courses.
  • Each Course has many Students.
  • Enrollment stores progress and grade metadata.
  • Admins can report on courses, average grades, and completions.

This pattern enables deep analytics and flexible growth as new data needs emerge.

Learn more aboutΒ One-to-Many Relationships

Scroll to Top