Rails has_many :through の使い方|多対多関連付け・中間テーブル

  • 作成日 2026.06.27
  • Ruby
Rails has_many :through の使い方|多対多関連付け・中間テーブル

Rails で「多対多の関係」を扱う時の主役が has_many :through:

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

class Group < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

ユーザーが複数のグループに所属し、グループにも複数ユーザーがいる、という典型的な多対多関係。

しかし、いざ使おうとすると:

  • has_and_belongs_to_many(HABTM)との違いは?
  • 中間テーブル(join table)はどう設計する?
  • source オプションは何を指定する?
  • 追加・削除のメソッドは?
  • includes でどう N+1 を防ぐ?
  • ネストした関連(has_many :through の連鎖)は可能?
  • ポリモーフィックと組み合わせられる?

など、覚えるべき作法と落とし穴が多数あります。

本記事では、has_many :through の使い方を、リファレンスとして実用的に整理します。基礎概念、マイグレーション、source/class_name/dependent オプション、追加・削除メソッド、Eager Loading、ネスト関連、ポリモーフィック、ありがちな間違い、FAQまで完全網羅。この1本でRails の多対多関連付けをマスターできます。


目次

結論:今すぐ使える3ステップ

時間がない方向けに、最短のセットアップ手順を示します。

ステップ1:3つのモデルを生成

bin/rails g model User name:string
bin/rails g model Group name:string
bin/rails g model Membership user:references group:references role:string
bin/rails db:migrate

ステップ2:モデル定義

# app/models/user.rb
class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :groups, through: :memberships
end

# app/models/group.rb
class Group < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :users, through: :memberships
end

# app/models/membership.rb
class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

ステップ3:使う

# グループに参加
user.groups << group

# グループの全ユーザー
group.users

# ユーザーが所属する全グループ
user.groups

# 関連の削除
user.groups.delete(group)

詳細は以下で解説します。


まず押さえる:多対多関係の基礎

1対多(has_many)

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
end

1人のユーザーが複数の投稿を持つ。投稿は1人のユーザーに属する。

多対多が必要なケース

  • User と Group: ユーザーは複数グループに所属、グループも複数ユーザー
  • Article と Tag: 記事は複数タグ付与、タグも複数記事に紐づく
  • Movie と Actor: 映画は複数俳優、俳優は複数映画に出演
  • User と Course: ユーザーは複数コース受講、コースは複数ユーザー履修

多対多の表現方法

方法説明推奨度
has_many :through中間モデルを明示推奨
has_and_belongs_to_many(HABTM)中間テーブルのみ(モデルなし)限定的

公式ドキュメントもRails コミュニティも has_many :through 推奨


has_many :through vs has_and_belongs_to_many

has_and_belongs_to_many(HABTM)

class User < ApplicationRecord
  has_and_belongs_to_many :groups
end

class Group < ApplicationRecord
  has_and_belongs_to_many :users
end

中間テーブル groups_users(アルファベット順)が必要:

class CreateGroupsUsersJoinTable < ActiveRecord::Migration[8.0]
  def change
    create_join_table :groups, :users do |t|
      t.index [:user_id, :group_id]
    end
  end
end

has_many :through

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

class Group < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

中間モデル Membership が存在。

比較表

観点HABTMhas_many :through
中間モデルなしあり
中間テーブルあり(アルファベット順命名)あり(任意命名)
中間テーブルへの属性追加△(非推奨)✅ 可能
中間モデルのコールバックなし✅ 可能
中間モデルのバリデーションなし✅ 可能
柔軟性
複雑なクエリ困難容易

