resource_id plus resource_type — where the type string chooses which table the ID points to. One column of data, many possible targets. ORMs make it a one-liner (polymorphic: true in Rails, GenericForeignKey in Django, morphTo in Laravel) and the database can’t help with any of it: no foreign key, no cascade, no planner metadata, no schema-level description of what the column actually references. Reads need conditional joins or unions. Orphans accumulate silently. For the cases people usually reach for it — comments, notifications, attachments — the alternatives (per-target tables, mutually-exclusive nullable FKs with a CHECK) restore schema integrity at modest cost. The pattern earns its keep only where the relationship is genuinely best-effort, like audit or activity logs.What the pattern looks like
|
|
The tell is resource_id BIGINT NOT NULL with no REFERENCES clause — it can’t have one, because there are multiple targets. What the application treats as a foreign key is, at the database level, a plain integer with a sibling tag string.
What the database can’t do
The cost shows up as absence — every mechanism the database offers for reasoning about relationships is disabled, because the column’s meaning depends on data in another column.
- No foreign key. A
REFERENCESclause names exactly one target. Orphanedresource_idvalues are a write-time non-event and a read-time mystery. (Foreign Keys Are Not Optional covers the general cost; polymorphic is the case where skipping isn’t a choice.) - No cascade. Delete an order and nothing cleans up the notifications pointing at it. The application has to know every table that might hold a polymorphic reference to
ordersand clean each one — new tables added later don’t get noticed. - No planner metadata. Foreign keys feed join ordering and row estimates, especially in PostgreSQL. The planner sees
resource_idas aBIGINTwith a histogram and no known target. - No schema-level description. Anything that reads the catalog — ERD tools, query generators, AI assistants, typed-client generators — sees no link between
notifications.resource_idand the tables it points at. The mapping lives in model files and string literals. (Comment Your Schema helps here but can’t fully restore the information.)
LEFT JOIN ... WHERE target.id IS NOT NULL, so the broken rows disappear from the UI but stay in the table. In schemas a few years old, the orphan rate is rarely zero — and nobody designed for it.Reads pay for the write-side convenience
The absent FK is the schema problem. The read-path shape is where the cost becomes daily. A query that needs any column from the referenced row can’t write a single join — the target depends on a per-row value, and SQL’s join syntax takes a static target.
|
|
Every new target type adds a join clause here and in every other read-path query that displays a related field. The alternative — a UNION ALL per target — is narrower per branch but scales linearly with target count and pushes pagination up to the union level. And most ORMs’ default resolution is one query per (resource_type, resource_id) group, which is the N+1 pattern that makes polymorphic feeds slow once the target set widens.
“One column can point at many tables” on the write side turns into “every read query enumerates every possible table” on the read side. The symmetry people expect isn’t there.
Why the pattern spreads
It’s not a designed-in choice; it’s the path of least resistance that framework ergonomics encourage. Rails’ polymorphic: true, Django’s GenericForeignKey, and Laravel’s morphTo make one-liner what would otherwise be multiple belongs_to associations and a migration. “Comments on orders” and “comments on invoices” look like duplication, so a single comments table with commentable_id / commentable_type feels cleaner. An open-ended “add comments to anything” product ask reads as an argument against committing to a target list.
Each of those framings overweights the write-side cost (another table or another FK column) and underweights the integrity loss (no enforcement, no cascades, schema no longer describes itself). ORMs Are a Coupling covers the broader trade — polymorphic is the canonical case where the ORM’s preferred shape is actively incompatible with what the database wants to enforce.
Alternatives
Each alternative gives back some of the database’s relational machinery at different levels of verbosity.
Per-target tables. Split along the target dimension: order_notifications, invoice_notifications, ticket_notifications, each with a real FK. Real cascades, real planner metadata, self-describing schema. Cost: duplicated column sets and an explicit UNION ALL for cross-target reads — but that union already exists implicitly in the polymorphic shape, just moved from the read query into typed branches.
Mutually-exclusive nullable FKs with CHECK. One table, one FK column per target, a constraint enforcing exactly one is non-null:
|
|
Real FKs per target, real cascades, row’s meaning unambiguous. Scales reasonably up to a handful of targets and stops scaling cleanly somewhere around ten.
Supertype table. A shared parent table carries a common ID; each target type’s table references the parent. The polymorphic column then points at the parent, which is a single real FK. Cleanest structural answer and the one with the highest adoption cost — retrofitting this onto an existing schema is substantial migration work.
When polymorphic is actually the right call
The trade-offs stack up unfavorably for most common uses, but not all. The pattern earns its keep when the relationship is genuinely best-effort — audit events, activity logs, “recently viewed” lists, undo history — where a lost reference is a recoverable annoyance rather than a correctness incident. The FK was never going to be load-bearing, and the polymorphic shape matches the actual semantics: “reference anything, and if it’s gone, show a tombstone.”
Outside that zone the default bias should run the other way. A comment system with three possible parents is not a case for polymorphism; it’s a case for three comment tables or mutually-exclusive FK columns, with the ORM abstracting the read-side stitching.
The bigger picture
Polymorphic references are a specific case of a broader pattern: designs that move information out of the schema and into the application, in exchange for ergonomics in the model layer. The schema drifts from “self-describing relational structure” toward “indexed key-value store the application interprets.” That’s a legitimate position — DynamoDB and friends live there on purpose — but a relational database running on polymorphic associations is paying for a relational engine and choosing not to use most of what it offers.
The pattern isn’t wrong. It’s an aggressive trade, priced on day one by the convenience of polymorphic: true and on day three hundred by the silent orphan count, the conditional joins, and resource_id BIGINT telling no one what the table is related to. Reach for it on purpose, not by default — and keep the option of pulling it back onto typed FK columns open, because the migrations away are slower the longer the schema has been pretending the reference isn’t there.