Single Table Inheritance (STI) is one of those Rails features that sparks strong opinions. Used correctly, it eliminates duplication and creates elegant model hierarchies. Used poorly, it leads to bloated tables and confusing code. This guide covers when STI makes sense, how to implement it properly in Rails 8, and when to reach for alternatives.
Understanding the Problem STI Solves
Consider an application managing different types of notifications: email notifications, SMS notifications, and push notifications. Each shares common attributes (recipient, message, sent_at) but has type-specific behavior and perhaps a few unique fields.
Without STI, developers face three options: create separate tables with duplicated columns, use a single table with a manual type column and scattered conditionals, or build a complex polymorphic setup. STI offers a fourth path—one table, multiple Ruby classes, with Rails handling the mapping automatically.
Basic STI Implementation
Setting up STI requires a type column in the database and a class hierarchy in Ruby. The parent class inherits from ApplicationRecord, and child classes inherit from the parent.
# db/migrate/20260110120000_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[8.0]
def change
create_table :notifications do |t|
t.string :type, null: false
t.references :user, null: false, foreign_key: true
t.string :recipient
t.text :message
t.json :metadata
t.datetime :sent_at
t.datetime :failed_at
t.string :failure_reason
t.timestamps
end
add_index :notifications, :type
add_index :notifications, [:user_id, :type]
add_index :notifications, :sent_at
end
endThe type column stores the class name as a string. Rails uses this to instantiate the correct class when loading records. Adding an index on type is essential since queries often filter by notification type.
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
validates :recipient, presence: true
validates :message, presence: true, length: { maximum: 1000 }
scope :sent, -> { where.not(sent_at: nil) }
scope :pending, -> { where(sent_at: nil, failed_at: nil) }
scope :failed, -> { where.not(failed_at: nil) }
def send_notification
raise NotImplementedError, "Subclasses must implement #send_notification"
end
def mark_as_sent!
update!(sent_at: Time.current)
end
def mark_as_failed!(reason)
update!(failed_at: Time.current, failure_reason: reason)
end
end
# app/models/email_notification.rb
class EmailNotification < Notification
validates :recipient, format: { with: URI::MailTo::EMAIL_REGEXP }
def send_notification
NotificationMailer.notify(self).deliver_later
mark_as_sent!
rescue StandardError => e
mark_as_failed!(e.message)
end
def subject
metadata&.dig("subject") || "Notification"
end
end
# app/models/sms_notification.rb
class SmsNotification < Notification
validates :recipient, format: { with: /\A\+?[1-9]\d{1,14}\z/ }
validates :message, length: { maximum: 160 }
def send_notification
SmsGateway.send(to: recipient, body: message)
mark_as_sent!
rescue SmsGateway::DeliveryError => e
mark_as_failed!(e.message)
end
end
# app/models/push_notification.rb
class PushNotification < Notification
validates :recipient, presence: true # device token
def send_notification
PushService.deliver(
token: recipient,
title: metadata&.dig("title") || "New Notification",
body: message
)
mark_as_sent!
rescue PushService::InvalidToken
mark_as_failed!("Invalid device token")
end
endEach subclass overrides send_notification with type-specific logic while inheriting common functionality. The base class defines the interface, and subclasses provide implementations.
Querying STI Models
Rails makes querying STI models intuitive. Querying the parent class returns all types; querying a subclass returns only that type.
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
def index
# Returns ALL notification types
@notifications = current_user.notifications.recent
end
end
# app/controllers/admin/email_notifications_controller.rb
module Admin
class EmailNotificationsController < ApplicationController
def index
# Returns ONLY EmailNotification records
# Rails automatically adds WHERE type = 'EmailNotification'
@notifications = EmailNotification.pending.order(created_at: :desc)
end
def retry_failed
EmailNotification.failed.find_each(&:send_notification)
redirect_to admin_email_notifications_path, notice: "Retrying failed emails"
end
end
endThis automatic scoping works with all Active Record methods, including joins, includes, and complex queries.
Using the Metadata Column Effectively
STI tables often accumulate type-specific columns that remain null for other types. A metadata JSON column provides flexibility without table bloat.
# app/models/email_notification.rb
class EmailNotification < Notification
# Store email-specific data in metadata
def subject
metadata&.dig("subject")
end
def subject=(value)
self.metadata = (metadata || {}).merge("subject" => value)
end
def cc_addresses
metadata&.dig("cc") || []
end
def cc_addresses=(addresses)
self.metadata = (metadata || {}).merge("cc" => Array(addresses))
end
end
# Usage in a service or controller
email = EmailNotification.new(
user: current_user,
recipient: "[email protected]",
message: "Your order has shipped!"
)
email.subject = "Order Update"
email.cc_addresses = ["[email protected]"]
email.save!This approach keeps the notifications table lean while allowing each type to store relevant data. For MySQL, the JSON column provides query capabilities when needed.
Common STI Pitfalls and Solutions
Pitfall 1: Sparse columns. Adding columns used by only one subclass leads to mostly-null data. Use the metadata JSON column for type-specific attributes, reserving regular columns for shared data.
Pitfall 2: Diverging validation logic. When subclasses have vastly different validation requirements, STI becomes unwieldy. If EmailNotification needs ten validations and SmsNotification needs two completely different ones, separate tables might be cleaner.
Pitfall 3: Changing types. STI makes it awkward to change a record's type. Updating the type column directly works but bypasses Rails:
# This works but skips validations and callbacks
notification.update_column(:type, "PushNotification")
# Safer approach: create new, destroy old
Notification.transaction do
new_notification = PushNotification.create!(
notification.attributes.except("id", "type", "created_at", "updated_at")
)
notification.destroy!
new_notification
endPitfall 4: N+1 queries with associations. When subclasses have different associations, eager loading requires care:
# app/models/email_notification.rb
class EmailNotification < Notification
has_many :attachments, foreign_key: :notification_id
end
# Loading all notifications with attachments only for emails
Notification.includes(:user).then do |notifications|
email_ids = notifications.select { |n| n.is_a?(EmailNotification) }.map(&:id)
attachments = Attachment.where(notification_id: email_ids).group_by(&:notification_id)
# Manual association
endWhen to Avoid STI
STI works best when subclasses share 70% or more of their attributes and behavior. Consider alternatives when:
- Subclasses have mostly different columns—use separate tables
- The type hierarchy exceeds two levels—complexity compounds quickly
- Records frequently change types—polymorphic associations handle this better
- Subclasses have different association structures—separate tables provide clarity
Polymorphic associations offer flexibility when the "types" are truly different entities. Delegated types (introduced in Rails 6.1) provide a middle ground, storing shared attributes in one table and type-specific attributes in separate tables.
Summary
Single Table Inheritance provides an elegant solution for modeling similar entities that share most attributes and behavior. Success with STI depends on keeping the hierarchy shallow, using JSON metadata for type-specific attributes, and recognizing when models have diverged enough to warrant separate tables. When the types genuinely belong together—like notification channels or payment methods—STI reduces duplication and leverages Ruby's inheritance for cleaner code.