A polymorphic association — usually a commentable_type string paired with a commentable_id integer — lets one comments table attach to posts, photos, and tasks without three near-identical tables. The catch: no relational database can define a real foreign key against a column whose target table changes at runtime, so orphaned rows and silent corruption become a permanent cost.

Quick answer: Polymorphic associations (a _type + _id column pair) trade referential integrity for schema flexibility, because a foreign key constraint can only ever point at one table. Prefer separate join tables per type, a shared parent entity, or exclusive-arc nullable foreign keys instead — the right call depends on how many target types exist and how much integrity enforcement actually matters.

The Commentable Pattern: One Table, Many Parents

A polymorphic association stores a type discriminator and a generic identifier instead of a normal foreign key, letting one table logically reference rows across several other tables. Rails calls it polymorphic: true; Laravel calls it morphTo(); Django calls it a GenericForeignKey. All three ship the identical underlying schema shape once you strip away the ORM syntax.

In practice, the comments table ends up looking like this:

comments
  id
  body
  commentable_type   -- 'Post', 'Photo', 'Task'
  commentable_id      -- 42, 17, 9
  created_at

A row with commentable_type = 'Post' and commentable_id = 42 is "attached" to posts.id = 42. A row with commentable_type = 'Task' and commentable_id = 9 is attached to tasks.id = 9. One table, one index, one API endpoint — no matter how many "commentable" surfaces the product grows into.

If you're still deciding whether "commentable" is a legitimate entity in your model or just a convenient umbrella label, that question is worth answering explicitly using the criteria in what counts as an entity in data modeling before you build around it.

Why teams reach for it

The appeal is almost always velocity, not architecture:

  • Fewer migrations. Adding a fourth commentable type is an application-code change, not a schema change.
  • One index to maintain instead of three or four near-duplicate ones.
  • One code path for creating, listing, and moderating comments, regardless of parent type.
  • It reads as DRY. Three copy-pasted post_comments, photo_comments, task_comments tables genuinely do look redundant on a whiteboard.

Every one of those is a real, legitimate pressure. None of them is free — they're funded by the constraint you didn't write, which is the subject of the next section. If you haven't sketched the alternatives side by side yet, this is exactly the kind of decision that benefits from drawing the ERD first rather than committing to DDL from memory.

Why the Database Can't Enforce a Single Foreign Key

A foreign key constraint can reference exactly one table — that specificity is what makes it enforceable. Because commentable_type and commentable_id jointly encode "which table, which row," no single REFERENCES clause can express that logic, so the guarantee a comment's parent actually exists simply doesn't exist at the database layer.

This isn't a gap anyone can patch with a clever constraint. PostgreSQL's and MySQL's own reference documentation are explicit that a FOREIGN KEY ... REFERENCES clause names one table, full stop. There is no REFERENCES posts OR photos OR tasks syntax in the SQL standard, because the constraint has to name a single target for the engine to check on every insert and delete.

Bill Karwin's book SQL Antipatterns (Pragmatic Bookshelf, 2010) catalogs this exact structure — Karwin names it "Polymorphic Associations" directly — as one of roughly two dozen recurring schema antipatterns he saw repeatedly across real consulting engagements.

The practical fallout shows up in four places:

  1. Orphaned rows. Delete posts.id = 42 and every comment with commentable_type = 'Post', commentable_id = 42 is now attached to nothing. No cascade fires, because no foreign key exists to cascade from. This interacts directly with how you handle deletion generally — see soft delete vs hard delete in data modeling for why orphan risk compounds when the parent-side deletion strategy isn't decided up front.
  2. Silent typos. commentable_type is free text. 'Post', 'post', and 'Posts' are three different, equally valid-looking strings to the database, and only one of them actually matches application code.
  3. No native join. Fetching "this comment's parent" requires a CASE/conditional dispatch in application code, because SQL can't JOIN against a table it doesn't know the name of until it reads the row.
  4. No query-planner help. A real foreign key gives the planner metadata it can use for join optimization and cardinality estimates. A polymorphic pair is invisible to it.

E. F. Codd's original relational model is the underlying reason this matters more than it looks. Codd's twelfth rule for relational database management (Integrity Independence, from his 1985 "true relational" rules) requires that integrity constraints be definable in the catalog and enforced by the system — not delegated to application code that every future engineer has to remember to re-implement correctly. A polymorphic association routes around that rule by design.

