Ruby on Rails

RailsのAssociationとは?6種類の関連付けと使い分け・実装手順

RailsのAssociation(アソシエーション、日本語版公式ガイドでは「関連付け」)は、モデル同士のつながりをクラス側で宣言する仕組みです。PostComment のように外部キーで結ばれたテーブルがあるとき、has_many :comments と1行書くだけで post.comments が使えるようになり、Comment.where(post_id: post.id) と毎回書く必要がなくなります。

ただし関連付けは「書けば動く」で終わる機能ではありません。親を消したとき子をどうするか、逆方向の参照でオブジェクトを二重に作らないか、件数表示でクエリが何本増えるか。この記事では6種類の関連付けを一覧で整理したうえで、実務で判断を迫られるオプションを Rails 8.1(2026年8月時点の最新は 8.1.3.1)の公式仕様に沿って解説します。

まとめ

  • Railsの関連付けは belongs_to / has_one / has_many / has_many :through / has_one :through / has_and_belongs_to_many の6種類。外部キーを持つ側が belongs_to です。
  • 多対多は原則 has_many :through を選びます。中間テーブルに属性・バリデーション・コールバックが必要になった時点で has_and_belongs_to_many は詰みます。
  • belongs_to は Rails 5.0 以降、関連レコードの存在を既定で検証します。任意にするには optional: true が必要です。
  • 親を消したときの子の扱いは dependent で決めます。:destroy:delete_all はコールバックの実行有無が違い、履歴を残したいなら :restrict_with_error を選びます。
  • 一覧表示が遅いときの原因はほぼN+1クエリです。preload は2クエリ、eager_load は LEFT OUTER JOIN の1クエリ、includes は条件次第でどちらにもなります。
  • 使われなくなった関連付けは Rails 8.1 の deprecated: true で参照を検知してから消せます。

関連付けが引き受ける処理とActive Recordが生やすメソッド

関連付けの実体は、モデルクラスへのメソッド定義です。has_many :comments の宣言に対して、Active Record は post.comments のほか post.comments.buildpost.comments.create といったコレクション操作のメソッド群を追加します。Active Record 自体の位置づけは Railsを支える設計パターンとしてのActive Recordで整理しています。

関連付けを書かない場合との差分

関連付けを宣言しないと、テーブルの結び付きはコードのあちこちに散ります。記事に紐づくコメントを取り、さらにその投稿者を取る処理は、外部キーを手で追いかける形になります。

# 関連付けなし
comments = Comment.where(post_id: @post.id)
authors  = User.where(id: comments.pluck(:user_id))

# 関連付けあり
comments = @post.comments
authors  = @post.comments.map(&:user)

差は行数だけではありません。外部キー名を post_id から変更したとき、前者は該当箇所をすべて探して直す必要がありますが、後者はモデルの宣言1行を直せば済みます。関連付けはテーブル間の知識をモデルに集約するための宣言です。

アソシエーション・リレーション・ActiveRecord::Relationの用語の違い

この3語は指す層が異なります。リレーション(relation)はリレーショナルデータベースの用語で、正規化されたテーブルそのものと、外部キーによるテーブル間の参照関係を指します。アソシエーション(関連付け)は、その関係を Rails のモデルクラス上で宣言したものです。そしてコード中の ActiveRecord::Relation は、クエリを組み立てる途中の遅延評価オブジェクトを指し、テーブル間の関係とは別物です。

3語が混ざったまま設計を進めると、「has_many を書いたのだからDB側の整合性も守られている」という誤解につながります。実際には連動しません。

6種類の関連付けと外部キーの置き場所

公式ガイドは「Rails supports six types of associations」として6種類を定義しています。どれを選ぶかは、外部キーカラムをどちらのテーブルに置くか相手が何件かの2点でほぼ決まります。

宣言 相手の件数 外部キーの位置 典型例
belongs_to 1 自分のテーブル Comment → Post
has_one 1 相手のテーブル User → Profile
has_many 0件以上 相手のテーブル Post → Comment
has_many :through 0件以上 中間テーブル Doctor → Patient(Appointment経由)
has_one :through 1 中間テーブル Supplier → AccountHistory(Account経由)
has_and_belongs_to_many 0件以上 結合テーブルのみ Assembly ⇔ Part

