Skip to main content

Hibernate 7 Inheritance Mapping: SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS (SQL and Performance)

One payment hierarchy mapped three ways with Hibernate 7.4: the generated DDL, INSERT and SELECT statements, three reproducible failures, @MappedSuperclass, and a 150,000-row timing run, all from committed transcripts.

Your Java classes can inherit from each other. Relational tables cannot. The moment you write CardPayment extends Payment and mark both as entities, Hibernate has to decide what that means in SQL, and there are three answers: put every class in one wide table, give every class its own table and join them by key, or give every concrete class a table that repeats the inherited columns. That choice shows up in your schema, in every query Hibernate generates for the hierarchy, and in which bugs you meet. This article maps one small payment hierarchy all three ways with plain Hibernate 7, runs it against an in-memory database, and shows what Hibernate actually generates: the DDL, the INSERT and SELECT statements, three failures you can reproduce in a minute, and a timing run over 150,000 rows. Every output block below is copied from a file in the companion repository, and the tests that write those files assert the numbers they print.
Versions and limits. Hibernate ORM 7.4.13.Final (the latest 7.x release on Maven Central on 24 October 2026; the same listing also contains 8.0.0.Beta3, which is a beta), Jakarta Persistence 3.2.0, H2 2.4.240 in memory, Temurin 25.0.5+8, on a 2 vCPU, 8 GB Linux machine. Plain Hibernate, no Spring. The timings are indicative: H2 in memory is not PostgreSQL, so read them for their shape, not their milliseconds. All code and every transcript is in the inheritance module of the hibernate-demo repository.

Java inheritance has no table equivalent, so Hibernate offers three layouts

