Coming from JavaScript, I’m used to flexible object relationships. Need comments on different things? Add a commentableId and commentableType field, write some conditional logic, done. Rails has the same pattern, but codifies it into the framework: polymorphic associations.
The database columns work identically to what you’d do in MongoDB or PostgreSQL with a JavaScript backend. What confused me wasn’t the columns themselves—it was the Rails magic layered on top. When I saw belongs_to :commentable, polymorphic: true in a model, I had no idea how Rails knew which table to query. There’s no import, no explicit configuration, just a symbol and an option.
This article walks through the database schema and Rails internals so you can understand what’s actually happening.
The Problem Polymorphic Associations Solve
You’re building a social app where users can comment on Posts, Photos, and Videos.
The JavaScript Approach
In JavaScript with MongoDB, you might do:
// Comment document
{
id: 1,
body: "Great post!",
userId: 42,
commentableType: "Post", // or "Photo" or "Video"
commentableId: 123
}
// Then when fetching:
async function getCommentable(comment) {
if (comment.commentableType === "Post") {
return await Post.findById(comment.commentableId);
} else if (comment.commentableType === "Photo") {
return await Photo.findById(comment.commentableId);
}
// ... more conditionals
}
This works, but you’re writing the same conditional logic everywhere you need to load the parent.
The Rails Way
Rails bakes this pattern into the framework:
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
class Post < ApplicationRecord
has_many :comments, as: :commentable
end
Now:
comment = Comment.first
comment.commentable # Rails automatically loads the right model
post = Post.first
post.comments # All comments on this post
No conditionals. No manual lookups. Rails reads the _type column and calls the right model.
How Polymorphic Associations Actually Work
The Database Schema
A polymorphic association requires two columns:
# db/migrate/20250101000000_create_comments.rb
class CreateComments < ActiveRecord::Migration[7.0]
def change
create_table :comments do |t|
t.text :body
t.references :user, null: false, foreign_key: true
# Polymorphic association - creates both columns
t.references :commentable, polymorphic: true, null: false
t.timestamps
end
add_index :comments, [:commentable_type, :commentable_id]
end
end
This creates:
commentable_type(string) - stores the model class name: “Post”, “Photo”, “Video”commentable_id(bigint) - stores the ID of that record
Polymorphic associations can’t have database foreign key constraints because commentable_id could point to multiple tables. The database can’t enforce referential integrity across tables, so Rails handles validation at the application level.
Creating a Comment
post = Post.find(123)
comment = post.comments.create(body: "Great post!", user: current_user)
Rails automatically sets the polymorphic columns:
INSERT INTO comments (body, user_id, commentable_type, commentable_id, ...)
VALUES ('Great post!', 42, 'Post', 123, ...)
Loading the Commentable
comment = Comment.first
comment.commentable # What happens here?
Rails reads commentable_type (gets “Post”), reads commentable_id (gets 123), then calls Post.find(123):
# What Rails does internally:
class_name = comment.commentable_type # => "Post"
record_id = comment.commentable_id # => 123
class_name.constantize.find(record_id) # => Post.find(123)
Setting Up Your Models
On the “Many” Side (Comment)
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :user
validates :body, presence: true
end
The polymorphic: true option tells Rails to check commentable_type to determine which model to load.
On the “One” Side (Post, Photo, Video)
class Post < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
class Photo < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
The as: :commentable must match the name in belongs_to. The dependent: :destroy deletes comments when the parent is deleted.
Building a Commenting System
Generate the migration
rails generate model Comment body:text user:references commentable:references{polymorphic}
Set up routes with concerns
# config/routes.rb
Rails.application.routes.draw do
concern :commentable do
resources :comments, only: [:create, :destroy]
end
resources :posts, concerns: :commentable
resources :photos, concerns: :commentable
resources :videos, concerns: :commentable
end
Create the controller with Signed Global IDs
Instead of passing commentable_type and commentable_id as separate params (which requires whitelisting types and constantizing strings), use Rails’ Signed Global IDs. They encode the model type and ID into a single tamper-proof token:
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = current_user
if @comment.save
redirect_to @commentable, notice: "Comment created!"
else
render :new, status: :unprocessable_entity
end
end
private
def set_commentable
@commentable = GlobalID::Locator.locate_signed(params[:commentable_sgid])
raise ActiveRecord::RecordNotFound unless @commentable
end
def comment_params
params.require(:comment).permit(:body)
end
end
In your form, generate the signed global ID:
<%= form_with model: Comment.new, url: comments_path do |f| %>
<%= hidden_field_tag :commentable_sgid, @post.to_sgid.to_s %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
This approach prevents users from tampering with params to comment on records they shouldn’t access. For more on this pattern, see Let Rails Help You.
Common Polymorphic Patterns
DRY with Concerns
Extract the association into a concern when multiple models share the same polymorphic relationship:
# app/models/concerns/commentable.rb
module Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable, dependent: :destroy
end
end
# app/models/post.rb
class Post < ApplicationRecord
include Commentable
end
Querying
# Get all comments on posts
Comment.where(commentable_type: "Post")
# Eager load to avoid N+1
Comment.includes(:commentable).where(user: current_user)
# Count by type
Comment.group(:commentable_type).count
# => {"Post" => 150, "Photo" => 89, "Video" => 45}
If you’re using delegated types (Rails 6.1+), you get scopes like Comment.posts for free.
Common Pitfalls
Forgetting the Index
Always add a composite index on both columns:
add_index :comments, [:commentable_type, :commentable_id]
Mismatched Names
The as: option must match the belongs_to name exactly:
# These must match
belongs_to :commentable, polymorphic: true
has_many :comments, as: :commentable # not :post_commentable
N+1 Queries
# Triggers N+1
comments.each { |c| c.commentable.title }
# Fixed
Comment.includes(:commentable).each { |c| c.commentable.title }
When NOT to Use Polymorphic Associations
Avoid polymorphic associations when:
- You need database foreign key constraints (polymorphic can’t have them)
- You only have 2-3 parent models (separate foreign keys are simpler)
- The parent models have very different behavior
Alternatives: separate associations with foreign keys, Single Table Inheritance (STI) if models are similar, or delegated types (Rails 6.1+).
Wrapping up
Polymorphic associations encode a common pattern—one model belonging to multiple types—into Rails conventions. The database stores a _type and _id column, and Rails handles the rest.
The key points:
- Two columns:
{name}_type(string) and{name}_id(bigint) belongs_to :name, polymorphic: trueon the childhas_many :children, as: :name, dependent: :destroyon each parent- Always index
[:type_column, :id_column] - Use Signed Global IDs in controllers to avoid constantizing user input
- No database foreign keys possible—Rails handles integrity at the application level