外部キーは常に「1件だけ持つ側」に置きます。コメントは記事1件にぶら下がるので comments.post_id が必要で、その側が belongs_to です。記事側は件数が不定なのでカラムを持てず、has_many になります。

belongs_toの実装と外部キーまわりの指定

belongs_to は、外部キーカラムを持つ側のモデルに書きます。belongs_to :post と宣言すると、Active Record は自分のテーブルに post_id カラムがある前提で comment.post を組み立てます。単数形で書く点が has_many との表記上の違いです。

Rails 5.0以降のデフォルト必須とoptional: trueの使いどころ

公式ガイドは「By default belongs_to validates the presence of the associated record to guarantee reference consistency」と記述しています。つまり post_idnil のまま保存しようとすると、バリデーションエラーになります。

この既定値は config.active_record.belongs_to_required_by_default が制御し、config.load_defaults が 5.0 以降であれば true です。Rails 4系から移行してきたアプリで load_defaults を上げた途端に既存の保存処理が落ちる場合、原因はほぼこれです。

class Comment < ApplicationRecord
  # 記事に紐づかない下書きコメントを許すなら optional が必要
  belongs_to :post, optional: true
end

optional: true は、関連先が後から決まるワークフロー(下書き、インポート待ちの一時データ)でだけ使ってください。「バリデーションエラーが出たから外す」という理由で付けると、参照先を失ったレコードが静かに増えます。

class_name・foreign_keyを明示する条件

規約から外れるのは2パターンです。1つ目は、同じテーブルを別の役割で複数回参照する場合。承認者と申請者がどちらも User を指すようなケースでは、関連名がクラス名と一致しないため class_name が要ります。

class Request < ApplicationRecord
  belongs_to :applicant, class_name: "User"
  belongs_to :approver,  class_name: "User", foreign_key: "approved_by_id"
end

2つ目は、既存DBのカラム名が Rails の規約と違う場合です。approved_by_id のように _id の前がクラス名でないカラムは推測できないので foreign_key で明示します。なお foreign_key を指定した関連付けは、後述する inverse_of の自動検出が効かなくなります。

モデルの関連付けとDBの外部キー制約の役割分担

belongs_to はアプリケーション層の宣言であり、それ自体はデータベースに外部キー制約(FOREIGN KEY)を作りません。制約はマイグレーションで張ります。

class AddPostToComments < ActiveRecord::Migration[8.1]
  def change
    add_reference :comments, :post, foreign_key: true
  end
end

両方を用意する価値があります。関連付けだけの状態では、SQLを直接流すバッチや別システムからの書き込みで、存在しない post_id を持つ行が混入します。逆に制約だけでは、Rails 側でエラーメッセージをフォームに返せません。foreign_key: true はインデックスも同時に作るため、post.comments の検索性能にも効きます。なお次章のジェネレータで references 型を使った場合、この制約とインデックスは生成時点で付いています。

has_manyの実装と関連レコードの操作

has_many は宣言そのものより、生成されたコレクションの操作で詰まります。追加・削除の各メソッドが、いつSQLを発行して何を書き換えるのかが分かれ道です。

マイグレーションからモデル定義までの手順

関連付けはモデルに書くだけでは動きません。外部キーカラムが先に必要です。references 型でモデルを生成すると、カラム・インデックス・外部キー制約・belongs_to 宣言までまとめて用意されます。

$ bin/rails g model Comment body:text post:references
$ bin/rails db:migrate

# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy   # ここは手で追記する
end

post:references が生成するのは Comment 側までで、マイグレーションには t.references :post, null: false, foreign_key: true が入ります。Post 側の has_many は自動では書かれません。

コレクションへの追加・削除で発行されるSQLの違い

post.comments が返すのはコレクションプロキシで、配列のように見えますが挙動は異なります。

post.comments.build(body: "下書き")  # メモリ上だけ。INSERT しない
post.comments.create(body: "投稿")   # 即 INSERT
post.comments << comment             # 未保存なら INSERT、保存済みなら post_id を UPDATE

