Files
member-console/design/documents/reference-polymorphic-associations.md
T
cgalo5758 bfe9cee0fe Consolidate design docs into documents directory
- Remove per-module projection files (README, architecture, companion,
  interfaces, model) under design/<module>/
- Add design/documents/ with numbered design docs, references, policies,
  and manifest
- Update design/README.md to describe the directory as a mirror of
  membcons-db's normative surfaces
- Record Decisions 140-141 in companion and glossary; update
  data-model.md schema organization
2026-08-21 00:55:42 -05:00

19 KiB

Polymorphic associations in relational databases: patterns, trade-offs, and the exclusive arc

Relational databases were not designed for polymorphism — and every solution is a compromise. Relational algebra (1970) predates object-oriented inheritance patterns (Smalltalk 1972, Hope 1975), which means modeling "a comment can belong to either a Post or a Photo" has no clean native solution in SQL. The industry has converged on roughly six primary patterns, each trading referential integrity, query performance, or schema complexity against each other. The most commonly deployed pattern — the ORM-native type/id column pair — is also the most widely criticized by database experts, who generally recommend the exclusive arc (multiple nullable FKs with a CHECK constraint) or class table inheritance (a shared supertype table) as superior alternatives that preserve real foreign key constraints.

This report examines each pattern in depth, drawing on authoritative sources including Bill Karwin's SQL Antipatterns, Martin Fowler's Patterns of Enterprise Application Architecture, the PostgreSQL documentation, production experiences from GitLab and 37signals, and major ORM implementations across Rails, Hibernate, Django, and SQLAlchemy.


The naive polymorphic pattern sacrifices integrity for convenience

The most widely deployed approach — used by Rails belongs_to :commentable, polymorphic: true, Django's GenericForeignKey, and Hibernate's @Any — stores a type string column alongside an id column in the child table. A comments table might contain commentable_type = "Post" and commentable_id = 42, meaning "this comment belongs to Post #42." The ORM resolves the reference at runtime by reading the type string to determine which table to query.

CREATE TABLE comments (
  id bigint PRIMARY KEY,
  commentable_id bigint NOT NULL,
  commentable_type varchar NOT NULL,
  content text NOT NULL
);

The fundamental problem is that a foreign key constraint must reference exactly one parent table. Bill Karwin, author of SQL Antipatterns (Pragmatic Bookshelf, 2010), states this definitively: "A foreign key must reference only one parent table. This is fundamental to both SQL syntax, and relational theory. A Polymorphic Association is when a given column may reference either of two or more parent tables. There's no way you can declare that constraint in SQL." Karwin classifies this as a logical database design antipattern because it uses data (a string value) to refer to metadata (a table name), which SQL does not support.

The consequences in production are significant. Without FK constraints, the database cannot prevent orphaned references — inserting commentable_type = 'Group', commentable_id = 999999 succeeds even if no such record exists. Cascading deletes do not work. JOINs require filtering on both columns simultaneously, and ORMs like ActiveRecord cannot eagerly load polymorphic associations because they don't know which table to JOIN — they must issue separate queries per type, creating N+1-style performance problems. The type column stores Ruby/Python class names as strings, coupling the database schema to application code; Shopify documented the painful multi-deployment migration required when they needed to rename a polymorphic type. A 2021 peer-reviewed survey in Engineering Reports (Wiley) formally classifies polymorphic associations as an antipattern, noting that "the query optimizer can hardly provide good plans for joins whose predicate can only be met by one of the parent tables."

GitLab's engineering documentation is unequivocal: "Summary: always use separate tables instead of polymorphic associations." They encountered these exact problems with their production members table and now mandate alternatives in their database design guidelines.


The exclusive arc preserves referential integrity with nullable foreign keys

The exclusive arc — also called "exclusive FK," "exclusive belongs_to," or "exclusive relationship group" — replaces the type/id pair with multiple nullable foreign key columns, one per possible parent type, plus a CHECK constraint ensuring exactly one is non-null at any time.

CREATE TABLE comments (
  id bigint PRIMARY KEY,
  content text NOT NULL,
  post_id bigint REFERENCES posts,
  photo_id bigint REFERENCES photos,
  video_id bigint REFERENCES videos,
  CHECK (
    (CASE WHEN post_id IS NULL THEN 0 ELSE 1 END +
     CASE WHEN photo_id IS NULL THEN 0 ELSE 1 END +
     CASE WHEN video_id IS NULL THEN 0 ELSE 1 END) = 1
  )
);

