← Back to the section

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.

one object — three ways to lay its fields out across tables CardPayment id = 1 amount = 100.00 status = PAID card_last4 = 4242 SINGLE_TABLEpolymorphic query — one SELECTpaymentidpayment_typeamountcard_last4iban1CARD100.004242NULL2BANK250.00NULLDE89columns of the other type stay empty — no NOT NULL here JOINEDpolymorphic query — LEFT JOINpaymentidamountstatus1100.00PAIDid — primary key and foreign key at oncecard_paymentidcard_last414242 TABLE_PER_CLASSpolymorphic query — UNION ALLcard_paymentidamountstatuscard_last41100.00PAID4242bank_transfer_paymentidamountstatusiban2250.00PAIDDE89

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 @DiscriminatorColumn anyway, and then the SQL for polymorphic queries gets simpler
  • card_payment(id, card_last4) — id is both PK and FK to payment
  • bank_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

StrategySchemaPolymorphic queryWhen to use
SINGLE_TABLE1 tableSimple SELECTFew subtypes, no strict NOT NULL
JOINEDN tables (normalized)JOIN per subtypeMany specific fields, integrity needed in the DB
TABLE_PER_CLASSN tables (denormalized)UNION ALLPractically never used
@MappedSuperclassSame as the subclassesNot supportedCommon technical fields

In short

  • Hibernate supports three strategies for mapping a hierarchy: SINGLE_TABLE, JOINED, TABLE_PER_CLASS; without @Inheritance you get SINGLE_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, and IDENTITY does not work — you need SEQUENCE.
  • @MappedSuperclass — not an inheritance strategy but field reuse. Suitable for technical base classes (id, createdAt).
  • By default pick SINGLE_TABLE; JOINED is taken when there are many subtypes and specific fields and integrity has to hold at the schema level.