A JavaScript developer's guide to Rails: What is Polymorphic Rails Model?

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: true on the child
  • has_many :children, as: :name, dependent: :destroy on 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

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.