In PostgreSQL, the CHECK can use the more concise cast syntax: CHECK ((post_id IS NOT NULL)::int + (photo_id IS NOT NULL)::int + (video_id IS NOT NULL)::int = 1). Each FK column is a standard REFERENCES constraint, giving you real referential integrity, working cascading deletes, and standard query optimizer behavior. Partial unique indexes (CREATE UNIQUE INDEX ON comments (post_id) WHERE post_id IS NOT NULL) keep index sizes small and efficient.

The pattern's historical roots trace to Richard Barker's CASE*Method: Entity Relationship Modelling (1990), developed at Oracle Corporation. In Barker ER notation, an "arc" drawn across two or more relationship lines indicates mutual exclusivity — "a seminar may be taught by a staff member or an external consultant, but not both." Oracle's Data Modeler documentation still defines it formally: "An arc is an exclusive relationship group, defined such that only one of the relationships can exist for any instance of an entity."

The main objection is schema bloat: adding a new parent type requires adding a nullable column, updating the CHECK constraint, and running a migration. Jack Christensen of Hashrocket, in his influential 2016 analysis, mitigates this concern for PostgreSQL specifically: NULL values cost approximately 1 bit per row (rounded to the nearest byte — 30 nullable fields add only 4 bytes), adding a nullable column is an instant operation regardless of table size, and partial indexes ignore NULLs entirely. Christensen concludes: "I suggest using an exclusive belongs-to model to represent a polymorphic association."

In Rails, the activerecord-exclusive-arc gem by Justin Talbott provides first-class support: has_exclusive_arc :commentable, [:post, :page] generates the migration with FK references, partial indexes, and the CHECK constraint. Talbott writes: "The exclusive arc is not evil — rather, it is a benevolent polymorphism pattern that seeks to maintain your data's integrity." The Objection.js ORM for Node.js also explicitly recommends this pattern over polymorphic associations in its documentation.


Class table inheritance offers the cleanest normalized design

Martin Fowler defined Class Table Inheritance in Patterns of Enterprise Application Architecture (2003) as "one database table per class in the inheritance structure." A shared base table holds common columns, and each subtype gets its own table with a foreign key back to the base:

CREATE TABLE commentables (
  id SERIAL PRIMARY KEY,
  type CHAR(1) NOT NULL CHECK (type IN ('P', 'H', 'V')),
  UNIQUE (id, type)
);
CREATE TABLE posts (
  id INT PRIMARY KEY,
  type CHAR(1) NOT NULL DEFAULT 'P' CHECK (type = 'P'),
  title TEXT NOT NULL,
  FOREIGN KEY (id, type) REFERENCES commentables(id, type)
);
CREATE TABLE comments (
  id SERIAL PRIMARY KEY,
  commentable_id INT NOT NULL REFERENCES commentables(id),
  content TEXT NOT NULL
);

Bill Karwin explicitly recommends this "common supertable" approach as his preferred solution to the polymorphic associations antipattern. The compound FK on (id, type) enforces that subtypes are disjoint — a record cannot simultaneously be both a Post and a Photo. Comments reference only the base commentables table, giving full referential integrity without any nullable columns or CHECK constraints.

The trade-off is JOIN cost. Fowler warns that "joins for more than three or four tables tend to be slow" and that the supertype table "may become a bottleneck as it has to be accessed frequently." However, queries needing only shared attributes can hit the base table alone, and per-type queries require only a single JOIN. With proper indexing on the shared primary key, these JOINs are typically fast — the FK column equals the PK column, enabling efficient index-nested-loop joins.

Rails 6.1 introduced delegated_type, which maps directly to this pattern. Developed by DHH from production usage at Basecamp and HEY, it provides a shared "superclass" table (entries) with type-specific "delegate" tables (messages, comments). Jeffrey Hardy, Principal Programmer at 37signals, states: "This is what we're actually using and we've proven it out over a decade...it's replaced our use of single table inheritance and even polymorphic relationships." Django's multi-table inheritance works similarly, automatically creating a OneToOneField linking child to parent. Hibernate implements it via @Inheritance(strategy = InheritanceType.JOINED), and SQLAlchemy provides joined table inheritance with its polymorphic_identity system.


