Polymorphic associations
A Rails polymorphic association is two database columns and one line of Ruby, and most of what you need to know about it is in neither. The mechanism takes a paragraph. The consequences take the rest of this post: one constraint the database refuses to enforce, and one query Active Record refuses to write.
Everything below was run against activerecord 8.1.3.1 and read out of the LaunchKit boilerplate,
which carries six polymorphic belongs_to declarations across four tables. The sales site you are
reading this on has none, which is the honest reason every example here comes from the product
repository.
Two columns, and the migration that writes them
A polymorphic migration is one call to t.references with one option on it, and the option writes a
second column:
create_table :notifications do |t|
t.references :recipient, null: false, polymorphic: true
t.string :message, null: false
t.string :url
t.datetime :read_at
t.timestamps
end
ReferenceDefinition#columns in activerecord 8.1.3.1 is where the second column comes from, and
its body is five lines:
def columns
result = [[column_name, type, options]]
if polymorphic
result.unshift(["#{name}_type", :string, polymorphic_options])
end
result
end
unshift, not push. The type column goes first, and the index that references adds by default
follows the same order. In the boilerplate's db/schema.rb that lands as:
t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient"
Non-unique, composite, type column leading. The name is worth a second look, because a plain
two-column index would have been named
index_notifications_on_recipient_type_and_recipient_id. Polymorphic references get a shorter one
on purpose:
def polymorphic_index_name(table_name)
"index_#{table_name}_on_#{name}"
end
That branch only fires for the index references creates itself. The same boilerplate writes
t.references :chat, polymorphic: true, null: false, index: false on ruby_llm_usages and adds the
index by hand, which is why that one is called
index_ruby_llm_usages_on_chat_type_and_chat_id and the notifications one is not. Two indexes on
the same shape, two naming conventions, decided by which line of the migration created them.
Leading column order matters for reads. A query filtering on recipient_id alone cannot use that
index, because recipient_type is in front of it. Every query Active Record generates for the
association supplies both, so this only bites the maintenance queries you write yourself.
The other side of the association
The owning model declares the inverse with as:, naming the association on the other table rather
than a foreign key:
has_many :notifications, as: :recipient, dependent: :destroy
ruby_llm does the same thing for its accounting table. acts_as_chat in ruby_llm 2.0.0.rc1 adds
three associations to whatever model calls it, and the third is polymorphic:
has_many :ruby_llm_usages,
-> { chronological },
as: :chat,
class_name: 'RubyLLM::ActiveRecord::Usage',
dependent: :destroy
The Usage model on the far end is the matching half, and it is two lines in the gem:
belongs_to :chat, polymorphic: true
belongs_to :message, polymorphic: true, optional: true
The token accounting behind those declarations is covered in acts_as_chat and the six tables ruby_llm leaves behind, so this post stays on the association mechanics.
One asymmetry to carry into the next few sections: the has_many ... as: side can be joined and the
belongs_to polymorphic side cannot. User.joins(:notifications) produces, with no help from you:
INNER JOIN "notifications"
ON "notifications"."recipient_type" = 'User'
AND "notifications"."recipient_id" = "users"."id"
Active Record knows the target table here, because has_many names a class. It writes the type
condition into the ON clause and the join is exactly as ordinary as any other.
Cannot add a foreign key to a polymorphic relation
That sentence is not a summary, it is the literal string activerecord raises. Add foreign_key: true
to the migration line and you never reach the database:
ArgumentError: Cannot add a foreign key to a polymorphic relation
The guard is the last three lines of ReferenceDefinition#initialize:
if polymorphic && foreign_key
raise ArgumentError, "Cannot add a foreign key to a polymorphic relation"
end
The reason is SQL, not Rails. A REFERENCES clause names one table. recipient_id points at
whichever table the string in recipient_type happens to name on that row, and there is no version
of a foreign key constraint that resolves its target per row.
You can see the consequence by counting. The boilerplate's db/schema.rb ends with 13
add_foreign_key lines, covering chats, messages, sessions, subscriptions, transactions
and the rest. The migration that writes them says why the count stops there:
# ruby_llm_tool_calls and ruby_llm_usages point at their message and chat polymorphically, so
# they get no foreign key here: there is no single table for the constraint to reference.
active_storage_attachments shows both halves in one table. The migration gives it a polymorphic
record reference and an ordinary blob one, and exactly one of the two columns is protected:
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
The record_id column, which is the entire point of that table, has nothing.
What the missing constraint actually costs
Orphan rows, and no error when you make one. Here is the whole failure, run against activerecord
8.1.3.1 with belongs_to_required_by_default on:
u = User.create!
n = Notification.create!(recipient: u, message: "x")
u.delete # not destroy: no callbacks, no dependent: :destroy
n.reload.recipient # => nil
The row survives its recipient. n.recipient is nil rather than an exception, so any code doing
notification.recipient.email gets a NoMethodError at render time, a long way from the delete
that caused it. And the row is now permanently unsaveable:
n.valid? # => false, ["Recipient must exist"]
n.update!(message: "y")
# ActiveRecord::RecordInvalid: Validation failed: Recipient must exist
dependent: :destroy on the has_many ... as: side is the entire integrity story, and it only runs
when something calls destroy. delete, delete_all, a DELETE in a console, a fixture reset and
a restored database dump all walk past it. The boilerplate's notifications feature makes the same
trade and the notifications post
works through it on that one table.
Three things people actually do about it, in descending order of how often they are done:
- Nothing, plus
dependent: :destroy, plus a periodic sweep likeNotification.where(recipient_type: "User").where.not(recipient_id: User.select(:id)). This is the majority answer and it is not unreasonable, because the sweep is cheap on the composite index and the damage from an orphan notification is a missing row on a dropdown. - An exclusive arc: one nullable foreign key column per possible parent, plus a
CHECKconstraint asserting that exactly one of them is non-null. Every column gets a real constraint, and adding a seventh parent type means a seventh column and a rewrittenCHECK.activerecord-exclusive-arcwraps the boilerplate for this, though its last release is 0.3.1 from 2024-01-20, so check the diff against your Rails version before adopting it. - A database trigger that validates the pair on insert and update. Correct, enforced, and invisible to everyone reading the Rails code, which is why it tends to be written once and discovered by whoever debugs the next schema load.
Active Record refuses to join a polymorphic belongs_to
The error has a class of its own. On activerecord 8.1.3.1, every one of these four calls raises the same thing:
Notification.joins(:recipient)
Notification.eager_load(:recipient)
Notification.includes(:recipient).references(:recipient)
Notification.includes(:recipient).where(users: { email: "a@b.c" })
ActiveRecord::EagerLoadPolymorphicError: Cannot eagerly load the polymorphic association :recipient
The fourth one is the one that catches people, because nothing in it mentions eager loading. Naming
another table inside where makes Active Record decide it needs a join, and the join it needs is
the one it cannot build.
The raise is three lines inside ActiveRecord::Associations::JoinDependency, in the private
build that walks the association tree:
if reflection.polymorphic?
raise EagerLoadPolymorphicError.new(reflection)
end
and the message comes from the error class itself, in associations/errors.rb:
class EagerLoadPolymorphicError < ActiveRecordError
def initialize(reflection = nil)
if reflection
super("Cannot eagerly load the polymorphic association #{reflection.name.inspect}")
The reason is in the documentation for ActiveRecord::Associations, and it is one sentence worth
memorising: "The reason is that the parent model's type is a column value so its corresponding table
name cannot be put in the FROM/JOIN clauses of that query."
A SQL join names its table when the query is compiled. A polymorphic association picks its table
when the row is read. Those two facts cannot both be true in one statement, and Rails would rather
raise than generate a LEFT OUTER JOIN against every table in your schema and sort it out
afterwards.
Writing the join by hand
Starting from the polymorphic side means writing the ON clause yourself, with the type condition
in it. The LaunchKit boilerplate's admin AI dashboard has to do exactly this, because the numbers on
the page live in ruby_llm_usages and the dates it groups by live in chats:
# ruby_llm_usages belongs_to :chat polymorphically, so there is no association to join through.
# The chat_type condition is what keeps the join sound if a second model ever acts_as_chat.
CHATS_JOIN = <<~SQL.squish.freeze
INNER JOIN chats ON chats.id = ruby_llm_usages.chat_id
AND ruby_llm_usages.chat_type = 'Chat'
SQL
used twice in the controller, once for the monthly token chart and once for the per-user table:
tokens_by_month = usages.joins(CHATS_JOIN).where(chats: { created_at: since.. })
.group(CHAT_MONTH_SQL).sum(Arel.sql(TOKENS_SQL))
tokens = usages.joins(CHATS_JOIN).group("chats.user_id").sum(Arel.sql(TOKENS_SQL))
The AND ruby_llm_usages.chat_type = 'Chat' is not optional and it is not defensive programming. It
is the condition Active Record would have written for you in the other direction. Drop it and the
join matches any usage row whose chat_id collides with a chats.id, which today is every row in
the table and tomorrow, the moment a second class calls acts_as_chat, is silently wrong numbers on
a dashboard nobody audits.
Note what the hand written join is not doing: it is not loading Chat objects. It produces a
grouped sum, which is the case where the hand written join is genuinely better than the alternative.
When you want the objects instead, the next section is the answer.
Why includes still works
includes does not join unless something forces it to. Left alone it falls back to preload, which
runs a separate query per distinct type value:
Notification Load SELECT "notifications".* FROM "notifications"
User Load SELECT "users".* FROM "users" WHERE "users"."id" = ?
Team Load SELECT "teams".* FROM "teams" WHERE "teams"."id" = ?
Three queries for a mixed list, not one per row, and the count scales with the number of distinct types rather than the number of notifications. Two types, three queries, whether the list has 20 rows or 20,000. This is the ordinary fix for an N+1 on a polymorphic association, and the general case is N+1 queries in Rails.
What preload cannot do is filter or sort on the parent, because the parent has not been fetched
when the first query runs. Notification.includes(:recipient).order("users.email") is the same dead
end as the where above. Sorting a mixed polymorphic list by a column on the parent is not a
missing feature, it is a question with no SQL answer when the parents are in different tables, and
the honest fixes are to denormalise the sort key onto the child table or to query one type at a
time.
The type column holds a class name in a string
recipient_type stores Ruby, in a varchar, forever. polymorphic_name decides what goes in it:
def polymorphic_name
store_full_class_name ? base_class.name : base_class.name.demodulize
end
base_class, so single table inheritance collapses. An AdminUser < User saved as a recipient
writes "User" into the column, not "AdminUser", and reading it back gives you a correctly typed
AdminUser only because Active Record then consults the STI type column on users. A polymorphic
type column pointing at an STI table stores the root of the hierarchy and nothing else.
Rename the class and the rows do not follow. Set one row's type to a constant that no longer resolves and every path to it fails the same way:
n.recipient
# NameError: uninitialized constant Account
Notification.includes(:recipient).to_a
# NameError: uninitialized constant Account
The second one is the dangerous one, because that record was not asked for by name. One stale row in
a table poisons the whole preload for every reader of that list. The Rails guide states the
obligation without stating the failure: "Since polymorphic associations rely on storing class names
in the database, that data must remain synchronized with the class name used by the Ruby code." What
that means in practice is that renaming a model with a polymorphic child table is a data migration,
and forgetting the UPDATE produces a NameError in production and a green test suite, because
your fixtures wrote the new name.
When two types are all there will ever be
Two parent types, and confidence that there will only ever be two, is the case where polymorphic is
the wrong answer. Write two nullable foreign key columns and a CHECK constraint. You get real
referential integrity on both, joins that Active Record writes for you in both directions, no string
column repeated on every row, and the compiler-ish benefit that adding a third type requires a
migration somebody has to review rather than a new string value somebody can typo.
The position, stated as a rule: reach for polymorphic when the set of parent types is genuinely open,
and especially when it is open to people who are not you. The boilerplate's Notification#recipient
is the defensible version of that bet, and the comment above it does not pretend otherwise:
"Polymorphic so anything can be notified; today it is always a User." A product sold as a starting
point does not know what its buyers will notify. An application you control usually does.
What would flip it: a Notification that stays single-recipient for two years is a belongs_to
:user that paid a foreign key, an index column and a join for nothing. If that is your table, the
migration from the polymorphic pair to a plain user_id is an afternoon, and it is worth the
afternoon, because the constraint you get back is one the database enforces and the one you gave up
was never enforced by anything.
What this post does not cover
has_many :through a polymorphic association, which is its own set of restrictions and its own
errors. Polymorphic has_one. ruby_llm_batches, which stores a chat_type string next to a
chat_ids jsonb array rather than a single id, and so is a polymorphic pointer at a set of rows
rather than one: the jsonb side of that is
jsonb columns in Rails, not this post. Partitioned or sharded
targets. And the argument, which is a real one and older than Rails, that a polymorphic belongs_to
is a relational modelling error rather than a Rails feature, and that the exclusive arc is not a
workaround but the correct design that Active Record made inconvenient.
Comments
No comments yet. Be the first.