# 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. ```sql 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. ```sql 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: ```sql 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: ```sql 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.