post.comments.delete(comment)        # 既定は post_id を NULL にするだけ
post.comments.destroy(comment)       # DELETE。コールバックも走る
post.comments.clear                  # dependent の設定に従う

<< がどちらのSQLを出すかは、渡した子が永続化済みかどうかで決まります(いずれも親が保存済みの場合)。事故になりやすいのは delete のほうで、レコードを消さず外部キーを NULL にします。dependent: :destroy を宣言してあれば delete も削除に変わりますが、宣言の有無で挙動が変わらない destroy を使うほうが読み違いを防げます。

dependentで指定できる6つの値と削除時の挙動

親を削除したときに子をどうするかは dependent で決めます。has_many で指定できる値は次の6つです(未指定なら何もしません)。

子への操作 コールバック 選ぶ場面
:destroy 1件ずつ destroy 実行される 子にも後始末がある
:destroy_async バックグラウンドジョブで destroy 実行される 子が大量
:delete_all 1回のDELETE 実行されない 子が単純な明細
:nullify 外部キーをNULLに更新 実行されない 子を残したい
:restrict_with_exception 削除せず例外 削除を禁止
:restrict_with_error 削除せずエラー追加 フォームに理由を返す

迷ったら :destroy を選びます。子がさらに孫を持つ場合や、削除時に添付ファイルを消すコールバックがある場合、:delete_all だと孫とファイルが残ります。逆に子が数万件ある明細テーブルで、コールバックを一切持たないと確認できているなら :delete_all が1クエリで済みます。:restrict_with_exceptionActiveRecord::DeleteRestrictionError を投げるため、画面から呼ぶ削除には :restrict_with_error を使い、errors 経由で理由を表示してください。

belongs_to 側にも dependent は書けますが、指定できるのは :destroy / :delete / :destroy_async の3つです。公式APIは、相手側に has_many がある関係でこれを指定すると孤児レコードを残す可能性があるとして、併用しないよう注意しています。

counter_cacheで件数取得のクエリを減らす手順

一覧画面に「コメント N件」を出すと、記事の件数だけ COUNT が飛びます。counter_cache は件数をカラムに保持してこの COUNT をなくす仕組みで、宣言するのは belongs_to 側、カウントを持つのは相手のテーブルです。

class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true   # posts.comments_count を更新
end

公式APIは、カウント用カラムを関連先クラスのテーブルに #{table_name}_count という名前で用意することを求めています。Commentbelongs_to :post, counter_cache: true と書くなら、posts テーブルに comments_count が必要です。運用中のテーブルに後から追加する場合は既存件数の初期化が要り、集計中の書き込みでカウントがずれます。この手順とずれの直し方は counter_cacheの実装手順とカウントずれ・後付けの対処で詳しく扱っています。

has_many :throughで多対多を中間モデルとして扱う実装

医師と患者のように、どちらも相手を複数持つ関係は中間テーブルで表現します。has_many :through では、その中間テーブルにも独立したモデルを用意します。

# app/models/appointment.rb
class Appointment < ApplicationRecord
  belongs_to :doctor
  belongs_to :patient
  validates :starts_at, presence: true
end

# app/models/doctor.rb
class Doctor < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end

この形にしておくと、予約日時や担当区分といった属性を中間モデルに持たせられます。doctor.patients で患者一覧を取りつつ、doctor.appointments.where(starts_at: Date.current.all_day) で当日分だけを絞る、という2つの読み方が同居できるのが利点です。

has_and_belongs_to_manyを選んでよい2つの場合

多対多にはもう1つ has_and_belongs_to_many という手段があり、これが混乱のもとになっています。判断基準は公式ガイドが明示しています。has_many :through を選ぶのは「結合テーブルに属性やメソッドを追加する必要があるとき」「結合モデルにバリデーションやコールバックが必要なとき」。has_and_belongs_to_many は「関連がシンプルで、結合テーブルに追加の属性や振る舞いを必要としないとき」に限られます。

