You have a supertype entity with several subtypes that share common attributes and each add their own — for example Payment with CardPayment, BankTransfer and Voucher. What are the standard ways to map that into relational tables, and what does each one cost?
answer
- single table = discriminator + nullable columns
- class table = shared PK is also FK
- concrete table = duplicate shared columns, UNION reads
- nullability vs join count is the trade
- CHECK conditioned on discriminator restores NOT NULL
basics
~20 sThree options. Single table: one table with all columns plus a type discriminator; subtype columns must be nullable. Class table: a parent table for shared columns and one child table per subtype sharing the parent's primary key; reads join. Concrete table per subtype: one independent table per subtype with the shared columns repeated; no join, but no single place holding all payments.
solid answer
~1 min**Single table (table-per-hierarchy).** One table holds every attribute of every subtype plus a discriminator column naming the subtype. Reads and polymorphic queries need no join and it is the fastest option. The price: subtype-specific columns must be nullable, so the database cannot enforce "a card payment must have a card token" with a plain NOT NULL — you need CHECK constraints conditioned on the discriminator. The table gets wide and sparse as subtypes multiply. **Class table (table-per-subclass / joined).** A parent table with the shared columns and the primary key, plus one table per subtype whose primary key is also a foreign key to the parent. Fully normalized, subtype columns can be NOT NULL, and adding a subtype is a new table rather than new nullable columns. Every read of a full subtype instance is a join, and polymorphic reads join or union across all subtype tables. **Concrete table per subtype.** Each subtype gets a standalone table repeating the shared columns. Single-table reads per subtype, but shared attributes are duplicated, cross-subtype queries need a UNION, and there is no single identity space — foreign keys from elsewhere to "any payment" have nowhere to point. Default: class table when subtypes are disjoint and constraints matter; single table when subtypes are few, similar, and read latency dominates.
code
sql · 17 linesCREATE TABLE payment (
id BIGINT PRIMARY KEY,
payment_type VARCHAR(20) NOT NULL,
amount_cents BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
card_token VARCHAR(64) NULL,
iban VARCHAR(34) NULL,
voucher_code VARCHAR(32) NULL,
CONSTRAINT payment_type_valid
CHECK (payment_type IN ('CARD','TRANSFER','VOUCHER')),
CONSTRAINT payment_shape
CHECK (
(payment_type = 'CARD' AND card_token IS NOT NULL AND iban IS NULL AND voucher_code IS NULL)
OR (payment_type = 'TRANSFER' AND iban IS NOT NULL AND card_token IS NULL AND voucher_code IS NULL)
OR (payment_type = 'VOUCHER' AND voucher_code IS NOT NULL AND card_token IS NULL AND iban IS NULL)
)
);go deeper
Name the three mappings and give the headline trade for each: nullable columns vs a join vs duplicated columns.
Explain the discriminator, the shared-PK foreign key, and how nullability and constraint enforcement differ; write the class-table DDL from memory.
Choose from query patterns and constraint requirements, cover completeness/disjointness enforcement, and discuss migration cost when subtypes are added.
Treat it as a schema-evolution and integrity-ownership decision — where invariants live, how the choice ages as subtypes multiply, and when a hybrid beats a pure pattern.
## The problem Entity-relationship modelling lets you say "a Payment is either a CardPayment, a BankTransfer or a Voucher" — a supertype with disjoint subtypes, each sharing some attributes (amount, currency, created_at, status) and adding its own (card token and last four digits; IBAN and bank name; voucher code and campaign). Relational tables have no inheritance, so the model must be flattened. Three flattenings are standard, and each moves cost between reads, writes, storage and constraint enforcement. ## Single table (table-per-hierarchy) One table, every column from every subtype, plus a **discriminator** column (`payment_type`) that names which subtype a row is. Reading any payment, or all payments regardless of type, is one table access with no join — the fastest possible read and the easiest to index across the whole hierarchy. Inserts touch one table, so there is no multi-table write to keep atomic. Foreign keys from other tables point at one place. The cost is constraint expressiveness. `card_token` cannot be NOT NULL, because bank transfers have no card token. So the schema no longer states which columns are mandatory for which type, and nothing stops a bank transfer row carrying a card token. The fix is conditional CHECK constraints — for each subtype, assert that its required columns are present and the other subtypes' columns are NULL when the discriminator says so. These are perfectly enforceable and worth writing, but they are hand-maintained and grow combinatorially with subtypes. Secondary costs: the table becomes wide and sparse; rows carry many NULLs (storage impact depends on the engine's NULL encoding, often small but not free); and every new subtype is a DDL change adding nullable columns to a table other subtypes also live in, which in a large table can be a nontrivial migration. ## Class table (joined / table-per-subclass) A parent table holds the identity and the shared attributes. Each subtype gets its own table whose primary key *is* a foreign key to the parent's primary key — a one-to-one identifying relationship. A card payment is one row in `payment` plus one row in `card_payment` sharing the same id. This is the normalized answer. Subtype columns live only where they apply, so they can be NOT NULL and the schema documents the model. Adding a subtype adds a table and touches nothing existing. Other tables can foreign-key to `payment` and reference any payment regardless of type, while a table that must reference specifically a card payment can foreign-key to `card_payment`. Indexes on subtype columns cover only the rows of that subtype, so they stay small. Costs: reading a full instance is a join, and a polymorphic read that needs subtype detail is a join to every subtype table (typically LEFT JOINs, or a UNION ALL of per-subtype queries). Writes touch two tables and must be in one transaction. And the model's real weakness is that the database does not, by itself, enforce **completeness and disjointness**: nothing stops a parent row having no subtype row (an incomplete payment) or rows in two subtype tables at once. You can approximate the guarantee by carrying the discriminator in the parent, including it in the subtype tables' foreign key as a composite `(id, type)` with a CHECK pinning the type per table — that makes double membership impossible. Full "every parent has exactly one child" is only enforceable with deferred constraints or triggers. ## Concrete table per subtype No parent table at all; each subtype table repeats the shared columns. Every single-subtype query is one table with no join, and each table is narrow and dense with no NULLs. Costs are structural. Shared attributes are duplicated in the schema, so a change to a common column is a change in N tables. There is no shared identity space, so identifiers must be unique across tables by convention (a shared sequence or UUIDs), and nothing else in the schema can foreign-key to "any payment". Every cross-subtype query — "total payments today", "find payment by id" — is a UNION ALL over all tables and must be edited whenever a subtype is added. In practice this pattern is defensible only when subtypes are truly independent, rarely queried together, and share little. ## Choosing Ask in order: 1. **How often do you query polymorphically?** "All payments" on every dashboard pushes toward single table or class table, and rules out concrete tables. 2. **How much do subtypes differ?** Two subtypes with one extra column each: single table, trivially. Five subtypes with ten distinct columns each: class table, or the single table becomes an unreadable 60-column sheet. 3. **How much do you need database-enforced constraints?** If "a card payment must have a token" must be guaranteed at the storage layer without hand-written CHECKs, class table wins. 4. **How volatile is the subtype set?** Frequently added subtypes favour class tables — new table, no migration on hot existing tables. 5. **Do other tables reference the supertype?** If yes, concrete tables are effectively out. Hybrids are legitimate: a single table for a mostly-uniform hierarchy with one exotic subtype split out; or a parent table plus subtype tables where the very common subtype's few columns are inlined into the parent. Note also that this is a storage decision independent of any object mapper's vocabulary — the trade-offs are the same whether the code is hand-written SQL or generated, and the schema should be chosen from query patterns and constraint needs rather than from a framework default.
- In the single-table mapping, how do you get back the guarantee that a card payment always has a card token?With a CHECK constraint conditioned on the discriminator: for each subtype value, assert that its required columns are NOT NULL and the other subtypes' columns are NULL. That restores full database-side enforcement, including the negative half — a bank transfer cannot smuggle in a card token. The drawback is that the constraint must be extended by hand every time a subtype or a subtype column is added, so it tends to rot in schemas nobody reviews.
- In the class-table mapping, what stops a row existing in two subtype tables at once, and what stops a parent row having no subtype row?Nothing, by default — each subtype table's foreign key only asserts the parent exists. Double membership can be blocked by storing the discriminator in the parent, making the subtype foreign key composite over (id, type), and adding a CHECK in each subtype table pinning the type to its own value, so a second subtype table's key would not match. Guaranteeing that every parent has exactly one child is harder: it needs a deferred constraint checked at commit or a trigger, because at the moment the parent row is inserted no child exists yet.
- Which mapping would you avoid if other tables need to reference a payment without caring about its type?Concrete table per subtype, because there is no supertype table to point a foreign key at. You would be forced into either a nullable foreign key per subtype table, or a type-plus-id pair that no constraint can validate — both of which give up referential integrity. Single table and class table both keep one identity space that a foreign key can reference.
Single table is one big form with sections you leave blank; class table is a short common form stapled to a type-specific addendum; concrete tables are three entirely separate forms that happen to ask some of the same questions.
saying these in an interview costs you the question
- Saying single-table inheritance can enforce required subtype columns with NOT NULL
- Claiming class-table mapping prevents a row from being two subtypes at once out of the box
- Choosing concrete tables while other tables need a foreign key to the supertype
- Treating the choice as an ORM setting rather than a query-pattern and constraint decision
- Assuming joins in the class-table mapping are always prohibitively slow without measuring