/ Tags: RAILS / Categories: RAILS

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 query WHERE commentable_type = 'Post' AND commentable_id = 42 to use the index efficiently. Rails’ t.references :commentable, polymorphic: true, index: true creates it correctly, but it’s worth verifying in schema.rb before 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More