新規設計であれば has_many :through を既定にしてください。has_and_belongs_to_many はモデルクラスを持たないため、後から「この関連はいつ作られたのか」「役割は何か」を記録したくなった時点で、テーブル作り直しとデータ移行が発生します。ユーザーと部署の多対多は最初は所属の有無だけでも、遠からず「兼務か」「いつから」を持たされます。has_and_belongs_to_many が妥当なのは、レガシースキーマをそのまま扱う場合と、結合そのものを一切参照しない場合の2つだけです。

中間モデル経由の関連付けを増やしすぎると、モデルクラスが宣言だけで数十行に膨らみます。関連付けとスコープでモデルが肥大したときの分割は、model.rbを分割する4手法と判断基準で扱っています。

inverse_ofの自動検出が効かない3条件

post.comments.first.post のように往復して参照したとき、Active Record が同じオブジェクトを返すか、DBから読み直して別オブジェクトを作るか。これを決めるのが双方向関連の認識です。公式ガイドは「Active Record will attempt to automatically identify that these two models share a bi-directional association」と述べており、標準的な命名であれば inverse_of は不要です。

自動検出が効かないのは、次の3条件です。

  • :through オプションを使っている関連付け
  • :foreign_key オプションを明示した関連付け
  • 関連付けにスコープが付いている場合(反対側のスコープも含む)

3つ目のスコープについては、config.active_record.automatic_scope_inversingtrue なら自動推論されます。この設定は config.load_defaults が 7.0 以降で true です。つまり Rails 7.0 以降のデフォルト設定で動いているアプリなら、残る要注意は :through:foreign_key の2つに絞られます。

class Request < ApplicationRecord
  belongs_to :approver, class_name: "User",
             foreign_key: "approved_by_id", inverse_of: :approved_requests
end

class User < ApplicationRecord
  has_many :approved_requests, class_name: "Request",
           foreign_key: "approved_by_id", inverse_of: :approver
end

書き忘れたときの症状は分かりにくく出ます。親でセットした未保存の値が子から見えない、同じレコードのオブジェクトが2つできてメモリ上の変更が片方にしか反映されない、バリデーションで親を参照すると nil になる、フォームのネスト保存で件数が合わない、といった形です。foreign_key を書いたら inverse_of もセットで書く、と決めておくと踏みません。

N+1クエリを避ける読み込み方の選択

関連付けを入れた直後に一覧が遅くなるのは、ほぼこの問題です。公式ガイドの例では、10冊の書籍とその著者を表示するだけで「1 (to find 10 books) + 10 (one per each book to load the author) = 11 queries」が発行されます。関連付け自体が遅いのではなく、遅延読み込みの回数が件数に比例して増えます。

includes・preload・eager_load・joinsの使い分け

4つのメソッドは目的が違います。発行クエリ数と、関連先テーブルへの条件指定が可能かどうかで選びます。

メソッド クエリ数 結合 関連先への絞り込み
preload 2 しない 不可
includes 2(条件次第で1) 条件次第 可(references併用)
eager_load 1 LEFT OUTER JOIN
joins 1 INNER JOIN 可(読み込みはしない)

第一候補は preload です。関連先を表示するだけなら、関連ごとに1本ずつ発行される単純なクエリが最も読みやすく、実行計画も安定します。関連先の値で絞り込む必要が出たら eager_load に切り替えます。includes は状況に応じて2クエリと LEFT OUTER JOIN を切り替えますが、SQLフラグメントで条件を書いた場合は references を明示しないと結合に切り替わりません。挙動が読み手に伝わりにくいので、意図が決まっているなら preloadeager_load を直接書くほうが保守しやすくなります。

joins は関連先を読み込まない点に注意してください。Post.joins(:comments).where(comments: { approved: true }) は絞り込みには効きますが、そのあと post.comments を参照すればN+1が復活します。フレームワーク間の対処法の違いは DjangoとRailsのN+1問題解決法の比較でまとめています。

strict_loadingで遅延読み込みを検知する設定

N+1は「気づけない」ことが本質的な問題です。開発環境の少量データでは体感できず、本番のデータ量で顕在化します。Rails には遅延読み込みそのものを禁止する strict_loading があり、公式ガイドは「an ActiveRecord::StrictLoadingViolationError will be raised if the record tries to lazily load any association」と説明しています。

# 特定のクエリだけ厳格にする
Post.strict_loading.limit(10).each { |p| p.comments.to_a }
# => ActiveRecord::StrictLoadingViolationError

