A Rails Polymorphic Type Is Not a Foreign Key

mooreds1 pts0 comments

Rails: The Sharp Parts. A Polymorphic Type Is Not a Foreign Key | baweaver

baweaver

baweaver

Software Engineering

Simplicity is hard work. But there's a huge payoff. The person who has a genuinely simpler system is going to be able to affect the greatest change with the least work.<br>— Rich Hickey

ruby<br>June 25, 2026

Rails: The Sharp Parts. A Polymorphic Type Is Not a Foreign Key

Last time we made a T::Struct the only thing allowed to cross a pack boundary: typed, inert, and reviewable in five seconds.

This article is about polymorphic associations, and sharp edges I’ve had to contend with, especially around delegators when trying a strangler fig refactoring pattern. I still have an open Rails issue on the delegator bug I need to find a way to land.

To not bury the lede, at scale my personal answer to polymorphic associations? Don’t.

Note : GitLab bans them in their developer docs, and Bill Karwin’s SQL Antipatterns named them an antipattern back in 2010. This isn’t a novel position.

The rest of this article is going to go into why I have that opinion, what sharp edges there are, and what you’re trading when you use one.

Aside : The patterns in this series come from my time working in large Rails monoliths (1M+ lines of code, 10+ years of history, hundreds to thousands of engineers). The failures below happen at any size, but in a small app they’re fixable in an afternoon. At scale, with thirty teams writing to the same polymorphic table, you lose the ability to even find all the places that need fixing.

Two Columns and No Constraint

Let’s go back to our theater app, which has events, orders, and seats. Say that we wanted the ability to leave notes on all three. The textbook answer is one polymorphic Note that can belong to any of them.

We’d add one using t.references :notable, polymorphic: true, but note that it doesn’t give you one column, it gives you two:

def schema_columns<br>Note<br>.columns<br>.select { |c| c.name.include?("notable") || c.name == "id" }<br>.map { |c| "#{c.name}: #{c.sql_type}" }<br>end<br># => ["notable_type: varchar(255)", "notable_id: bigint"]

It also builds you a composite index, and the order of the columns in that index is going to matter:

def schema_index<br>ActiveRecord::Base.connection.indexes(:notes).map { |index|<br>"#{index.name} on #{index.columns.inspect}"<br>end<br># => ["index_notes_on_notable on [\"notable_type\", \"notable_id\"]"]

Now ask the database what foreign keys protect that table:

def schema_foreign_keys<br>ActiveRecord::Base.connection.foreign_keys(:notes)<br>end<br># => []

Nothing. notable_id references nothing the database can enforce, because it can’t. A foreign key points at one table, and notable_id points at events or orders or seats depending on a string sitting in the type column right next to it.

A Find Is Two Clauses

Now suppose we have three notes, one for each of the owning tables (events, orders, and seats.) Each table has its own ID sequence, so all three parent rows end up with id of 1, and all three notes end up with notable_id of 1:

def notes_share_id<br>Note<br>.order(:id)<br>.map { |n| {id: n.id, notable_type: n.notable_type, notable_id: n.notable_id} }<br>end<br># => All three notes have notable_id=1, distinguished only by notable_type

The only way to tell these notes apart is the type string, meaning it’s load bearing. ActiveRecord will do this automatically for us:

def well_formed_find(event)<br>Note.where(notable: event).to_sql<br>end<br># => SELECT ... WHERE notable_type = 'Event' AND notable_id = 1

This works when the type and id are both present, but let’s say we forgot the type, what happens? Well the query stops meaning “the notes for this event” and starts meaning one of three or more possible rows with an id of 1.

The Type Comes From Asking an Object Its Class

How does ActiveRecord know the type? In the case of an event where does "Event" come from? It’s not from the column, ActiveRecord derives it when the query is built by looking at the value and asking what class it is. You can find the relevant code in PolymorphicArrayValue#klass:

def klass(value)<br>if value.is_a?(Base)<br>value.class<br>elsif value.is_a?(Relation)<br>value.model<br>end<br>end

If klass(value) returns nil, the type is nil, and the predicate builder emits a query missing the notable_type clause entirely. This happens for a value that isn’t an ActiveRecord::Base or an ActiveRecord::Relation. That’s not an issue, until it is.

Failure One: The Delegator Drops the Type

When does this become an issue? Well let’s say you’re refactoring Event and using a delegator to override some methods while everything else passes through:

class EventProxy SimpleDelegator<br>def price_cents<br>0 # pretend this calls the new pricing service<br>end<br>end

It’s an easy way to override a few methods, but there’s a problem here: a SimpleDelegator doesn’t forward class, is_a?, or kind_of?. Those answers come from the wrapper, not the wrapped object:

def proxy_identity(proxy)<br>class: proxy.class,<br>is_a_ar_base:...

type notable_id polymorphic notes value notable_type

Related Articles