The hierarchy used throughout is deliberately small. Payment is an abstract root with an id, an amount and a currency. It has three subclasses: CardPayment (the last four digits and the network), BankTransferPayment (an IBAN and a bank code) and WalletPayment (a provider). A PaymentOrder entity points at a Payment without caring which kind. The same four classes plus the order are written three times, in the packages single, joined and tpc, differing only in the strategy named on the root class, so the three can run side by side. The word to learn first is polymorphic query: select p from Payment p returns cards, bank transfers and wallet payments in one list, each as an instance of its own class. Hibernate has to find every row of every subclass for that to work, and that is where the three layouts differ most.
SINGLE_TABLE JOINED TABLE_PER_CLASS st_payment id amount currency payment_type card_last4 network iban bank_code provider jn_payment id amount currency payment_id card_last4 network card payment_id iban bank_code bank_transfer payment_id provider wallet tp_payment: abstract root, no table tp_card_payment id amount currency card_last4 network tp_bank_transfer_payment id amount currency iban bank_code tp_wallet_payment id amount currency provider One row per payment. Other subclasses’ columns stay NULL. A card payment is two rows: jn_payment + jn_card_payment. Three rows in three tables hold the same id, amount and currency columns. Blue: columns of the root class. Green, amber, red: columns of one subclass each. Grey: the discriminator. .
The figure shows the same data stored three ways. SINGLE_TABLE keeps one row per payment in one table, with a discriminator column naming the subclass and NULL in the columns that belong to other subclasses. JOINED splits each payment across two tables, so a card payment is a row in jn_payment plus a row in jn_card_payment. TABLE_PER_CLASS keeps one row per payment again, but in three separate tables that each repeat the root’s columns, and the abstract root gets no table at all. Everything else in this article follows from that picture. The strategy is chosen with one annotation on the root class, @Inheritance(strategy = ...). Here is what a subclass looks like in the SINGLE_TABLE variant, from single/CardPayment.java:
@Entity(name = "StCardPayment")
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {

    @Column(name = "card_last4", length = 4)
    private String cardLast4;

    @Column(length = 16)
    private String network;

SINGLE_TABLE: one table, and a column that says which subclass each row is

With SINGLE_TABLE every subclass lives in the root’s table. A @DiscriminatorColumn holds a short code per class, set with @DiscriminatorValue on each subclass, and Hibernate writes it on insert and reads it back to decide which class to instantiate. This is the root class from single/Payment.java; the long @Check line will make sense in a moment:
@Entity(name = "StPayment")
@Table(name = "st_payment")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", length = 12)
@Check(name = "st_payment_subclass_columns", constraints = "(payment_type <> 'CARD' or card_last4 is not null) and (payment_type <> 'BANK' or iban is not null) and (payment_type <> 'WALLET' or provider is not null)")
public abstract class Payment {
This is the table Hibernate generated for it, copied from output/01-ddl-single-table.txt:
    create table st_payment (
        amount numeric(12,2) not null,
        card_last4 varchar(4),
        currency varchar(3) not null,
        id bigint not null,
        bank_code varchar(11),
        payment_type varchar(12) not null check ((payment_type in ('CARD','BANK','WALLET'))),
        network varchar(16),
        provider varchar(24),
        iban varchar(34),
        primary key (id),
        constraint st_payment_subclass_columns check ((payment_type <> 'CARD' or card_last4 is not null) and (payment_type <> 'BANK' or iban is not null) and (payment_type <> 'WALLET' or provider is not null))
    );
Three things in that DDL are worth reading slowly. Every subclass column (card_last4, network, iban, bank_code, provider) is nullable. The discriminator column carries a generated check that lists the three legal values. And the st_payment_subclass_columns constraint is the one I wrote with @Check, for a reason the next callout explains.
The trap: nullable = false on a subclass column. It reads like a sensible rule (a card payment must have its last four digits) and it breaks the table. All subclasses share one table, so a NOT NULL column demands a value from every row, including the rows of the other subclasses that have no such field. I reproduced it with two throwaway subclasses that each declare one mandatory column. Persisting the first one fails because the database wants the second one’s column, and the failure is a constraint violation from the database, not a mapping error at startup.
The two throwaway classes are SnA.java and SnB.java, and the transcript is in output/03-contracts.txt:
== SINGLE_TABLE with nullable = false on subclass columns
IS_NULLABLE: [A_VALUE | NO, B_VALUE | NO, ID | NO, KIND | NO]
persist(new SnA()): org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "B_VALUE"; SQL statement:
insert into sn_root (a_value,kind,id) values (?,'A',?) [23502-240]] [insert into sn_root (a_value,kind,id) values (?,'A',?)]
The fix is to drop nullable = false from the subclass attributes and move the guarantee into a CHECK constraint keyed on the discriminator, which is what the @Check on the root does: a CARD row must have card_last4, a BANK row must have iban, a WALLET row must have provider, and rows of other kinds are left alone. The same transcript shows the database enforcing it against a raw JDBC insert that bypasses Hibernate entirely:
raw INSERT of a CARD row with no card_last4: Check constraint violation: "ST_PAYMENT_SUBCLASS_COLUMNS: "; SQL statement:
insert into st_payment (id, amount, currency, payment_type) values (900, 1.00, 'EUR', 'CARD') [23513-240]
From output/03-contracts.txt. Now the SQL. Persisting one card, one bank transfer and one wallet payment writes one row each, with the discriminator value inlined as a literal. These are the statements from output/02-sql-single-table.txt:
== persist 3 payments and 1 order: 4 INSERT statements
insert into st_payment (amount,currency,card_last4,network,payment_type,id) values (?,?,?,?,'CARD',?);
insert into st_payment (amount,currency,bank_code,iban,payment_type,id) values (?,?,?,?,'BANK',?);
insert into st_payment (amount,currency,provider,payment_type,id) values (?,?,?,'WALLET',?);
insert into st_order (payment_id,reference,id) values (?,?,?);
The polymorphic query is a plain scan of one table, selecting every subclass column plus the discriminator, and a query for one subclass is the same table with a where on the discriminator (both from 02-sql-single-table.txt):
== select p from StPayment p   (polymorphic, returns 3 rows)
select p1_0.id,p1_0.payment_type,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider
from st_payment p1_0
  order by p1_0.id;
== from StCardPayment   (one subclass, returns 1 row)
select cp1_0.id,cp1_0.amount,cp1_0.currency,cp1_0.card_last4,cp1_0.network
from st_payment cp1_0
  where cp1_0.payment_type='CARD';
From output/02-sql-single-table.txt.
Why the discriminator check exists, and what else the transcripts show about it
The generated check ((payment_type in ('CARD','BANK','WALLET'))) in the DDL above is Hibernate’s own addition, not something I wrote: it comes from the three @DiscriminatorValue annotations. The discriminator column is declared not null as well, so a row can never lack a subclass. The contract test confirms the stored values after the three inserts:
discriminator values stored: [BANK, CARD, WALLET]
From output/03-contracts.txt. The subclass query selects only the columns of CardPayment and its parents, which is why its select list is shorter than the polymorphic one, but it still reads the whole row from the wide table. A table with many subclasses and many columns means wide rows even for the narrowest query. The row-count and foreign-key checks for this strategy are in the same file.
Why the @Check lives on the root class
All the subclasses share one table, so one table-level constraint is enough, and the root is the only class that is certain to exist in every deployment of the hierarchy. The generated DDL shows a single constraint on st_payment. A subclass added later needs another clause in the same expression, which is the maintenance cost of this approach: the constraint and the subclasses must be kept in step by hand. I did not test @Check placed on a subclass, so I make no claim about it.

JOINED: one table per class, glued together by the primary key

JOINED is the layout a database designer would draw by hand. The root’s table holds what every payment has, each subclass table holds only its own columns plus a primary key that is also a foreign key to the root, and a row in a subclass table means that payment is of that kind. The root declaration from joined/Payment.java differs from the single-table one only in the strategy, and the subclass adds a @PrimaryKeyJoinColumn, here from joined/CardPayment.java:
@Entity(name = "JnPayment")
@Table(name = "jn_payment")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
@Entity(name = "JnCardPayment")
@Table(name = "jn_card_payment")
@PrimaryKeyJoinColumn(name = "payment_id")
public class CardPayment extends Payment {

    @Column(name = "card_last4", nullable = false, length = 4)
    private String cardLast4;

    @Column(length = 16)
    private String network;
The generated schema, from output/01-ddl-joined.txt, shows what you gain: the subclass column is genuinely not null, because the table only contains card payments.
    create table jn_payment (
        amount numeric(12,2) not null,
        currency varchar(3) not null,
        id bigint not null,
        primary key (id)
    );
    create table jn_card_payment (
        card_last4 varchar(4) not null,
        payment_id bigint not null,
        network varchar(16),
        primary key (payment_id)
    );
The price is paid on writes. Saving three payments and one order took seven INSERT statements here, against four for the other two layouts, because every subclass row is two rows. Notice that the statements alternate: root row, subclass row, root row, subclass row.
== persist 3 payments and 1 order: 7 INSERT statements
insert into jn_payment (amount,currency,id) values (?,?,?);
insert into jn_card_payment (card_last4,network,payment_id) values (?,?,?);
insert into jn_payment (amount,currency,id) values (?,?,?);
insert into jn_bank_transfer_payment (bank_code,iban,payment_id) values (?,?,?);
insert into jn_payment (amount,currency,id) values (?,?,?);
insert into jn_wallet_payment (provider,payment_id) values (?,?);
insert into jn_order (payment_id,reference,id) values (?,?,?);
From output/02-sql-joined.txt. Reading is where the other cost lives. A polymorphic query has to find which subclass table, if any, holds each id. Hibernate does that with a left join to every subclass table and a case expression over which join matched. A query for one subclass is cheaper: an inner join of the subclass table to the root.
SINGLE_TABLE st_payment 1 table, 0 joins. A subclass query adds where payment_type=’CARD’. JOINED jn_payment left join card_payment left join bank_transfer left join wallet_payment 4 tables, 3 left joins; a CASE expression names the subclass. A subclass query is an inner join of two tables. TABLE_PER_CLASS tp_card_payment union all tp_bank_transfer_payment union all tp_wallet_payment 3 tables, 2 union all inside a subselect. Each branch pads the other subclasses’ columns with null. A subclass query reads one table, with no join. Counts are read off the committed SQL transcripts (output/02-sql-*.txt).
The figure puts the three read shapes side by side, using the counts in the transcripts: one table for SINGLE_TABLE, four tables and three left joins for the polymorphic JOINED query, and three tables stitched with two union all for TABLE_PER_CLASS. This is the polymorphic query from output/02-sql-joined.txt:
== select p from JnPayment p   (polymorphic, returns 3 rows)
select p1_0.id,case when p1_2.payment_id is not null then 1 when p1_1.payment_id is not null then 2 when p1_3.payment_id is not null then 3 end,p1_0.amount,p1_0.currency,p1_1.bank_code,p1_1.iban,p1_2.card_last4,p1_2.network,p1_3.provider
from jn_payment p1_0
  left join jn_bank_transfer_payment p1_1 on p1_0.id=p1_1.payment_id
  left join jn_card_payment p1_2 on p1_0.id=p1_2.payment_id
  left join jn_wallet_payment p1_3 on p1_0.id=p1_3.payment_id
  order by p1_0.id;
== from JnCardPayment   (one subclass, returns 1 row)
select cp1_0.payment_id,cp1_1.amount,cp1_1.currency,cp1_0.card_last4,cp1_0.network
from jn_card_payment cp1_0
  join jn_payment cp1_1 on cp1_0.payment_id=cp1_1.id;
Every new subclass adds a join to every polymorphic query. With three subclasses the root query joins four tables; with thirty it joins thirty-one, whether or not the query needs any subclass data. That is the practical limit of JOINED on a wide hierarchy, and it is the reason to keep the hierarchy shallow and narrow when you choose it.
How Hibernate knows the subclass without a discriminator column
Nothing in jn_payment says what kind of payment a row is. The generated select answers that itself: case when p1_2.payment_id is not null then 1 when p1_1.payment_id is not null then 2 when p1_3.payment_id is not null then 3 end, where each alias is one of the left-joined subclass tables. The first subclass table that has a matching row decides the number, and Hibernate maps the number back to a class. That is the entire mechanism, and you can see it in the transcript above. The consequence for correctness is that the database does not stop a payment from having rows in two subclass tables, or in none. A row in jn_payment with no subclass row would come back as nothing useful (the abstract root cannot be instantiated). If you need the database to forbid that, it is a constraint you write yourself; I did not test one here.
Two inserts per row and what that means for batching
The transcript shows insert into jn_payment and insert into jn_card_payment alternating. JDBC batching groups consecutive executions of the same SQL text, so an alternating sequence breaks into many tiny batches. Hibernate has the hibernate.order_inserts setting for exactly this, which sorts the statements so same-table inserts sit together. The setting is documented; I did not run a batching benchmark in this repository, so I do not give a number for the gain. What the repository does show is the raw count, in the order SINGLE_TABLE, JOINED, TABLE_PER_CLASS:
INSERT statements to save 3 payments + 1 order: 4
INSERT statements to save 3 payments + 1 order: 7
INSERT statements to save 3 payments + 1 order: 4
From output/03-contracts.txt.

TABLE_PER_CLASS: the layout that looks simplest and costs the most

TABLE_PER_CLASS gives each concrete class a full table with every inherited column repeated, and the abstract root gets nothing. For writes it looks ideal: one INSERT per payment, no joins, no discriminator. The root declaration in tpc/Payment.java has no @Table at all, because there is no root table:
@Entity(name = "TpPayment")
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
Here is one of the three generated tables, from output/01-ddl-table-per-class.txt. Note that amount and currency are repeated, and that card_last4 is not null here, as in JOINED:
    create table tp_card_payment (
        amount numeric(12,2) not null,
        card_last4 varchar(4) not null,
        currency varchar(3) not null,
        id bigint not null,
        network varchar(16),
        primary key (id)
    );
The weakness shows up when something needs to refer to the root. A foreign key can point at one table, and there is no root table to point at, so Hibernate generates the order’s table without one. Here is the order table, and below it the foreign-key counts the contract test read from the database for all three layouts:
    create table tp_order (
        id bigint not null,
        payment_id bigint not null,
        reference varchar(20) not null,
        primary key (id)
    );
From output/01-ddl-table-per-class.txt.
foreign keys on st_order: 1
foreign keys on jn_order: 1
foreign keys on tp_order: 0
From output/03-contracts.txt. SINGLE_TABLE and JOINED each have one foreign key from the order to the root. TABLE_PER_CLASS has none: the database will happily accept an order whose payment_id matches no payment at all. Reads pay for the same missing root. A polymorphic query becomes a subselect that unions all the concrete tables, padding the columns a branch does not have with null and adding a literal clazz_ column so Hibernate can tell the branches apart. And because the order points at the root, loading an order together with its payment embeds that whole union inside a join (both statements are from 02-sql-table-per-class.txt):
== select p from TpPayment p   (polymorphic, returns 3 rows)
select p1_0.id,p1_0.clazz_,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider
from (select amount, card_last4, currency, id, network, null as bank_code, null as iban, null as provider, 1 as clazz_
from tp_card_payment
union all
select amount, null as card_last4, currency, id, null as network, bank_code, iban, null as provider, 2 as clazz_
from tp_bank_transfer_payment
union all
select amount, null as card_last4, currency, id, null as network, null as bank_code, null as iban, provider, 3 as clazz_
from tp_wallet_payment) p1_0
  order by p1_0.id;
== select o from TpOrder o join fetch o.payment   (association to the root)
select po1_0.id,po1_0.payment_id,p1_0.id,p1_0.clazz_,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider,po1_0.reference
from tp_order po1_0
  join (select amount, card_last4, currency, id, network, null as bank_code, null as iban, null as provider, 1 as clazz_
from tp_card_payment
union all
select amount, null as card_last4, currency, id, null as network, bank_code, iban, null as provider, 2 as clazz_
from tp_bank_transfer_payment
union all
select amount, null as card_last4, currency, id, null as network, null as bank_code, null as iban, provider, 3 as clazz_
from tp_wallet_payment) p1_0 on p1_0.id=po1_0.payment_id;
From output/02-sql-table-per-class.txt.
The trap: IDENTITY ids. The union above only makes sense if an id identifies one payment across all three tables, so every table must draw ids from a shared source. An identity column belongs to one table, so Hibernate refuses to map it, and it does so at startup, not at the first insert. I reproduced it with a throwaway root and subclass. The sequence-based mapping used everywhere else in this module is the version that works.
The failing mapping is TpcIdentityPayment.java (and its subclass, TpcIdentityCard.java):
@Entity(name = "TpcIdentityPayment")
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
abstract class TpcIdentityPayment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
What Hibernate says is in output/03-contracts.txt:
== TABLE_PER_CLASS with GenerationType.IDENTITY
org.hibernate.MappingException: Cannot use identity column key generation with <union-subclass> mapping for: com.ankurm.inheritance.TpcIdentityCard
Why a missing root table also means no foreign key, and what to do about it
A foreign key constraint names exactly one referenced table. With the root abstract and unmapped to a table, the choices are one key per concrete table (which cannot express “either of these”) or none. Hibernate generates none. The association still works in Java and in queries, because Hibernate does the union at read time, but nothing in the database protects it. If you need the database-level guarantee and still want a table per class, the usual answer is to add the guarantee yourself, for instance with a trigger or a small registry table that every concrete table references. I have not built or tested either here, so treat that as a direction, not a recipe.
The sequence settings the module uses and why
Every hierarchy in the module uses GenerationType.SEQUENCE with a named sequence and allocationSize = 50, as in the id mapping of tpc/Payment.java:
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "tp_payment_seq")
    @SequenceGenerator(name = "tp_payment_seq", sequenceName = "tp_payment_seq", allocationSize = 50)
    private Long id;
SEQUENCE works with all three strategies, so the three hierarchies are comparable. The 50-id allocation block is the Hibernate default pattern: a call to the sequence reserves 50 ids, which is why the insert transcripts show only INSERTs and no sequence calls for a handful of rows.

@MappedSuperclass shares columns without creating a hierarchy

Sometimes what you want to share is not a hierarchy but a set of columns: an id, a version, a creation timestamp that every entity carries. @MappedSuperclass does that. The parent is a plain Java class whose mappings are copied into each subclass’s own table, and it is not an entity. This is the base class from mapped/AuditedBase.java:
 * @MappedSuperclass: shared columns and mappings, but not an entity. It has no table, cannot be
 * queried, and cannot be the target of an association.
 */
@MappedSuperclass
public abstract class AuditedBase {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Version
    private int version;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
and an entity that uses it, from mapped/Invoice.java (the second one, Receipt, is the same idea):
@Entity(name = "MpInvoice")
@Table(name = "mp_invoice")
public class Invoice extends AuditedBase {

    @Column(nullable = false, length = 20)
    private String number;
The generated DDL, from output/01-ddl-mapped-superclass.txt, has two tables and none for the base class. The shared columns appear in each:
    create table mp_invoice (
        version integer not null,
        created_at timestamp(6) with time zone not null,
        id bigint not null,
        number varchar(20) not null,
        primary key (id)
    );
You cannot query the parent. Because AuditedBase is not an entity, select a from AuditedBase a has nothing to resolve. The error is raised when the query is built, so you find out the first time the query runs. If you need “all auditable things” in one result list, this is not the mapping; you want one of the three inheritance strategies above.
== querying a @MappedSuperclass
tables: [mp_invoice, mp_receipt]
org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'AuditedBase'
From output/03-contracts.txt.
When this is the right tool
Use it for pure column reuse across unrelated entities: ids, versions, audit fields. Because each entity gets its own table with its own copy of the columns, there is no join and no union, and nothing to design around. It is the cheapest option when you never need to treat the entities as one type in a query. The moment you do, you have outgrown it.

What the three layouts cost at query time, measured

I loaded 150,000 payments (50,000 of each kind) into each layout and timed four queries through a StatelessSession, so no persistence-context bookkeeping is in the number. Each figure is the median of 15 runs after 5 warm-ups. The program is Bench.java and the raw result is output/04-polymorphic-timings.txt:
strategy         query                                            rows  median ms
single-table     all payments (polymorphic)                     150000      247.2
single-table     one subclass (CardPayment)                      50000       82.4
single-table     count where amount > 500 (root col)                 1       18.4
single-table     first 50 by id (polymorphic)                       50        0.3

joined           all payments (polymorphic)                     150000      401.5
joined           one subclass (CardPayment)                      50000      107.1
joined           count where amount > 500 (root col)                 1       21.6
joined           first 50 by id (polymorphic)                       50        0.3

table-per-class  all payments (polymorphic)                     150000      422.0
table-per-class  one subclass (CardPayment)                      50000       82.1
table-per-class  count where amount > 500 (root col)                 1       56.0
table-per-class  first 50 by id (polymorphic)                       50      165.8
Read the table for shape rather than milliseconds. Fetching every payment polymorphically took about 247 ms on SINGLE_TABLE against 402 ms for JOINED and 422 ms for TABLE_PER_CLASS, so the joins and the union cost something, but it is the same order of magnitude. The query for a single subclass was close to level across all three. The counting query on a root column went from 18 ms and 22 ms to 56 ms for the union. The last row is the one to remember. Asking for the first 50 payments ordered by id took 0.3 ms on SINGLE_TABLE and 0.3 ms on JOINED, and 166 ms on TABLE_PER_CLASS. The first two can walk the root’s primary key and stop after 50 rows. The third has to produce the union before it can order it. I did not capture the database’s query plan, so I am describing the pattern the SQL makes, not proving the mechanism, but a factor of several hundred on a “first page” query is the kind of surprise that ends up in production because nobody paged a union in a test.
A benchmark trap I fell into. My first timing run reported about a tenth of a millisecond for the 150,000-row union and for the first-50 query on every layout. By default H2 reuses the previous result of an identical query when no table has changed, so a loop that repeats one query measures a cache hit. Adding ;OPTIMIZE_REUSE_RESULTS=0 to the JDBC URL (see Db.java in the repository) produced the numbers above. If you benchmark Hibernate on H2, check for this before you believe a result.
How the timing run is built, and what it leaves out
The data is generated in code and inserted through a StatelessSession in a single transaction; ids come from a sequence. The queries are HQL strings run with createSelectionQuery and read fully into a list. Nothing is indexed beyond the primary keys, which is the default Hibernate generates. What this does not measure: a real database over a network, a warm buffer cache, secondary indexes on subclass columns, concurrent load, or the persistence-context overhead of loading managed entities. The ratios between layouts on PostgreSQL or MySQL may differ, in either direction, because a real optimiser can push a limit or a filter into a union branch in ways H2 apparently does not. The module’s scripts/bench.sh reruns it on your machine; point Db.java at your own database to repeat it there.

How to choose, and when not to use inheritance at all

Everything above collapses into one table. Every cell comes from a transcript in the repository, except the cells marked “not tested here”.
SINGLE_TABLEJOINEDTABLE_PER_CLASS@MappedSuperclass
Tables for the four payment classes143 (the abstract root has none)n/a
INSERTs per subclass row1211
Polymorphic query on the rootone table scanroot plus 3 left joins and a CASEunion all of 3 tablesnot possible
Query for one subclassone table, where on discriminatorinner join of two tablesone tablen/a
Database enforces a mandatory subclass columnonly with a CHECK on the discriminatoryes, NOT NULLyes, NOT NULLyes, NOT NULL
Foreign key from another table to the rootyesyesnone generatedno root to point at
IDENTITY id generationnot tested herenot tested hererejected at startupnot tested here
First 50 by id, polymorphic (150,000 rows)0.3 ms0.3 ms166 msn/a
Do you only want to share columns, and neverquery or point at the parent type? yes @MappedSuperclassno table for the parent no Does any table point at the parent type,or do you list across all the subtypes? no TABLE_PER_CLASSacceptable; see warnings yes Do subclasses carry mandatory columns youwant the database itself to enforce? no SINGLE_TABLEfewest joins, NULLable columns yes JOINEDNOT NULL works, more joins SINGLE_TABLE can also enforce a subclass column with a CHECK constraint on the discriminator (shown above), which is the usual way to stay on one table when the subclasses have mandatory columns. This is guidance drawn from the measurements in this article, not a rule: your query mix and your database decide.
The flow reduces the table to three questions. If you only want to share columns and never query or point at the parent, use @MappedSuperclass. If something else points at the parent or you list across the subtypes, you need a real hierarchy, and the remaining question is whether the database must enforce mandatory subclass columns: JOINED if so, SINGLE_TABLE otherwise (or SINGLE_TABLE with the CHECK constraint from earlier). TABLE_PER_CLASS is what is left when nothing points at the root and you never query it, and in that case a @MappedSuperclass usually does the same job with fewer surprises. The flow is my reading of these measurements, not a rule.
Should you use entity inheritance at all? It is a modelling decision before it is a mapping decision. If the subtypes differ by a handful of attributes and no behaviour, one entity with a type column and some nullable fields gives you the SINGLE_TABLE schema without a class hierarchy, and without polymorphic queries to reason about. If the subtypes have real behaviour and you query across them constantly, inheritance is the right model and SINGLE_TABLE or JOINED is the right mapping. That advice is an opinion; I did not benchmark the alternatives.
Reproduce everything

You need JDK 25 and Maven 3.9, and network access to Maven Central for the first build. The commands below are from the module’s README.md.

git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
cd hibernate-demo/inheritance
./scripts/run-all.sh

That runs the three JUnit tests (they assert the numbers they print and write output/01 to 03) and then the timing run (output/04). The timings will differ on your machine; the other files should not.

What went wrong while building this repository
Three things cost a compile or a run, and they are worth knowing before you copy any of this code. First, org.hibernate.tool.hbm2ddl.SchemaExport, the class most older examples use to print generated DDL, is not in the hibernate-core 7.4.13 jar. The test compile failed on the import. The DDL transcripts here are produced with the standard Jakarta Persistence script-generation settings instead (jakarta.persistence.schema-generation.scripts.action=create with a script target), which Hibernate honours. Second, my first mapping put nullable = false on the subclass columns of the SINGLE_TABLE hierarchy, as most people would, and the very first test run failed with the constraint violation shown above. That is where the throwaway SnA and SnB classes came from. Third, the H2 result-reuse default described in the benchmark callout.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.