A class hierarchy is a familiar tool in Java. But how do you carry it over into a relational database? Tables know nothing about inheritance, and Hibernate offers three mapping strategies plus a helper tool, @MappedSuperclass.
The object always has the same fields — only their destination changes. SINGLE_TABLE puts the whole hierarchy into one table, so columns of the other type stay empty. JOINED splits one object into two rows: common fields in the parent table, the own field in the child one, linked by id. TABLE_PER_CLASS gives every type a full table, and the dashed frame shows the price — the common columns are duplicated.
Why map a hierarchy
Suppose you have a payment system with three types of payments: CardPayment, BankTransferPayment, CryptoPayment. They all share the same common fields: id, amount, createdAt, status. Each type adds its own field: cardLast4, ibanNumber, walletAddress.
Without special mapping you would either duplicate the common fields across three tables, store everything mixed in one, or do the JOINs by hand. Hibernate takes that work over — you only pick a strategy.
@Inheritance and the three strategies
The base class of the hierarchy is annotated with @Inheritance(strategy = ...). The subclasses are ordinary @Entity classes. If you leave the annotation out entirely, JPA falls back to SINGLE_TABLE.
SINGLE_TABLE — one table with a discriminator column
All classes of the hierarchy are stored in a single table. Hibernate adds a discriminator column (dtype by default), which tells it which type of record to read.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")
public abstract class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private BigDecimal amount;
private LocalDateTime createdAt;
private String status;
}
@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
private String cardLast4;
}
@Entity
@DiscriminatorValue("BANK")
public class BankTransferPayment extends Payment {
private String ibanNumber;
}
The payment table holds the columns of all three types at once: a card row leaves iban_number empty, a bank row leaves card_last4 empty.
Pros: simple and fast — a single SELECT with no JOINs, and a polymorphic query ("all payments") is trivial.
Cons: columns of the child types cannot be NOT NULL — for other types they are always NULL; a mandatory subtype field rests on a CHECK constraint or on application code. With many subtypes the table becomes wide and sparse.
JOINED — a separate table per type
Each class gets its own table; the child tables hold only the specific fields plus a foreign key to the parent.
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
// the same fields as above
}
@Entity
@Table(name = "card_payment")
public class CardPayment extends Payment {
private String cardLast4;
}
The schema in the database:
payment(id, amount, created_at, status)— no discriminator column here: Hibernate works out the type from which child table the row was found in. You may add@DiscriminatorColumnanyway, and then the SQL for polymorphic queries gets simplercard_payment(id, card_last4)—idis both PK and FK topaymentbank_transfer_payment(id, iban_number)— the same
Pros: a normalized schema. Each field lives in its own table and NOT NULL works as expected — which pays off when subtypes have many specific fields.
Cons: every query for an entity does a JOIN, and a polymorphic one does a LEFT JOIN against every subtype. On large data volumes and a wide inheritance tree this is noticeable.
TABLE_PER_CLASS — a separate table with the full set of fields
Each concrete class gets a table with all fields, its own and the inherited ones. The subclasses stay the same; the strategy and the id generator change:
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private BigDecimal amount;
private LocalDateTime createdAt;
private String status;
}
The card_payment table contains: id, amount, created_at, status, card_last4. The common fields are duplicated.
Pros: reading a concrete type is a simple SELECT with no JOIN and no sparse columns.
Cons: a polymorphic query ("find all payments") turns into a UNION ALL across all tables — expensive and awkward. GenerationType.IDENTITY does not fit here: every table numbers its rows on its own, while the identifier has to be unique across the whole hierarchy — you need a shared SEQUENCE. Hibernate does not allow a discriminator column for this strategy at all. The strategy is almost never used in practice.
The same fields, three layouts
The difference shows best on one pair of payments. The class below prints the rows each strategy puts in the database:
live example
import java.util.List;
public class InheritanceLayoutDemo {
record Payment(long id, String type, String amount, String own) {}
static String table(Payment p) {
return p.type().equals("CARD") ? "card_payment" : "bank_transfer_payment";
}
public static void main(String[] args) {
List<Payment> all = List.of(new Payment(1, "CARD", "100.00", "4242"),
new Payment(2, "BANK", "250.00", "DE89"));
System.out.println("SINGLE_TABLE: payment(id, payment_type, amount, card_last4, iban)");
for (Payment p : all) {
System.out.printf(" %d %s %s %s %s%n", p.id(), p.type(), p.amount(),
p.type().equals("CARD") ? p.own() : "NULL",
p.type().equals("BANK") ? p.own() : "NULL");
}
System.out.println("JOINED: payment(id, amount) + child(id, own field)");
for (Payment p : all) {
System.out.printf(" payment %d %s | %s %d %s%n", p.id(), p.amount(), table(p), p.id(), p.own());
}
System.out.println("TABLE_PER_CLASS: one table per type, every column in it");
for (Payment p : all) {
System.out.printf(" %s %d %s %s%n", table(p), p.id(), p.amount(), p.own());
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Each strategy's price is in the output: SINGLE_TABLE leaves half of the cells empty, JOINED turns one payment into two rows, TABLE_PER_CLASS keeps amount in both tables — so a polymorphic query reads both.
@MappedSuperclass — reusing fields without entity inheritance
@MappedSuperclass is a special case. It is not an inheritance strategy in the JPA sense: the superclass is not an entity, has no table, and supports no polymorphic queries.
The point is to pull common fields into a base class instead of duplicating them in every entity.
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
@Entity
@Table(name = "card_payment")
public class CardPayment extends BaseEntity {
private BigDecimal amount;
private String cardLast4;
}
@Entity
@Table(name = "user_account")
public class UserAccount extends BaseEntity {
private String email;
}
CardPayment and UserAccount are different entities with different tables and no polymorphic link between them: Hibernate simply "copies" the fields from BaseEntity into each table when generating the schema.
@MappedSuperclass is the right choice for common technical fields (id, createdAt, updatedAt, version). @Inheritance is for domain hierarchies where you need polymorphism.
What to choose
| Strategy | Schema | Polymorphic query | When to use |
|---|---|---|---|
SINGLE_TABLE | 1 table | Simple SELECT | Few subtypes, no strict NOT NULL |
JOINED | N tables (normalized) | JOIN per subtype | Many specific fields, integrity needed in the DB |
TABLE_PER_CLASS | N tables (denormalized) | UNION ALL | Practically never used |
@MappedSuperclass | Same as the subclasses | Not supported | Common technical fields |
In short
- Hibernate supports three strategies for mapping a hierarchy:
SINGLE_TABLE,JOINED,TABLE_PER_CLASS; without@Inheritanceyou getSINGLE_TABLE. SINGLE_TABLE— the simplest and most performant: one table, a discriminator column, no JOIN. The cost — you cannot use NOT NULL for subtype fields.JOINED— a normalized schema: common fields in the parent table, specific ones in the children. Every query does a JOIN.TABLE_PER_CLASS— each type in a separate table with all its fields. Polymorphic queries go through UNION ALL, andIDENTITYdoes not work — you needSEQUENCE.@MappedSuperclass— not an inheritance strategy but field reuse. Suitable for technical base classes (id,createdAt).- By default pick
SINGLE_TABLE;JOINEDis taken when there are many subtypes and specific fields and integrity has to hold at the schema level.
What to read next
- Entity Mapping — how the
@Entity,@Table,@Columnannotations describe the schema - Associations Between Entities —
@OneToMany,@ManyToOne,@ManyToManyand their nuances - JPQL and Criteria API — how to write queries against hierarchies with polymorphism
- Common Hibernate Mistakes — the traps people hit with these very entities