Single table inheritance trades normalization for query simplicity

Fowler's Single Table Inheritance stores all subtypes in one table with a discriminator column and many nullable columns. Where CTI optimizes for schema purity, STI optimizes for read performance — no JOINs, no UNIONs, just a single table scan filtered by type:

CREATE TABLE vehicles (
  id SERIAL PRIMARY KEY,
  type VARCHAR(50) NOT NULL,
  make VARCHAR(100),
  num_doors INTEGER,        -- Car only
  engine_cc INTEGER,         -- Motorcycle only
  payload_capacity DECIMAL   -- Truck only
);

Rails has first-class STI support via a type column — querying Car.all automatically adds WHERE type = 'Car'. Hibernate makes InheritanceType.SINGLE_TABLE its default JPA strategy, and Vlad Mihalcea recommends using INTEGER discriminator types over STRING for better indexing. SQLAlchemy supports it with polymorphic_on and polymorphic_identity mapper arguments.

The pattern works well when subtypes share most columns and differ primarily in behavior. Steven Li's heuristic: "A good indication that STI is right is when the different subclasses have the same fields/columns but different methods." But it degrades badly as subtypes diverge. You cannot apply NOT NULL constraints on subtype-specific columns (the database must allow NULLs for rows of other types), the table grows wide and sparse, and mixed-type rows impede index efficiency. GitLab explicitly bans new STI tables, noting they "lead to tables with large numbers of rows, need additional indexes, and add overhead by having to filter all data by a value." Nathan Long observes that "normal STI makes it impossible to declare NOT NULL constraints because the database must allow NULLs for non-applicable attributes."


Three more patterns complete the primary toolkit

