Rails Delegated Types: Replacing Single Table Inheritance Without the Mess
Rails delegated types explained: ditch STI for clean per-type tables. Real migrations, query patterns, N+1 traps, and when STI is still the right call.
The model was called Notification. When I first opened the schema, the table had 52 columns. The type column had eight values in production: EmailNotification, SmsNotification, PushNotification, InAppNotification, SlackNotification, WebhookNotification, DigestNotification, and SystemNotification. Of those 52 columns, any given row used at most 14. The other 38 were NULL. The model file was 430 lines of case type branching, callbacks wrapped in if email_notification?, and scopes that broke silently whenever someone added a ninth type without finding every branch.
The team had reached for Single Table Inheritance because it is the first thing Rails suggests when you need multiple variants of a thing. STI is not wrong — it is the right choice for a narrow set of problems. But it has a well-known failure mode, and that codebase was living inside it.
After nineteen years of Rails I have watched this pattern cause real damage: bloated tables, confused queries, and models that are functionally impossible to reason about. Rails 6.1 shipped a first-class answer: Rails delegated types. It is one of the most underused features in modern Rails, and this post is the practical guide I hand teams when they hit the STI wall.
What STI Does and Where It Breaks
Single Table Inheritance stores every variant of a type in one table, with a type string column that Rails uses to decide which subclass to instantiate. The appeal is obvious: one migration, one table, and the full scope of polymorphic Rails queries just works.
The failure mode has two forms.
The first is the null column explosion. Every type-specific attribute has to live in the shared table, so columns that only apply to one or two types end up as NULL for every other type. The Postgres docs do not say this, but a wide table with many sparse columns does take real disk space in the dead-tuple heap and adds noise to EXPLAIN ANALYZE output. More practically, it confuses every developer who runs SELECT * FROM notifications and tries to understand which columns matter for which types.
The second is the model boundary collapse. STI puts every variant’s logic in subclasses that share a table, which means validations, callbacks, and scopes must constantly defend against cross-type pollution. A before_save that makes sense for EmailNotification gets inherited by SmsNotification unless you guard it. A database constraint that should apply only to WebhookNotification cannot be expressed at the database level because the column has to be nullable for every other type. You end up pushing validations that should be database constraints into Ruby, and into fragile if type == 'X' guards that break on refactors.
The rule of thumb I use: STI is the right choice when you have two or three types, the variants share 80%+ of their columns, and the per-type logic is minimal. The moment you hit four or five types with meaningful per-type data, you are heading into trouble.
Delegated Types: The Core Idea
Rails delegated types splits the representation cleanly in two:
- A shared table holds every attribute that is meaningful across all variants — timestamps, ownership, status, anything you query across types.
- A per-type table holds every attribute specific to that variant. No nulls. No column compromise. Just the columns that belong there.
The shared table has two special columns: a _type string and an _id integer. This is a polymorphic association under the hood, but Rails manages the foreign key logic and adds a clean query API on top. The “delegate” part means you declare which methods from the shared model should forward to the type-specific record.
Setting Up a Real Example: A Notifications System
Say you are building a SaaS app that sends notifications through multiple channels. All notifications share: a user, a title, a read_at timestamp, and a created_at. But each channel has its own fields:
EmailNotification:to_address,from_address,email_message_id,opened_atSmsNotification:phone_number,twilio_message_sidPushNotification:device_token,fcm_message_id,badge_countInAppNotification:action_url,icon,dismissable
Here is the migration for the shared table:
class CreateNotifications < ActiveRecord::Migration[8.0]
def change
create_table :notifications do |t|
t.references :user, null: false, foreign_key: true
t.string :notifiable_type, null: false
t.bigint :notifiable_id, null: false
t.string :title, null: false
t.datetime :read_at
t.timestamps
t.index [:notifiable_type, :notifiable_id], unique: true
end
end
end
And the per-type tables:
class CreateEmailNotifications < ActiveRecord::Migration[8.0]
def change
create_table :email_notifications do |t|
t.string :to_address, null: false
t.string :from_address, null: false
t.string :email_message_id
t.datetime :opened_at
t.timestamps
end
end
end
class CreateSmsNotifications < ActiveRecord::Migration[8.0]
def change
create_table :sms_notifications do |t|
t.string :phone_number, null: false
t.string :twilio_message_sid
t.timestamps
end
end
end
class CreatePushNotifications < ActiveRecord::Migration[8.0]
def change
create_table :push_notifications do |t|
t.string :device_token, null: false
t.string :fcm_message_id
t.integer :badge_count, default: 0, null: false
t.timestamps
end
end
end
class CreateInAppNotifications < ActiveRecord::Migration[8.0]
def change
create_table :in_app_notifications do |t|
t.string :action_url
t.string :icon
t.boolean :dismissable, null: false, default: true
t.timestamps
end
end
end
Each per-type table has only the columns that make sense for it. phone_number is NOT NULL in sms_notifications because it always has to exist there — that constraint cannot be expressed in the STI approach without complex validations.
The Model Setup
The shared model declares delegated_type:
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
delegated_type :notifiable,
types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
dependent: :destroy
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
def mark_read!
update!(read_at: Time.current)
end
end
Each type-specific model includes a concern that Rails does not require but I always add:
# app/models/concerns/notifiable.rb
module Notifiable
extend ActiveSupport::Concern
included do
has_one :notification, as: :notifiable, touch: true
end
end
# app/models/email_notification.rb
class EmailNotification < ApplicationRecord
include Notifiable
validates :to_address, :from_address, presence: true
validates :to_address, format: { with: URI::MailTo::EMAIL_REGEXP }
end
# app/models/sms_notification.rb
class SmsNotification < ApplicationRecord
include Notifiable
validates :phone_number, presence: true,
format: { with: /\A\+[1-9]\d{7,14}\z/ }
end
# app/models/push_notification.rb
class PushNotification < ApplicationRecord
include Notifiable
validates :device_token, presence: true
end
# app/models/in_app_notification.rb
class InAppNotification < ApplicationRecord
include Notifiable
end
The validations are clean and isolated. SmsNotification validates phone format; EmailNotification validates email format. Neither knows the other exists, and neither contaminates the shared model with type-specific guards.
Querying Delegated Types
Rails generates scope methods automatically from the types: list:
# All notifications for a user
user.notifications.unread
# Only email notifications — generated scope
Notification.email_notifications
# Only push notifications
Notification.push_notifications
# Type predicates
notif = Notification.first
notif.email_notification? # => true or false
notif.notifiable_class # => EmailNotification
notif.email_notification # => #<EmailNotification ...> or nil
The scope names are the underscored model names. EmailNotification becomes email_notifications, PushNotification becomes push_notifications. Simple enough.
The part that trips up almost everyone is eager loading. Because the type-specific record is a polymorphic association, a naive loop over notifications fires one query per row:
# N+1 — do not do this
Notification.unread.each do |n|
puts n.notifiable.to_address if n.email_notification?
end
The fix is includes:
# One query for notifications, one per type in the result set
Notification.unread.includes(:notifiable)
Rails groups the polymorphic IDs by type and issues one query per type that appears in the result set. If you have 100 unread notifications and 60 are email, 30 are in-app, and 10 are SMS, you get four queries total: one for notifications, one for email_notifications, one for in_app_notifications, one for sms_notifications. That is the behaviour you want.
For the indexed dashboard query with pagination, add the type column to a composite index:
add_index :notifications, [:user_id, :read_at, :notifiable_type]
I go into index strategy in more depth in Rails database indexing strategies — the composite index principle there applies directly.
Delegating Methods to the Type-Specific Record
One of the most useful patterns is delegating a common interface to the type record so callers never need to know which type they are dealing with:
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
delegated_type :notifiable,
types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
dependent: :destroy
delegate :channel_identifier, to: :notifiable
delegate :delivery_status, to: :notifiable, allow_nil: true
end
Each type implements the interface:
class EmailNotification < ApplicationRecord
include Notifiable
def channel_identifier = to_address
def delivery_status = email_message_id ? :delivered : :pending
end
class SmsNotification < ApplicationRecord
include Notifiable
def channel_identifier = phone_number
def delivery_status = twilio_message_sid ? :delivered : :pending
end
class PushNotification < ApplicationRecord
include Notifiable
def channel_identifier = device_token
def delivery_status = fcm_message_id ? :delivered : :pending
end
class InAppNotification < ApplicationRecord
include Notifiable
def channel_identifier = action_url || "(in-app)"
def delivery_status = :delivered
end
Now a notifications table renderer does not branch on type at all:
# In a view or presenter
notifications.each do |n|
render_row(title: n.title, channel: n.channel_identifier, status: n.delivery_status)
end
This is the pattern I reach for the most. The shared model defines the contract, the type records implement it, and the caller is blissfully unaware of the variant.
Creating Records
The creation pattern is slightly more verbose than STI, which is the trade-off you accept:
# Build both objects and let the shared model manage the link
notification = Notification.create!(
user: current_user,
title: "Your receipt for order #1234",
notifiable: EmailNotification.new(
to_address: current_user.email,
from_address: "billing@example.com"
)
)
# Or create the type-specific record first
email_notif = EmailNotification.create!(
to_address: params[:email],
from_address: "noreply@example.com"
)
notification = Notification.create!(
user: current_user,
title: "Verify your email",
notifiable: email_notif
)
I prefer the first form — the shared record and the type record are created in a single transaction, so you cannot end up with an orphaned email_notifications row that has no notifications parent.
Migrating From STI: The Playbook
If you have an existing STI model and want to move to delegated types, the migration has three phases. The zero-downtime constraints mean you cannot do this in one big bang — I cover the general approach in Rails zero-downtime migrations and the safety tooling in Rails Strong Migrations.
Phase 1: Add the new tables and the polymorphic columns without touching the existing STI table.
class AddDelegatedTypesToNotifications < ActiveRecord::Migration[8.0]
def change
add_column :notifications, :notifiable_type, :string
add_column :notifications, :notifiable_id, :bigint
add_index :notifications, [:notifiable_type, :notifiable_id], unique: true
create_table :email_notifications do |t|
t.string :to_address, null: false
t.string :from_address, null: false
t.timestamps
end
# … other per-type tables
end
end
Phase 2: Backfill. Run a Rake task or a one-off job that walks the existing STI rows, creates the type-specific record, and fills in the polymorphic columns:
# lib/tasks/migrate_notifications.rake
namespace :db do
task migrate_notifications_to_delegated_types: :environment do
Notification.where(notifiable_type: nil).find_each do |n|
type_record = case n.type
when "EmailNotification"
EmailNotification.create!(
to_address: n.read_attribute(:to_address),
from_address: n.read_attribute(:from_address)
)
when "SmsNotification"
SmsNotification.create!(phone_number: n.read_attribute(:phone_number))
# …
end
n.update_columns(
notifiable_type: type_record.class.name,
notifiable_id: type_record.id
)
end
end
end
Use find_each for batching and update_columns to skip callbacks and validations — you are moving data, not running business logic. See the notes on Rails callbacks for why skipping them here is the right call.
Phase 3: Flip the model and remove the old columns in a follow-up deploy once the backfill is complete and the new code has been validated in production.
class DropLegacyNotificationColumns < ActiveRecord::Migration[8.0]
def change
remove_column :notifications, :type
remove_column :notifications, :to_address
remove_column :notifications, :from_address
remove_column :notifications, :phone_number
# … all the STI-era columns
end
end
Never drop columns in the same deploy as the code change. Strong Migrations will enforce this if it is configured — and it should be.
When to Keep STI
Delegated types are not always the right answer. I keep STI when:
- There are two or three types that share virtually all their columns.
ShippingAddressandBillingAddressthat differ only in atypecolumn and adefaultboolean are a perfect STI fit. Creating two separate tables for them is ceremony without benefit. - The model is small and closed. If you are confident the type list will not grow beyond three entries, STI’s simplicity wins.
- The query pattern is almost always cross-type.
Account.allandAccount.where(...)that do not branch on type are perfectly efficient in STI. Delegated types always adds a JOIN when you need type-specific data.
The question I ask: “Will every type-specific attribute have to exist in the shared table, and will it be NULL for all but one type?” If the answer is yes for more than a couple of columns, delegated types is the right move.
The Performance Reality: It Is a JOIN
Reading a delegated type record always involves a JOIN or a second query. That is the cost you pay for the clean schema. In practice, it is not a problem — the queries are simple, the indexes are obvious, and a properly built index on the shared table covering [user_id, read_at] means the planner never touches the per-type tables until it has already filtered the result set to a small number of rows.
The place where performance surprises you is when you forget to includes(:notifiable) and get N+1 queries on a list page. Add a Bullet configuration or use strict_loading on the association if your team has not already adopted it:
class Notification < ApplicationRecord
delegated_type :notifiable, types: %w[...], dependent: :destroy
end
# In config/environments/development.rb
config.active_record.strict_loading_by_default = true
With strict loading on in development, Rails raises an error the first time someone lazy-loads a delegated type association in a loop. The fix is always the same: add includes.
FAQ
What is the difference between Rails delegated types and polymorphic associations?
A polymorphic association (belongs_to :commentable, polymorphic: true) defines a relationship between models. Delegated types define a hierarchy: one “base” concept (notification, entry, payment) with multiple concrete implementations. You would use a polymorphic association when Comment can belong to either a Post or a Video — two unrelated models. You would use delegated types when Notification, EmailNotification, and SmsNotification represent the same concept at different levels of specificity.
Does Rails delegated types work with STI at the same time?
You can combine them, but you probably should not. If you find yourself reaching for both in the same hierarchy, that is usually a sign the model is doing too much. Split the concept first, then pick one inheritance strategy per hierarchy.
How do I handle forms with Rails delegated types?
Use Rails’ fields_for with the notifiable sub-record, or use a form object that wraps both the shared and type-specific attributes. The accepts_nested_attributes_for :notifiable approach works if you know the type before rendering the form; if the type is user-selected, a service object that builds both records is cleaner.
# app/forms/create_notification_form.rb
class CreateNotificationForm
include ActiveModel::Model
attr_accessor :user, :title, :type, :to_address, :phone_number
def save
type_record = case type
when "email" then EmailNotification.new(to_address: to_address, from_address: "noreply@example.com")
when "sms" then SmsNotification.new(phone_number: phone_number)
end
Notification.create!(user: user, title: title, notifiable: type_record)
end
end
Does DESTROY on the shared record cascade to the per-type record?
Yes, if you pass dependent: :destroy to delegated_type. This is the default I use — an orphaned email_notifications row with no corresponding notifications entry is worse than accidentally deleting too much. If you have a retention requirement for the type-specific data, use dependent: :nullify and handle the cleanup explicitly.
Dealing with a Rails codebase where STI has become the load-bearing wall nobody wants to touch? TTB Software has been untangling exactly these architectures for nineteen years. We know which migrations to run in what order, and which shortcuts will cost you on the other side.
Related Articles
Rails Postgres LISTEN/NOTIFY: Real-Time Events Without Redis or a Message Broker
Rails Postgres LISTEN/NOTIFY guide: real-time events with ActionCable, connection patterns, production caveats. When ...
Rails Materialized Views with Scenic: Fast Postgres Read Models Without Killing Writes
Rails materialized views with the Scenic gem: build fast read models, version SQL in migrations, and refresh concurre...
Rails Audit Logging with PaperTrail: Change History, Compliance Trails, and Restoring Deleted Records
Rails audit logging with PaperTrail: track every model change, restore deleted records, meet SOC 2/GDPR, and query ch...