使い分け

  • シンプルな多対多 → HABTM でも可
  • 中間テーブルに属性追加・バリデーション・コールバック → has_many :through
  • 将来の拡張可能性 → has_many :through(通常はこれ推奨

公式の推奨も has_many :through。本記事ではこちらを中心に解説。


マイグレーションの書き方

基本構成

bin/rails g model User name:string
bin/rails g model Group name:string description:text
bin/rails g model Membership user:references group:references

マイグレーションの中身

class CreateMemberships < ActiveRecord::Migration[8.0]
  def change
    create_table :memberships do |t|
      t.references :user, null: false, foreign_key: true
      t.references :group, null: false, foreign_key: true
      
      t.timestamps
    end
    
    # 同じユーザーが同じグループに二重登録を防ぐ
    add_index :memberships, [:user_id, :group_id], unique: true
  end
end

ポイント:

  • null: false: 関連がない状態を許さない
  • foreign_key: true: 外部キー制約(参照整合性)
  • unique index: 重複登録防止

属性を追加する場合

class CreateMemberships < ActiveRecord::Migration[8.0]
  def change
    create_table :memberships do |t|
      t.references :user, null: false, foreign_key: true
      t.references :group, null: false, foreign_key: true
      t.string :role, default: "member"     # 役割
      t.datetime :joined_at                  # 参加日時
      t.boolean :active, default: true       # アクティブか
      
      t.timestamps
    end
    
    add_index :memberships, [:user_id, :group_id], unique: true
  end
end

中間テーブルにビジネスロジックの属性を追加できるのが has_many :through の最大の利点。

詳細はrails db:migrate 使い方の記事


モデル定義の詳細

基本パターン

# app/models/user.rb
class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :groups, through: :memberships
end

# app/models/group.rb
class Group < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :users, through: :memberships
end

# app/models/membership.rb
class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
  
  validates :user_id, uniqueness: { scope: :group_id }
end

重要な順序

class User < ApplicationRecord
  # ❌ 順序が悪い(through先が未定義)
  has_many :groups, through: :memberships
  has_many :memberships
end

# ✅ 正しい順序
class User < ApplicationRecord
  has_many :memberships          # 先に中間関連
  has_many :groups, through: :memberships   # 後から through
end

通常は順序を気にしなくても動きますが、慣習として直接関連を先に書く。

dependent オプション

has_many :memberships, dependent: :destroy   # User削除時にMembershipも削除
has_many :memberships, dependent: :delete_all  # 同上(コールバックなし、高速)
has_many :memberships, dependent: :nullify   # user_id を null に
has_many :memberships, dependent: :restrict_with_exception  # 関連があれば削除不可

destroy_all / delete_all の違いはsave vs save! の記事参照。

バリデーション

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
  
  # 重複防止
  validates :user_id, uniqueness: { scope: :group_id, message: "は既にグループに所属しています" }
  
  # 上限チェック
  validate :max_groups_per_user
  
  private
  
  def max_groups_per_user
    if user && user.groups.count >= 10
      errors.add(:base, "最大10グループまでです")
    end
  end
end

基本的な使い方

関連レコードの追加

user = User.find(1)
group = Group.find(1)

# 方法1: << で追加(Membership 自動生成)
user.groups << group

# 方法2: create でMembership 経由
user.memberships.create(group: group)

# 方法3: 中間モデル経由
Membership.create(user: user, group: group)

# 中間テーブルに属性を設定する場合
user.memberships.create(group: group, role: "admin")

# 一括追加
user.groups << [group1, group2, group3]

関連レコードの取得

# user の所属グループ一覧
user.groups
# SELECT "groups".* FROM "groups"
# INNER JOIN "memberships" ON "groups"."id" = "memberships"."group_id"
# WHERE "memberships"."user_id" = 1

# group の所属ユーザー一覧
group.users

# 件数
user.groups.count

# 存在チェック
user.groups.include?(group)
user.groups.exists?(group.id)

関連レコードの削除

# 関連だけ削除(Membership だけ消える、Group は残る)
user.groups.delete(group)

# Membership と Group の関連を解除
user.memberships.find_by(group: group).destroy

# user削除(dependent: :destroy なら関連も自動削除)
user.destroy

中間レコードの直接操作

# Membership 経由でアクセス
membership = user.memberships.find_by(group: group)
membership.role = "admin"
membership.save

# 中間テーブルの全件取得
user.memberships

source オプション

source中間モデル内の関連名と最終モデル名が一致しない時に必要。

名前が一致するケース(source 不要)

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  # Membership に belongs_to :group があれば自動推論
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group  # ← 関連名が groups の単数形と一致
end

名前が違うケース(source 必要)

class User < ApplicationRecord
  has_many :memberships
  has_many :organizations, through: :memberships, source: :group
  # ↑ User.organizations は Membership.group を経由する
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

User の organizations メソッドが、Membership の group 関連を経由して Group レコードを取得。

別の例:友達関係

class User < ApplicationRecord
  has_many :friendships
  has_many :friends, through: :friendships, source: :friend_user
end

class Friendship < ApplicationRecord
  belongs_to :user
  belongs_to :friend_user, class_name: "User"
end

ユーザー同士の関連で、Friendship のカラム名(friend_user_id)を反映。

source_type と組み合わせ(ポリモーフィック)

後述のポリモーフィック has_many :through で使用。


class_name オプション

関連先モデル名がメソッド名から推論できない時:

class User < ApplicationRecord
  has_many :memberships
  has_many :administered_groups, 
           through: :memberships, 
           source: :group,
           class_name: "Group"   # 明示的にクラス名
end

例:自己参照の関連

class User < ApplicationRecord
  has_many :followings, foreign_key: :follower_id, class_name: "Following"
  has_many :followers, foreign_key: :following_id, class_name: "Following"
  
  has_many :followed_users, through: :followings, source: :followed
  has_many :follower_users, through: :followers, source: :follower
end

class Following < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
end

Twitter 風のフォロー機能の例。


scope と has_many :through

中間関連にスコープを当てて、目的別の関連を作る:

class Movie < ApplicationRecord
  has_many :movie_crews
  
  # 役割別のCrew取得
  has_many :actor_movie_crews, -> { where(role: :actor) }, class_name: "MovieCrew"
  has_many :actors, through: :actor_movie_crews, source: :crew
  
  has_many :director_movie_crews, -> { where(role: :director) }, class_name: "MovieCrew"
  has_many :directors, through: :director_movie_crews, source: :crew
end

class Crew < ApplicationRecord
  has_many :movie_crews
  has_many :movies, through: :movie_crews
end

class MovieCrew < ApplicationRecord
  belongs_to :movie
  belongs_to :crew
end
matrix = Movie.find_by(title: "Matrix")
matrix.actors      # 出演俳優のみ
matrix.directors   # 監督のみ

実用上非常に便利なパターン。


includes でN+1問題を解決

has_many :through は3つのテーブルを跨ぐため、N+1問題が深刻になりやすい。

N+1 が発生する例

# ❌ N+1
@groups = Group.all
@groups.each do |group|
  puts group.name
  puts group.users.count   # ← 各 group で N+1 クエリ
end

includes で解決

# ✅ Eager Loading
@groups = Group.includes(:users)
@groups.each do |group|
  puts group.name
  puts group.users.count   # 追加クエリなし
end

実際の SQL:

-- 1. groups 取得
SELECT * FROM groups;

-- 2. memberships を一括取得
SELECT * FROM memberships WHERE group_id IN (1, 2, 3, ...);

-- 3. users を一括取得
SELECT * FROM users WHERE id IN (...);

3クエリで完結。

中間モデル経由のincludes

# users + memberships 両方を読み込み
Group.includes(:memberships, :users)

# user → groups → posts のような階層
Group.includes(users: :posts)

詳細はincludes vs preload vs eager_load 違いの記事

references と組み合わせ

# 中間モデルの属性で絞り込み
Group.includes(:memberships, :users)
     .where(memberships: { role: "admin" })

memberships テーブルでフィルタリング。


ネストした has_many :through

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  has_many :events, through: :groups   # ← さらに先
end

class Group < ApplicationRecord
  has_many :events
end

class Event < ApplicationRecord
  belongs_to :group
end
user = User.find(1)
user.events
# SELECT "events".* FROM "events"
# INNER JOIN "groups" ON "events"."group_id" = "groups"."id"
# INNER JOIN "memberships" ON "groups"."id" = "memberships"."group_id"
# WHERE "memberships"."user_id" = 1

ユーザーが所属するグループのイベントを1つのプロパティで取得できる。

さらに深く

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  has_many :events, through: :groups
  has_many :attendees, through: :events   # 3階層
end

Rails は柔軟だが、深すぎる関連はパフォーマンスとデバッグの観点で要注意


ポリモーフィック has_many :through

class Tag < ApplicationRecord
  has_many :taggings
end

class Tagging < ApplicationRecord
  belongs_to :tag
  belongs_to :taggable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :taggings, as: :taggable
  has_many :tags, through: :taggings
end

class Article < ApplicationRecord
  has_many :taggings, as: :taggable
  has_many :tags, through: :taggings
end

PostArticle も同じ Tag システムを共有できる。

逆方向は source_type 必要

class Tag < ApplicationRecord
  has_many :taggings
  has_many :posts, through: :taggings, source: :taggable, source_type: "Post"
  has_many :articles, through: :taggings, source: :taggable, source_type: "Article"
end

source_type で「どの型のtaggableか」を指定。

tag = Tag.find(1)
tag.posts      # Postだけ取得
tag.articles   # Articleだけ取得

マイグレーション

class CreateTaggings < ActiveRecord::Migration[8.0]
  def change
    create_table :taggings do |t|
      t.references :tag, null: false, foreign_key: true
      t.references :taggable, polymorphic: true, null: false
      
      t.timestamps
    end
  end
end

polymorphic: truetaggable_id + taggable_type カラムが作成される。


accepts_nested_attributes_for との組み合わせ

フォームから中間レコードと一緒に保存:

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  
  accepts_nested_attributes_for :memberships, allow_destroy: true
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end
<%= form_with model: @user do |f| %>
  <%= f.fields_for :memberships do |m| %>
    <%= m.collection_select :group_id, Group.all, :id, :name %>
    <%= m.text_field :role %>
    <%= m.check_box :_destroy %>  <%# 削除フラグ %>
  <% end %>
<% end %>
# controller
def user_params
  params.require(:user).permit(
    :name,
    memberships_attributes: [:id, :group_id, :role, :_destroy]
  )
end

これでフォームから中間レコードを直接追加・更新・削除できる。

詳細はsave vs save! の記事も参照。


中間テーブルの属性を活用

属性で絞り込み

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  
  # アクティブなメンバーシップのみ
  has_many :active_memberships, -> { where(active: true) }, class_name: "Membership"
  has_many :active_groups, through: :active_memberships, source: :group
end

user.active_groups  # active = true の memberships を経由

ロール別

class User < ApplicationRecord
  has_many :memberships
  
  has_many :admin_memberships, -> { where(role: "admin") }, class_name: "Membership"
  has_many :admin_groups, through: :admin_memberships, source: :group
  
  has_many :member_memberships, -> { where(role: "member") }, class_name: "Membership"
  has_many :member_groups, through: :member_memberships, source: :group
end
user.admin_groups   # 管理者として所属しているグループ
user.member_groups  # 一般メンバーとして所属しているグループ

注意:joins が外れる場合

class Post < ApplicationRecord
  has_many :online_comments, 
           -> { joins(:source).where(sources: { online: true }) }, 
           class_name: "Comment"
  has_many :online_users, through: :online_comments, source: :user
end

⚠️ Rails のバグで through で経由する関連のスコープ内 joins が無視されることがあります。複雑な場合はテストで確実な動作確認を。


クエリのベストプラクティス

1. distinct で重複を防ぐ

# 複数 Membership がある場合、Group が重複する
Group.joins(:users).where(users: { active: true })

# ✅ distinct で重複を排除
Group.joins(:users).where(users: { active: true }).distinct

2. references の活用

# Hash 形式(references 不要)
Group.includes(:users).where(users: { active: true })

# 文字列形式(references 必要)
Group.includes(:users).where("users.active = true").references(:users)

3. 中間関連にも includes

# ❌ 中間モデル経由でアクセスする時に N+1
group.users.each { |u| puts u.memberships.first.role }

# ✅ 中間モデルも eager load
Group.includes(memberships: :user).find(group_id)

4. counter_cache 活用

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group, counter_cache: :memberships_count
end

group.memberships_count で件数を高速取得(COUNT クエリ不要)。

詳細はincludes vs preload vs eager_load の記事


ありがちな間違い

1. through 先のモデル定義漏れ

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  # belongs_to :group を書き忘れ
end

→ エラー:

ActiveRecord::HasManyThroughSourceAssociationNotFoundError

Membershipbelongs_to :group の追加忘れ。

2. source の指定漏れ

class User < ApplicationRecord
  has_many :memberships
  has_many :organizations, through: :memberships
  # ↑ Membership に :organizations はない
end

# => ActiveRecord::HasManyThroughSourceAssociationNotFoundError

→ source を追加:

has_many :organizations, through: :memberships, source: :group

3. dependent: :destroy 忘れ

class User < ApplicationRecord
  has_many :memberships   # ← dependent なし
  has_many :groups, through: :memberships
end

User.find(1).destroy
# Membership が残ったまま(孤児レコード)

dependent: :destroy を追加:

has_many :memberships, dependent: :destroy

4. unique index 漏れで重複登録

# 重複登録ができてしまう
user.groups << group
user.groups << group   # もう1度同じグループ

→ DBレベルで防ぐ:

add_index :memberships, [:user_id, :group_id], unique: true
  • モデルでも:
validates :user_id, uniqueness: { scope: :group_id }

5. N+1 問題

# ❌
Group.all.each { |g| g.users.count }

# ✅
Group.includes(:users).each { |g| g.users.count }

6. 中間テーブルの属性を取得し忘れ

user.groups.first.name   # OK
user.groups.first.role   # ❌ NoMethodError(Group には role がない)

# ✅ Membership 経由
user.memberships.first.role

7. has_many :through での save! 失敗

user.groups << group
# 失敗時のエラーが取りづらい

# ✅ Membership 経由で明示
membership = user.memberships.build(group: group, role: "admin")
membership.save!
# RecordInvalid 例外で詳細が分かる

詳細はsave vs save! の記事

8. transactional の漏れ

# ❌ トランザクション外
user.groups << group
user.update_attributes(name: "Updated")
# 片方失敗で整合性崩壊
# ✅
ActiveRecord::Base.transaction do
  user.groups << group
  user.update!(name: "Updated")
end

よくある質問(FAQ)

Q1. has_many :through と HABTM どちらを使うべき?

ほぼすべての場合 has_many :through 推奨。HABTM は:

  • 中間テーブルに属性が一切必要ない
  • 将来的にも拡張予定なし

の場合のみ検討。

Q2. 中間モデルの命名規則

パターン
単純結合Membership(User-Group)、Enrollment(User-Course)
関係名FriendshipFollowingSubscription
アルファベット結合GroupUser(推奨されない)

意味のある名前を付ける方が読みやすいコードに。

Q3. through の関連にスコープを当てたい

class User < ApplicationRecord
  has_many :active_memberships, -> { where(active: true) }, class_name: "Membership"
  has_many :active_groups, through: :active_memberships, source: :group
end

Q4. 多階層の has_many :through

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
  has_many :events, through: :groups
end

技術的には可能。ただしSQLが複雑になりがちなので、用途を見極めて。

Q5. 中間テーブルの id を取得

user.memberships.find_by(group: group).id

または:

Membership.find_by(user_id: user.id, group_id: group.id).id

Q6. ポリモーフィックと組み合わせ

class Tag < ApplicationRecord
  has_many :taggings
  has_many :posts, through: :taggings, source: :taggable, source_type: "Post"
end

source_type で型を指定。

Q7. counter_cache の使い方

class Membership < ApplicationRecord
  belongs_to :group, counter_cache: true
end

groups テーブルに memberships_count カラムを追加(マイグレーション必要):

add_column :groups, :memberships_count, :integer, default: 0

これで group.memberships_count が高速取得可能。

Q8. has_one :through も存在する?

存在します:

class User < ApplicationRecord
  has_one :profile
  has_one :avatar, through: :profile
end

1対1の中継関連の場合に使用。

Q9. through で重複データを許可する

# unique index を外す
remove_index :memberships, [:user_id, :group_id]

# モデルバリデーションも外す
class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
  # validates :user_id, uniqueness: ... を外す
end

例:同じユーザーが同じ役割で2度登録されることを許容したい時。

Q10. through 経由での << の挙動

user.groups << group
# 内部的に:
# Membership.create(user: user, group: group)

Membership が自動生成される。中間テーブルに属性が必要な場合は明示的に:

user.memberships.create(group: group, role: "admin")

Q11. dependent オプションの選択

オプション動作用途
:destroyコールバック付き削除標準的
:delete_all高速削除(コールバックなし)大量データ
:nullifyforeign key を null に関連は切るがレコード残す
:restrict_with_exception関連あれば削除不可安全策
:restrict_with_error同上、エラー追加フォーム連動

Q12. テスト

RSpec.describe User do
  it "groups association" do
    user = User.create!(name: "Alice")
    group1 = Group.create!(name: "Ruby")
    group2 = Group.create!(name: "Rails")
    
    user.groups << [group1, group2]
    
    expect(user.groups.count).to eq(2)
    expect(user.memberships.count).to eq(2)
  end
end

rails console 使い方の記事で手動確認も。


参考リンク・関連資料

Rails 公式

関連記事(本サイト)


まとめ

has_many :through は Rails の多対多関連付けの主役。要点を再整理します。

  • 基本構造: 3つのモデル(A、中間、B)と、A→中間、中間→Bの関連付け
  • HABTMより推奨: 中間モデルに属性・バリデーション・コールバックを持てる
  • source オプション: 中間モデル内の関連名と最終モデル名が違う時に必要
  • class_name オプション: モデル名がメソッド名から推論できない時
  • dependent: :destroy: 親削除時に中間レコードも削除
  • unique index: 重複登録防止(DB + モデル両方で)
  • includes でN+1解決: 必ず使う習慣を
  • distinct で重複排除: joins/includes で同じレコードが複数返る場合
  • ネスト関連: has_many :through を連鎖可能
  • ポリモーフィック: source_type と組み合わせ
  • accepts_nested_attributes_for: フォームからの中間レコード操作

使い分けの指針

状況推奨
単純な多対多has_many :through
中間テーブルに属性has_many :through(必須)
Tag のような汎用関連ポリモーフィック has_many :through
階層的な関連ネストした has_many :through

これらの知識は、Rails アプリの設計・データモデリング・パフォーマンス改善など、あらゆる場面で活用できます。本記事をブックマークしておけば、多対多関係を実装する時の指針として役立ちます。


本記事は2026年6月時点の情報をもとに、Ruby on Rails 7.x / 8.x での動作確認・公式ドキュメントに基づき作成しています。Rails のバージョンによって挙動が異なる場合があるため、最新の情報はRails公式ガイドもあわせてご確認ください。