# モデル単位で常に厳格にする
class Post < ApplicationRecord
  self.strict_loading_by_default = true
  has_many :comments
end

例外が上がるのは関連先のレコードを実際に読み込もうとした時点です。comments.size のようにカウントで済む呼び出しは COUNT が1本飛ぶだけで違反になりません。動作確認するときは to_aeach でターゲットをロードさせてください。既存アプリでいきなりモデル全体に掛けると大量に落ちるので、新規モデルから strict_loading_by_default を有効にし、既存モデルは一覧系のクエリ単位で足していくと修正範囲を制御できます。

使われなくなった関連付けの棚卸し(Rails 8.1のdeprecatedオプション)

長く動いているアプリのモデルには、もう誰も呼んでいない関連付けが残ります。消したいが、どこかのビューやジョブから参照されていないか確認できないため放置される、というのがよくある状態です。Rails 8.1(2025年10月リリース)で入った関連付けの非推奨マークは、この確認を実行時に肩代わりします。

class Author < ApplicationRecord
  has_many :posts, deprecated: true
end

Active Record の CHANGELOG は「Three reporting modes are supported (:warn, :raise, and :notify), and backtraces can be enabled or disabled. Defaults are :warn mode and disabled backtraces.」と記しています。author.posts のような直接呼び出しだけでなく、Author.preload(:posts)Author.joins(:posts) のようなクエリ経由の参照も捕捉されます。

手順としては、本番で :warn のまま1〜2か月ログを集め、参照がゼロだと確認できてから削除します。ステージングやテスト環境では :raise にして、残った呼び出しをテスト失敗として顕在化させます。

一方で、この機能を「とりあえず全部に付けて様子を見る」使い方はしないでください。:warn のログが日常的に流れる状態は、本当に消したい関連付けのシグナルを埋もれさせます。削除の意思決定が済んだ関連付けにだけ付ける機能です。関連付けの整理と合わせてモデルの責務分割を考えるなら、Concernの正しい使い方と乱用を避ける代替設計も判断材料になります。

よくある質問

モデルに定義済みのアソシエーションを一覧で確認する方法はありますか?

ActiveRecord::Reflection::ClassMethods のリフレクションAPIを使います。Post.reflect_on_all_associations はそのクラスの全関連付けについて AssociationReflection オブジェクトの配列を返し、:has_many / :has_one / :belongs_to を引数に渡せば種類で絞れます。名前だけ見たいときは、関連名をキーとするハッシュを返す Post.reflections.keys が手軽です。継承したモデルや Concern 経由で定義された関連付けも拾えます。

belongs_toにforeign_keyを指定するのはどんなときですか?

関連名から推測されるカラム名と、実際のカラム名が違うときです。belongs_to :approverapprover_id を探すため、カラムが approved_by_id なら明示が必要です。同じテーブルを複数の役割で参照する場合が典型例です。この指定をすると inverse_of の自動検出が効かなくなるので、inverse_of も併記してください。

has_many :throughとhas_and_belongs_to_manyはどちらを使うべきですか?

新規に設計するなら has_many :through です。公式ガイドは、結合テーブルに属性やメソッドを追加する必要がある場合、または結合モデルにバリデーションやコールバックが必要な場合に has_many :through を使うとしています。has_and_belongs_to_many はモデルクラスを持たないため、後から属性を足せません。

inverse_ofはいつ書く必要がありますか?

自動検出が効かない :through:foreign_key・スコープ付きの3条件のときです。ただしスコープについては config.active_record.automatic_scope_inversingtrueconfig.load_defaults 7.0 以降の既定)なら自動推論されるため、実質は前の2つが要注意です。

アソシエーションを使うと一覧表示が遅くなるのはなぜですか?

関連付け自体ではなく、遅延読み込みによるN+1クエリが原因です。10件の記事で著者を参照すると、1本+10本の計11クエリが発行されます。表示するだけなら preload、関連先の値で絞り込むなら eager_load で事前に読み込んでください。検知を仕組み化するなら strict_loading が使えます。

関連記事

資料請求

RELATED POSTS 関連記事