When building Rails applications, situations arise where a model needs to belong to multiple other models. Comments that can attach to posts, photos, or videos. Notifications that reference orders, messages, or friend requests. Tags that apply to articles, products, or users. Polymorphic associations solve this elegantly.
The Problem: One Model, Many Parents
Consider a commenting system. Without polymorphic associations, the options are limited and problematic:
- Create separate tables:
post_comments,photo_comments,video_comments— duplicating logic everywhere - Add multiple foreign keys:
post_id,photo_id,video_id— with null columns and complex validations - Use polymorphic associations: one
commentstable that works with any model
The polymorphic approach keeps the schema clean and the code DRY. Rails 8 makes this straightforward with built-in support at both the migration and model levels.
Setting Up Polymorphic Associations
Start with the migration. The key is adding both a foreign key column and a type column that stores the associated model's class name:
# db/migrate/20260108120000_create_comments.rb
class CreateComments < ActiveRecord::Migration[8.0]
def change
create_table :comments do |t|
t.text :body, null: false
t.references :commentable, polymorphic: true, null: false, index: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
# Add composite index for efficient queries
add_index :comments, [:commentable_type, :commentable_id, :created_at],
name: "index_comments_on_commentable_and_created_at"
end
endThe polymorphic: true option creates two columns: commentable_id (integer) and commentable_type (string). MySQL stores values like commentable_type = "Post" and commentable_id = 42.
Now define the models. The Comment model uses belongs_to with the polymorphic option:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :user
validates :body, presence: true, length: { minimum: 2, maximum: 10_000 }
scope :recent, -> { order(created_at: :desc) }
scope :for_type, ->(type) { where(commentable_type: type) }
end
# app/models/post.rb
class Post < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
# Other post logic...
end
# app/models/photo.rb
class Photo < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
# Other photo logic...
end
# app/models/video.rb
class Video < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
# Other video logic...
endThe as: :commentable option tells Rails which polymorphic interface this association uses. The name must match the belongs_to :commentable declaration in Comment.
Working with Polymorphic Records
Creating and querying polymorphic associations follows familiar Active Record patterns:
# Creating comments on different models
post = Post.find(1)
post.comments.create!(body: "Great article!", user: current_user)
photo = Photo.find(5)
photo.comments.create!(body: "Beautiful shot", user: current_user)
# Accessing the parent from a comment
comment = Comment.find(10)
comment.commentable # Returns Post, Photo, or Video instance
comment.commentable_type # Returns "Post", "Photo", or "Video"
comment.commentable_id # Returns the ID
# Querying comments by type
Comment.where(commentable_type: "Post").count
Comment.for_type("Photo").recent.limit(10)
# Eager loading to avoid N+1 queries
Comment.includes(:commentable, :user).recent.limit(20)Building a Reusable Comments Controller
One controller can handle comments for all commentable types. Use a before action to load the parent resource:
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :set_commentable
before_action :set_comment, only: [:destroy]
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_back fallback_location: @commentable }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@comment.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@comment) }
format.html { redirect_back fallback_location: @commentable }
end
end
private
def set_commentable
@commentable = find_commentable
end
def find_commentable
params.each do |name, value|
if name =~ /(.+)_id$/
model = $1.classify.constantize
return model.find(value) if model.reflect_on_association(:comments)
end
end
nil
end
def set_comment
@comment = @commentable.comments.find(params[:id])
end
def comment_params
params.require(:comment).permit(:body)
end
endSet up nested routes for each commentable resource:
# config/routes.rb
Rails.application.routes.draw do
resources :posts do
resources :comments, only: [:create, :destroy]
end
resources :photos do
resources :comments, only: [:create, :destroy]
end
resources :videos do
resources :comments, only: [:create, :destroy]
end
endMySQL Indexing Considerations
Polymorphic associations require careful indexing. The default index on (commentable_type, commentable_id) handles lookups like "all comments for this post." However, queries filtering by type alone need attention:
# This query benefits from a type-only index
Comment.where(commentable_type: "Post").count
# Add if this query pattern is common
add_index :comments, :commentable_typeFor large tables, consider the composite index shown in the migration above. It supports queries that filter by type and sort by date — a common pattern for displaying recent comments.
Common Pitfalls and Solutions
Pitfall 1: Forgetting to eager load. Without includes(:commentable), displaying a list of comments triggers N+1 queries. Each comment.commentable call hits the database.
Pitfall 2: Type column mismatches. Rails stores the full class name in commentable_type. If a model gets renamed or moved to a module, existing records break. Use a migration to update type values when refactoring.
Pitfall 3: No foreign key constraints. MySQL foreign keys cannot reference multiple tables, so polymorphic associations lack database-level referential integrity. Use dependent: :destroy on the parent side and consider periodic cleanup jobs for orphaned records.
Pitfall 4: STI conflicts. When using Single Table Inheritance with polymorphic associations, Rails stores the STI subclass name (e.g., "GuestUser") not the base class. Query accordingly.
When to Use Polymorphic Associations
Polymorphic associations shine when:
- Multiple models share identical relationship behavior (comments, tags, attachments)
- The child model's logic is independent of the parent type
- Adding new parent types should require no schema changes
Consider alternatives when the child model needs type-specific behavior. If comments on posts have different validations than comments on photos, separate models may be cleaner despite the duplication.
Summary
Polymorphic associations reduce duplication by allowing one model to belong to many others through a single interface. The setup involves a migration with polymorphic: true, a belongs_to with polymorphic: true on the child, and has_many with as: on each parent. Combined with a resourceful controller that dynamically finds the parent, this pattern enables clean, maintainable code for features like comments, attachments, and activity feeds across Rails 8 applications.