The join table pattern (sometimes called "reverse polymorphism") uses an intermediate association table per relationship type. Instead of comments holding a polymorphic reference, you create post_comments(post_id, comment_id) and photo_comments(photo_id, comment_id), each with proper FK constraints. This provides full referential integrity and works well for many-to-many relationships, but adds extra tables and JOINs. In Rails, this maps to has_many :through with a polymorphic join model. Christensen of Hashrocket ranks this as his second choice: "If situation or developer sensibilities preclude [the exclusive arc], then use a reverse belongs-to model." Ecto (Elixir's ORM) deliberately omits Rails-style polymorphic associations entirely, recommending join tables instead as a design choice prioritizing data integrity.

Entity-Attribute-Value (EAV) stores attributes as rows (entity, attribute, value) rather than columns — the ultimate flexibility pattern. Joe Celko's critique is devastating: "You will see it called EAV in the literature. It is an attempt to put metadata into a RDBMS and it falls apart in about one year in production work. You cannot write any constraints, DRI is impossible and every typo becomes a new attribute." All values must be stored as TEXT (destroying type safety), querying requires self-joins that grow quadratically, and Laurenz Albe of CYBERTEC calls it "the worst possible design when it comes to performance." It remains appropriate only for truly dynamic schemas where attributes are unknown at design time (medical systems with thousands of possible observations, CRM custom fields). Even then, PostgreSQL JSONB or a document store is generally preferred.

Concrete Table Inheritance, Fowler's third pattern, gives each subtype its own fully independent table duplicating all shared columns. Per-type queries are blazing fast (single table, no JOINs), but cross-type queries require UNION ALL across all tables, primary keys can collide unless managed globally, and schema changes to shared fields must propagate to every table. SQLAlchemy's documentation warns that it "produces very large queries with UNIONS that won't perform as well as simple joins." This pattern fits best when types are truly independent and polymorphic querying is rare.


PostgreSQL-specific and non-relational alternatives

PostgreSQL's INHERITS keyword lets child tables inherit columns from a parent, with queries on the parent automatically including child rows. However, it has a fatal flaw for polymorphism: foreign key constraints referencing the parent table only check rows physically stored in the parent, not in children. Similarly, UNIQUE and PRIMARY KEY constraints don't span the hierarchy — a child can duplicate a "unique" parent value. The PostgreSQL documentation itself acknowledges that "inheritance has not been integrated with unique constraints or foreign keys, which limits its usefulness." The community consensus is that INHERITS is better suited as the underlying mechanism for partitioning (now superseded by declarative PARTITION BY in PostgreSQL 10+) than for modeling OO inheritance.

JSONB columns offer a hybrid approach: stable, shared fields as regular columns with proper constraints, and type-variable fields in a JSONB blob. GIN indexes enable efficient containment queries (WHERE attributes @> '{"color": "blue"}'), and expression indexes support B-tree queries on specific keys. The flexibility is compelling — adding new type-specific fields requires no migrations. But PostgreSQL does not maintain column-level statistics for JSONB keys, which can cause the query planner to make catastrophically poor decisions. Heap's benchmarks showed ~2,000x slower performance when an entire table was stored as JSONB versus normalized columns, and JSONB storage consumed 2x the space due to undeduplicated key names. The pragmatic approach is to use JSONB for genuinely semi-structured metadata alongside properly typed relational columns — not as a replacement for structured data.

Document databases like MongoDB sidestep the problem entirely. Documents in a collection can have any shape, so a comments collection can naturally hold comments with different parent reference structures. MongoDB officially documents both "polymorphic schema" and "inheritance" patterns, and supports $jsonSchema validation with oneOf for runtime type enforcement. This contextualizes why the polymorphic association problem is fundamentally a relational constraint issue — it arises specifically because SQL enforces rigid column schemas per table and requires FK references to target a single table.


How every pattern compares across critical dimensions

Pattern FK constraints Adding new types Query complexity NULL overhead ORM support
Polymorphic (type/id) None No schema change Moderate (type filter, N+1) None Excellent (Rails, Django, Hibernate)
Exclusive arc Real FKs Add column + update CHECK Simple (standard JOINs) ~1 bit/row per type in PG Gem/manual (activerecord-exclusive-arc)
Class table inheritance Real FKs Create new subtype table JOIN per subtype None Good (Rails delegated_types, Django MTI, Hibernate JOINED)
Single table inheritance No subtype constraints Add columns or nothing Simple (single table) Many nullable columns Excellent (Rails native, Hibernate default)
Join tables Real FKs Create new join table Extra JOIN per relationship None Manual/moderate
EAV Impossible No schema change Terrible (self-joins) N/A None recommended
Concrete table Per-type only Create new table UNION ALL across all types Column duplication SQLAlchemy, Hibernate optional
PG INHERITS Broken across hierarchy CREATE child table Good (auto-includes children) None Minimal
JSONB column Within JSON No schema change Moderate (GIN indexes) Key name duplication Manual

For referential integrity, the exclusive arc, class table inheritance, and join table patterns are the only options that provide real database-level enforcement. For schema evolution, the polymorphic type/id and JSONB patterns win — no migrations needed. For query performance on a single type, concrete table inheritance and STI are fastest. For polymorphic queries across all types, STI and the type/id pattern require no JOINs, while CTI needs joins and concrete table inheritance needs UNIONs.


Conclusion: match the pattern to your actual constraints

The research points to a clear hierarchy of recommendations shared across experts and production teams. If referential integrity matters — and in most production systems it should — the exclusive arc and class table inheritance are the strongest choices. The exclusive arc is simpler when the number of parent types is small and stable (fewer than ~8-10 types); class table inheritance scales better when types proliferate or the hierarchy is deep. Rails' delegated_type and Django's multi-table inheritance make CTI practical with good ORM ergonomics.

The polymorphic type/id pattern remains dominant in practice due to its zero-migration extensibility and first-class ORM support, but this is convenience at the cost of data integrity — a trade-off that experts from Karwin to GitLab's engineering team consistently advise against. STI works well for shallow hierarchies where subtypes share most fields and differ mainly in behavior. EAV should be a last resort, reserved for truly dynamic schemas, and even then JSONB is usually the better modern alternative.

Perhaps the most important insight comes from Fowler: "The trio of inheritance patterns can coexist in a single hierarchy." Real systems often combine patterns — CTI at the top level with STI for closely related leaf types, JSONB for truly variable metadata, and the exclusive arc for focused polymorphic references. The right answer depends not on which pattern is theoretically purest, but on which constraints — integrity, performance, evolvability, team familiarity — matter most for your specific system.