Polymorphic Associations in ActiveRecord — One Model, Many Parents
Some models naturally belong to many different types of things. Comments belong to posts, but also to videos, to products, to events. Images attach to users, articles, and listings. Tagging works across everything. Duplicating the association for each parent type creates tables that are nearly identical and migrations that have to stay synchronized. Polymorphic associations solve this at the database level with two columns instead of many foreign keys.
What Polymorphic Associations Solve
A polymorphic association lets one model (Comment, Image, Reaction) belong to multiple different parent models through a single association. Rails stores the relationship using two columns: commentable_type (the class name of the parent) and commentable_id (the ID of the specific parent record).
Example:
# Without polymorphism: three separate associations
class Comment < ApplicationRecord
belongs_to :post # post_id column
belongs_to :video # video_id column — now you have two nullable FKs
belongs_to :event # event_id column — and a third
end
# With polymorphism: one association, two columns
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
# commentable_type: "Post" | "Video" | "Event"
# commentable_id: 123
end
The trade-off: you can’t enforce referential integrity at the database level with a foreign key constraint, because a single column can’t reference multiple tables. That’s the main reason to think carefully before reaching for polymorphism.
Migration and Schema Setup
Setup:
# Generate the migration
rails generate migration AddCommentableToComments commentable:references{polymorphic}
# Or write it manually
class CreateComments < ActiveRecord::Migration[7.1]
def change
create_table :comments do |t|
t.text :body, null: false
t.references :commentable, polymorphic: true, null: false, index: true
t.timestamps
end
end
end
t.references :commentable, polymorphic: true generates:
commentable_type(string, not null)commentable_id(bigint, not null)- A composite index on
[commentable_type, commentable_id]
Verify the index was created — it’s essential for performance. Without it, post.comments scans the full comments table at scale.
Example:
# What the schema looks like after migration
create_table "comments", force: :cascade do |t|
t.text "body", null: false
t.string "commentable_type", null: false
t.bigint "commentable_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
end
Model Setup
Example:
# The polymorphic model
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
validates :body, presence: true
end
# Each parent model declares the other side
class Post < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
class Video < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
class Event < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
The as: :commentable on the has_many side and polymorphic: true on the belongs_to side are the two required pieces. The name (commentable) can be anything — pick something that describes the relationship, not the participating types.
Querying Polymorphic Associations
Example:
# From a parent to its comments — works the same regardless of type
post.comments # => all comments for this post
video.comments.recent # => scoped as usual
event.comments.count
# From a comment to its parent
comment.commentable # => returns the actual Post, Video, or Event instance
comment.commentable_type # => "Post"
comment.commentable_id # => 42
# Filtering by parent type
Comment.where(commentable_type: "Post")
Comment.where(commentable_type: "Post", commentable_id: post.id)
# Creating through the association
post.comments.create!(body: "Great article")
The N+1 Problem with Polymorphic belongs_to
The standard includes can’t join a polymorphic association because the target table is unknown at query time. This creates a subtle N+1 situation when loading the parent from child records.
Example:
# This N+1s — one query per comment to load each commentable
comments = Comment.all
comments.each { |c| puts c.commentable.title }
# includes won't eager load polymorphic belongs_to
Comment.includes(:commentable).each { |c| puts c.commentable.title }
# Still N+1 — includes can't join an unknown table
# Use preload instead — it groups by type and fires one query per type
Comment.preload(:commentable).each { |c| puts c.commentable.title }
# => SELECT * FROM posts WHERE id IN (1, 5, 9)
# => SELECT * FROM videos WHERE id IN (3, 7)
preload issues separate queries per commentable type, which is far better than N+1 but still multiple queries. If you only ever have one commentable type in a given context, you can filter and join manually.
When to Use vs. When to Think Twice
Good fit
- Behavior is identical across all parent types (comments, images, tags, reactions)
- You’re adding the same concept to many models and duplicating tables feels wrong
- The types are well-established and won’t need divergent behavior
Consider alternatives when
- Different parent types need different behavior on the shared model (a comment on a Post and a comment on a Product that need different validation rules)
- You need database-level referential integrity — foreign key constraints don’t work with polymorphism
- The number of participating types is small and fixed — separate associations are clearer
Single Table Inheritance (STI) is worth considering when the types share most behavior and a common parent makes conceptual sense. Separate junction tables are worth considering when referential integrity matters more than reduced duplication.
Pro-Tip: Double-check that your migration created the composite index on
[commentable_type, commentable_id], not just separate indexes on each column. A composite index is necessary for the queryWHERE commentable_type = 'Post' AND commentable_id = 42to use the index efficiently. Rails’t.references :commentable, polymorphic: true, index: truecreates it correctly, but it’s worth verifying inschema.rbbefore deploying.
Conclusion
Polymorphic associations are the right tool when the same concept genuinely applies across multiple parent types and the behavior is consistent. Comments, attachments, tags, and reactions are the canonical use cases. The implementation is straightforward — two columns, two model declarations, and the rest works like any other association. The main gotcha is eager loading: use preload instead of includes when loading the polymorphic parent from child records, and always verify that composite index exists before production traffic hits the table.
FAQs
Q1: Can I add validations based on the commentable type?
Yes: validates :body, length: { maximum: 500 }, if: -> { commentable_type == "Tweet" }. Or use a custom validation method that checks commentable_type and applies different rules. This pattern is a sign that your types may be diverging enough to warrant separate models, but it’s workable for limited cases.
Q2: Does dependent: :destroy work with polymorphic associations?
Yes, has_many :comments, as: :commentable, dependent: :destroy works correctly — when a Post is destroyed, its comments are destroyed. The polymorphism doesn’t affect how dependent callbacks work on the parent side.
Q3: How do I query all comments across all types?
Comment.all returns every comment regardless of parent type — polymorphism doesn’t partition the table. To narrow to a specific type: Comment.where(commentable_type: "Post"). To narrow to specific records: Comment.where(commentable_type: "Post", commentable_id: post_ids).
Q4: Can I use counter caches with polymorphic associations?
Yes, with a minor addition: belongs_to :commentable, polymorphic: true, counter_cache: true doesn’t work directly. You need counter_cache: :comments_count and each parent model needs a comments_count column. It’s more setup than non-polymorphic counter caches but works the same way.
Q5: Is polymorphism supported in Rails API mode?
Fully. Polymorphic associations are a database/model feature — they work identically regardless of whether you’re using full Rails or API mode. The serialization side (how you expose the commentable_type and commentable_id in JSON) is where you’ll need to make decisions, but the model layer is unchanged.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