GuaranteeStandard Foreign KeyPolymorphic type + id Pair
Enforced by the database engineYesNo
Blocks orphaned rows on parent deleteYes, with an ON DELETE ruleNo — requires app-level cleanup
Catches a mistyped target tableN/A — fixed at schema timeNo — commentable_type is free text
Single JOIN to fetch the parentYesNo — needs conditional dispatch
Usable by the query plannerYesNo

The table above is the whole argument in one place: every guarantee a real foreign key gives you for free becomes an application-code responsibility the moment you switch to a type-and-id pair. That's a legitimate trade in some cases — it's just rarely made consciously.

Three Alternatives That Preserve Integrity

Three patterns keep a real, database-enforced foreign key while still letting one logical "comment" concept attach to multiple parent types. They differ mainly in how many target types they tolerate before the schema gets unwieldy, and in how much extra ceremony each insert requires.

Separate join tables per type

The most direct fix is also the one polymorphic associations were invented to avoid: one table per relationship, each with a genuine foreign key.

post_comments   (id, post_id  FK -> posts.id,  body, created_at)
photo_comments  (id, photo_id FK -> photos.id, body, created_at)
task_comments   (id, task_id  FK -> tasks.id,  body, created_at)
  • Integrity: full — each table's foreign key is enforced by the engine on every write.
  • Cost: a new table (and matching model/API surface) for every new commentable type; a cross-type "all comments by this user" query needs a UNION ALL across tables instead of one SELECT.
  • Best fit: a small, stable number of target types where cross-type queries are rare.

A shared parent entity

Introduce a supertype table that every commentable thing must register with first, then point comments at that table instead of at posts, photos, or tasks directly.

commentable_items (id, kind)      -- kind = 'post' | 'photo' | 'task'
posts             (id, commentable_item_id FK UNIQUE -> commentable_items.id, ...)
photos            (id, commentable_item_id FK UNIQUE -> commentable_items.id, ...)
comments          (id, commentable_item_id FK -> commentable_items.id, body)

This is a direct application of what Martin Fowler, in Patterns of Enterprise Application Architecture (2002), calls Class Table Inheritance — a supertype table plus one subtype table per concrete type, connected by shared-key foreign keys. Comments get one real foreign key, to one real table, and the engine enforces it normally.

  • Integrity: full, and cross-type queries against comments become a single, ordinary JOIN.
  • Cost: every post/photo/task creation becomes a two-step insert (parent row, then subtype row) inside a transaction — more ceremony per write, in exchange for one real relationship per read.
  • Best fit: a moderate, possibly growing number of types where you frequently query across types (a unified activity feed, a single moderation queue).

Exclusive-arc columns

Give the comments table one nullable foreign key column per target type, and add a CHECK constraint guaranteeing exactly one is populated:

comments (
  id, body,
  post_id  FK -> posts.id  NULL,
  photo_id FK -> photos.id NULL,
  task_id  FK -> tasks.id  NULL,
  CHECK (
    (post_id  IS NOT NULL)::int +
    (photo_id IS NOT NULL)::int +
    (task_id  IS NOT NULL)::int = 1
  )
)

This is the exclusive arc, a pattern documented in Richard Barker's CASE*Method: Entity Relationship Modelling (1990) as a formal notation for "exactly one of these relationships holds." Three real foreign keys, three real ON DELETE behaviors, one CHECK doing the discriminating work that commentable_type used to do informally.

  • Integrity: full — three enforced foreign keys, plus a constraint the engine checks on every write.
  • Cost: the table gets structurally wider with every new type, and — if you're on an older engine — worth confirming enforcement first: MySQL silently parsed but did not enforce CHECK constraints before version 8.0.16, a real and easy-to-miss historical gap.
  • Best fit: two to four stable, well-known target types, where a wide-but-simple table beats a supertype join.

The Decision Rule: Weigh Type Count Against Integrity Needs

The right pattern depends on two variables you can answer in five minutes: how many target types exist (and whether that number is stable or growing), and how much it actually costs the business if a comment silently loses its parent. Everything else — query ergonomics, migration friction — follows from those two answers.

PatternBest when type count isIntegrity guaranteeCross-type query costSchema growth cost
Polymorphic type + idMany, and changes oftenNone — app-enforced onlyLow — one table alreadyLow — add a string value
Separate join tablesFew (2-3), stableFull, per-table FKMedium — needs UNIONMedium — new table per type
Shared parent entityModerate, may growFull, via one FKLow — one JOINMedium — two-step insert
Exclusive-arc columnsFew (2-4), stableFull, FK + CHECKLowHigh — new column per type

Read the table as a filter, in order:

  1. Count the types today, and ask if that count is closed. If new commentable surfaces get added the way features get shipped — closer to "several a year, indefinitely" than "three, permanently" — a shared parent entity or, honestly, a polymorphic association are the only two patterns that don't force a schema migration for every addition.
  2. Ask what job the table is actually hired to do. A framework like Jobs to Be Done is useful here for a reason that has nothing to do with customer research: an audit-trail comment on a billing dispute is hired for a very different job than a throwaway emoji reaction on a task, and only one of those jobs plausibly justifies integrity as a hard requirement.
  3. If integrity genuinely doesn't matter, say so in the schema, not just in your head. An activity-log or analytics-events table that's write-once and never joined back for correctness-critical logic is a legitimate place for a polymorphic pair — as long as that's a documented decision, not a default nobody examined.
  4. If the product surface is still expanding, plan for the shape it's becoming, not just the shape it is today. Products that add a new "thing you can comment on" every time they extend into a new part of the customer journey — onboarding checklists, then shared documents, then dashboards — tend to regret a rigid per-type-table design faster than they regret the extra JOIN a shared parent entity costs them.

If a table's integrity genuinely doesn't matter, write that decision into the migration or schema comment — not just into a Slack thread that won't exist by the time the next engineer asks why there's no foreign key.

Modeling both paths before you commit

The fastest way to see this trade-off concretely is to generate the DDL for both options and read what's missing. In Prodinja's Data Modelling tool, you can lay out the polymorphic shortcut — a comments table with commentable_type and commentable_id — right next to the shared-parent or exclusive-arc version, and compare the generated SQL side by side.

The polymorphic version's DDL simply has no FOREIGN KEY clause on the parent relationship; the alternative's does. Seeing that absence in actual DDL, rather than reasoning about it abstractly, is usually what settles the argument on a team.

It's also a natural extension of whatever you've already put in an ERD-first data modeling pass — the polymorphic decision is one entity-relationship choice among many, not a special case that needs its own process.

Key Takeaways

  • A polymorphic association (_type + _id) can't carry a real foreign key, because a REFERENCES clause can only ever name one table — the constraint you're used to getting for free simply isn't there.
  • The failure modes are concrete and recurring: orphaned rows on parent delete, silent typos in the type string, no native JOIN, and no query-planner visibility.
  • Separate join tables per type are the simplest fix for a small, stable set of target types, at the cost of a UNION for any cross-type query.
  • A shared parent entity (Fowler's Class Table Inheritance) keeps one real foreign key and one clean cross-type JOIN, at the cost of a two-step insert transaction per row.
  • Exclusive-arc nullable columns with a CHECK constraint work well for two to four stable types but make the table structurally wider with each new one — and need a modern engine to actually enforce the check.
  • The decision rule is two questions, not a style preference: how many target types exist (and is that number closed), and how much does it cost the business if integrity silently fails.
  • Polymorphic associations are defensible for low-stakes, append-only data (activity feeds, analytics events) — but only as a documented decision, not an unexamined default.

Frequently Asked Questions

Is a polymorphic association ever the right choice?

Yes, specifically for append-only, low-stakes data where a missing or orphaned row has no real consequence — an activity feed or an analytics event log, for example. It's the wrong choice anywhere a missing parent reference could cause a billing error, a broken audit trail, or silent data loss a user would notice.

What is an exclusive arc in database design?

An exclusive arc is a set of nullable foreign key columns on one table, constrained so exactly one is populated per row — for example post_id, photo_id, and task_id on a comments table with a CHECK enforcing "exactly one non-null." The term and notation come from Richard Barker's CASE*Method (1990); it preserves real, per-column foreign keys while still modeling a single logical relationship.

Can you enforce polymorphic association integrity with database triggers?

Partially — a trigger can check that commentable_id exists in whichever table commentable_type names, but it has to be written and maintained by hand for every current and future type, unlike a foreign key the engine enforces natively. Most teams find this ends up being more code than just switching to a shared parent entity or exclusive-arc columns in the first place.

Does Rails' or Laravel's polymorphic association support foreign keys?

No — Rails' polymorphic: true and Laravel's morphTo() both generate a type string column and an id integer column with no database-level foreign key constraint on either. Both frameworks document this plainly; the referential guarantee, if any, exists only in application-level validation code.

Polymorphic vs separate tables: which is faster to query?

For a single parent type, polymorphic and separate-table designs perform comparably. For cross-type queries ("all comments this user posted, on anything"), the polymorphic design wins, since it's already one table — separate join tables need a UNION ALL, and a shared parent entity needs one extra JOIN, though both remain simple, index-friendly queries in practice.