Back to Embeddable, Inheritance & Secondary Tables
Q5Question 5 of 5

What are JPA inheritance strategies?

Key Concepts
  • SINGLE_TABLE (default): one table with a discriminator column -- fastest queries, but subtype columns must be nullable
  • JOINED: base table plus one table per subclass joined by shared primary key -- normalized, but every read pays a join cost
  • TABLE_PER_CLASS: each concrete class gets a full standalone table -- simple per type, but polymorphic queries require UNION ALL
  • Default to SINGLE_TABLE for performance when subtype NOT NULL constraints are not critical
  • Choose JOINED when data integrity matters and subtype-specific columns must be NOT NULL
  • Avoid TABLE_PER_CLASS unless subtypes are queried in isolation and polymorphic queries are rare
  • Switching strategy later is a schema migration, so decide early based on query patterns and integrity needs

Definition

SINGLE_TABLE stores the whole hierarchy in one table with a discriminator column; JOINED normalizes into base plus per-subclass tables joined by key; TABLE_PER_CLASS gives each concrete class a full standalone table.

When you use @Inheritance for an entity hierarchy, JPA offers three strategies for table layout.

  • SINGLE_TABLE – stores all subtypes in one table with a discriminator column identifying each row's type; subtype-specific columns are nullable because they do not apply to every row.
  • JOINED – gives the base class its own table and each subclass its own table holding only its extra columns, linked by a shared primary key; loading a subtype requires a JOIN.
  • TABLE_PER_CLASS – gives each concrete subclass its own complete table including all inherited columns; there is no shared base table, so polymorphic queries must UNION ALL across all subclass tables.
  • @DiscriminatorColumn – defines the column in a SINGLE_TABLE strategy that identifies which subclass each row belongs to.
  • @DiscriminatorValue – specifies the value stored in the discriminator column for a particular subclass.

At National Bank of India, imagine storing savings and current account records. SINGLE_TABLE is one giant ledger book where every account is a row with a "type" stamp and some columns left blank when they do not apply -- fast to read, but messy with blanks. JOINED is a main ledger for common details plus a separate thin booklet for savings-only details and another for current-only details, cross-referenced by account number -- tidy with no blanks, but you flip between books (join) to see everything. TABLE_PER_CLASS is two entirely separate complete ledgers, one for savings and one for current, each self-contained -- simple per type, but "list all accounts" means reading both books and stitching them together.

Understanding the Concepts

What is the SINGLE_TABLE strategy?

SINGLE_TABLE is the default JPA inheritance strategy and usually the fastest. All subtypes share one table, and a discriminator column (for example account_type) tells Hibernate which subclass each row belongs to. Queries and polymorphic loads need no joins, making this the best performer.

The downside is that columns belonging only to a subtype must be nullable. A savings-only interest_rate column is null for every current-account row. You cannot enforce NOT NULL on subtype-specific columns at the database level.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "account_type",
    discriminatorType = DiscriminatorType.STRING)
public abstract class Account {

    @Id
    @Column(name = "account_id")
    protected String accountId;

    @Column(name = "holder_name")
    protected String holderName;

    protected BigDecimal balance;
}

@Entity
@DiscriminatorValue("SAVINGS")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate")
    private BigDecimal interestRate; // null for CURRENT rows
}

@Entity
@DiscriminatorValue("CURRENT")
public class CurrentAccount extends Account {
    @Column(name = "overdraft_limit")
    private BigDecimal overdraftLimit; // null for SAVINGS rows
}
TEXT
accounts table (SINGLE_TABLE):
account_id | account_type | holder_name | balance   | interest_rate | overdraft_limit
SAV2001    | SAVINGS      | Priya       | 50000.00  | 6.50          | NULL
CUR3001    | CURRENT      | Rajesh      | 120000.00 | NULL          | 50000.00

Hibernate generates a simple query with no join:

TEXT
SELECT * FROM accounts WHERE account_type = 'SAVINGS'

What is the JOINED strategy?

JOINED normalizes the model. The base entity has a table with common columns. Each subclass has its own table with only its extra columns plus a primary key that is also a foreign key to the base table. Inserting a savings account writes one row to the base table and one to the savings table. Loading it joins them.

This enforces NOT NULL on subtype columns because they live in their own table. But every read of a subtype pays a join cost, and polymorphic queries join across all subclass tables.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Account {

    @Id
    @Column(name = "account_id")
    protected String accountId;

    @Column(name = "holder_name")
    protected String holderName;

    protected BigDecimal balance;
}

@Entity
@Table(name = "savings_accounts")
@PrimaryKeyJoinColumn(name = "account_id")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate", nullable = false) // CAN enforce NOT NULL
    private BigDecimal interestRate;
}

@Entity
@Table(name = "current_accounts")
@PrimaryKeyJoinColumn(name = "account_id")
public class CurrentAccount extends Account {
    @Column(name = "overdraft_limit", nullable = false) // CAN enforce NOT NULL
    private BigDecimal overdraftLimit;
}
TEXT
accounts table:           savings_accounts table:    current_accounts table:
account_id | holder_name  account_id | interest_rate  account_id | overdraft_limit
SAV2001    | Priya        SAV2001    | 6.50           CUR3001    | 50000.00
CUR3001    | Rajesh

Hibernate generates a JOIN query:

TEXT
SELECT a.*, s.interest_rate
FROM accounts a
JOIN savings_accounts s ON a.account_id = s.account_id
WHERE a.account_id = 'SAV2001'

What is the TABLE_PER_CLASS strategy?

TABLE_PER_CLASS gives each concrete subclass a standalone table containing all columns -- inherited and its own. There is no shared base table. Loading a specific subtype is a single-table read with no join.

But a polymorphic query against the base type must UNION ALL across every subclass table. This is slow and complicates id generation. IDENTITY strategy is problematic because ids must be unique across the union, so you need a shared SEQUENCE or TABLE generator.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
        generator = "account_seq")
    @SequenceGenerator(name = "account_seq",
        sequenceName = "account_sequence", allocationSize = 1)
    protected Long id;

    @Column(name = "account_id")
    protected String accountId;

    @Column(name = "holder_name")
    protected String holderName;

    protected BigDecimal balance;
}

@Entity
@Table(name = "savings_accounts")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate")
    private BigDecimal interestRate;
}

@Entity
@Table(name = "current_accounts")
public class CurrentAccount extends Account {
    @Column(name = "overdraft_limit")
    private BigDecimal overdraftLimit;
}
TEXT
savings_accounts table:                     current_accounts table:
id | account_id | holder_name | balance    id | account_id | holder_name | balance
1  | SAV2001    | Priya       | 50000.00   2  | CUR3001    | Rajesh      | 120000.00
   | interest_rate = 6.50                      | overdraft_limit = 50000.00

No shared accounts table!

Hibernate generates a UNION ALL for polymorphic queries:

TEXT
SELECT * FROM savings_accounts
UNION ALL
SELECT * FROM current_accounts

How do the three strategies compare?

This comparison table captures the key trade-offs.

| Feature | SINGLE_TABLE | JOINED | TABLE_PER_CLASS |
|---|---|---|---|
| Number of tables | 1 | 1 + N subclass tables | N concrete class tables |
| Discriminator column | Yes (required) | Optional | No |
| Polymorphic query cost | No join, fastest | JOIN across all subclass tables | UNION ALL across all tables |
| Single subtype query | No join, fastest | JOIN base + subclass table | No join, single table |
| NOT NULL on subtype columns | Not possible | Yes | Yes |
| Data normalization | Sparse (many nulls) | Fully normalized | Fully normalized |
| Insert cost | 1 INSERT | 2 INSERTs (base + subclass) | 1 INSERT |
| IDENTITY id generation | Works | Works | Does NOT work |
| Schema migration risk | Low (one table) | Medium (multiple tables) | Medium (multiple tables) |
| Default? | Yes | No | No |

JAVA
// Polymorphic query -- same JPQL, different SQL per strategy
List<Account> all = em.createQuery(
    "SELECT a FROM Account a WHERE a.balance > :min", Account.class)
    .setParameter("min", new BigDecimal("10000.00"))
    .getResultList();

// SINGLE_TABLE: SELECT * FROM accounts WHERE balance > 10000
// JOINED:       SELECT a.*, s.*, c.* FROM accounts a
//               LEFT JOIN savings_accounts s ON ...
//               LEFT JOIN current_accounts c ON ...
//               WHERE a.balance > 10000
// TABLE_PER_CLASS: SELECT ... FROM savings_accounts WHERE balance > 10000
//                  UNION ALL
//                  SELECT ... FROM current_accounts WHERE balance > 10000

How does the discriminator column work?

The discriminator column is used by SINGLE_TABLE (required) and optionally by JOINED. It stores a value that tells Hibernate which subclass each row belongs to. You configure it with @DiscriminatorColumn on the base entity and @DiscriminatorValue on each subclass.

JAVA
package com.bank.model;

import jakarta.persistence.*;

@Entity
@Table(name = "accounts")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(
    name = "account_type",
    discriminatorType = DiscriminatorType.STRING,
    length = 20
)
public abstract class Account {
    @Id
    @Column(name = "account_id")
    protected String accountId;
}

@Entity
@DiscriminatorValue("SAVINGS")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate")
    private BigDecimal interestRate;
}

@Entity
@DiscriminatorValue("CURRENT")
public class CurrentAccount extends Account {
    @Column(name = "overdraft_limit")
    private BigDecimal overdraftLimit;
}

If you omit @DiscriminatorColumn, Hibernate generates a column named DTYPE of type STRING. If you omit @DiscriminatorValue, Hibernate uses the entity class name.

Hibernate also supports @DiscriminatorFormula (a Hibernate extension, not standard JPA) for computed discriminator values from SQL expressions.

What are the rules and constraints?

Rules for SINGLE_TABLE:

  • Subtype-specific columns must be nullable (no NOT NULL constraint).
  • All subtypes share one table, so column names must be unique across the hierarchy.
  • The discriminator column is required and auto-generated if omitted.

Rules for JOINED:

  • Each subclass table's primary key is also a foreign key to the base table.
  • Use @PrimaryKeyJoinColumn to customize the join column name.
  • Inserts and updates touch two tables (base + subclass).

Rules for TABLE_PER_CLASS:

  • IDENTITY id generation does not work because ids must be unique across the union. Use SEQUENCE or TABLE generator.
  • There is no base table, so @DiscriminatorColumn is not used.
  • Some JPA providers may not fully support this strategy (it is optional in the JPA spec).
JAVA
// TABLE_PER_CLASS -- must use SEQUENCE, not IDENTITY
// WRONG:
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // fails with TABLE_PER_CLASS

// CORRECT:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
    generator = "account_seq")
@SequenceGenerator(name = "account_seq",
    sequenceName = "account_sequence")
private Long id;

What are common mistakes?

Mistake 1: Trying to enforce NOT NULL on subtype columns with SINGLE_TABLE.

JAVA
// WRONG -- SINGLE_TABLE cannot enforce NOT NULL on subtype columns
@Entity
@DiscriminatorValue("SAVINGS")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate", nullable = false) // ERROR!
    private BigDecimal interestRate;
}
// Current-account rows have NULL interest_rate,
// violating the NOT NULL constraint.
JAVA
// CORRECT -- either accept nullable or switch to JOINED
// Option 1: accept nullable with SINGLE_TABLE
@Column(name = "interest_rate") // nullable by default
private BigDecimal interestRate;

// Option 2: switch to JOINED strategy
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Account { ... }

@Entity
@Table(name = "savings_accounts")
public class SavingsAccount extends Account {
    @Column(name = "interest_rate", nullable = false) // works in JOINED
    private BigDecimal interestRate;
}

Mistake 2: Using IDENTITY with TABLE_PER_CLASS.

JAVA
// WRONG -- IDENTITY does not work with TABLE_PER_CLASS
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // each table generates its own IDs
    // SAV table might have id=1, CUR table also has id=1
    // UNION ALL returns duplicate IDs!
}
JAVA
// CORRECT -- use a shared SEQUENCE
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
        generator = "account_seq")
    @SequenceGenerator(name = "account_seq",
        sequenceName = "account_sequence", allocationSize = 1)
    private Long id; // unique across all subclass tables
}

Mistake 3: Deep inheritance hierarchies with JOINED.

JAVA
// WRONG -- 4 levels of JOINED hierarchy = 4-way JOIN on every query
@Entity @Inheritance(strategy = InheritanceType.JOINED)
public abstract class FinancialProduct { ... }

@Entity public abstract class Account extends FinancialProduct { ... }
@Entity public abstract class SavingsAccount extends Account { ... }
@Entity public class HighYieldSavings extends SavingsAccount { ... }
// Loading HighYieldSavings JOINs 4 tables!
JAVA
// CORRECT -- flatten the hierarchy or switch to SINGLE_TABLE
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "product_type")
public abstract class Account { ... }

@Entity @DiscriminatorValue("HIGH_YIELD_SAVINGS")
public class HighYieldSavings extends Account { ... }
// Single table, no joins

When should you use each strategy?

At National Bank of India, Pankaj chooses the strategy based on query patterns and data integrity needs.

  • Use SINGLE_TABLE when:
  • The hierarchy is small (2-5 subtypes) with few subtype-specific columns.
  • Polymorphic queries are frequent.
  • Performance is more important than NOT NULL constraints on subtype columns.
  • Example: Account with SavingsAccount and CurrentAccount.
  • Use JOINED when:
  • Subtype-specific columns have strict NOT NULL and CHECK constraints.
  • The hierarchy has many subtypes or many subtype-specific columns.
  • You prefer normalized data and can tolerate join cost.
  • Example: LoanProduct with HomeLoan, PersonalLoan, VehicleLoan, each with many required fields.
  • Use TABLE_PER_CLASS when:
  • Subtypes are almost always queried in isolation.
  • Polymorphic queries are rare.
  • Each subtype has a very different column set.
  • Example: legacy tables that were not designed for inheritance but you want to model with JPA.
JAVA
package com.bank.service;

import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.util.List;

public class AccountQueryService {

    private final EntityManager em;

    public AccountQueryService(EntityManager em) { this.em = em; }

    public List<Account> findHighValueAccounts() {
        return em.createQuery(
            "SELECT a FROM Account a WHERE a.balance > :min",
            Account.class)
            .setParameter("min", new BigDecimal("100000.00"))
            .getResultList();
        // SINGLE_TABLE: fast, no join
        // JOINED: works but joins all subclass tables
        // TABLE_PER_CLASS: works but UNION ALL
    }
}
TEXT
Expected output:
[SavingsAccount{SAV2001, Priya, 500000.00},
 CurrentAccount{CUR3001, Rajesh, 120000.00}]

What should a senior engineer know?

  • Second-level cache. SINGLE_TABLE is easiest to cache because one table maps directly to the entity. JOINED requires caching the join result. TABLE_PER_CLASS requires caching per concrete type.
  • Schema evolution. Adding a new subtype to SINGLE_TABLE is just adding nullable columns -- zero downtime. Adding a new subtype to JOINED is creating a new table. TABLE_PER_CLASS is creating a new table with all inherited columns.
  • Hibernate @DiscriminatorFormula. A Hibernate extension that computes the discriminator value from a SQL expression instead of a stored column. Useful when mapping to legacy schemas.
  • Mixed strategies. Hibernate allows mixing strategies in a hierarchy (for example, SINGLE_TABLE for the top level and JOINED for a branch), but this is non-standard and can be confusing.
  • Switching strategies. Changing the strategy later requires a database migration. Plan the table layout early based on expected query patterns and integrity constraints.
  • Index recommendations. For SINGLE_TABLE, index the discriminator column. For JOINED, ensure the primary key / foreign key join columns are indexed. For TABLE_PER_CLASS, index columns commonly used in WHERE clauses per table.
JAVA
// Checking strategy at runtime with Hibernate metadata
import org.hibernate.persister.entity.EntityPersister;
import org.hibernate.persister.entity.SingleTableEntityPersister;
import org.hibernate.persister.entity.JoinedSubclassEntityPersister;
import org.hibernate.persister.entity.UnionSubclassEntityPersister;

// SingleTableEntityPersister → SINGLE_TABLE
// JoinedSubclassEntityPersister → JOINED
// UnionSubclassEntityPersister → TABLE_PER_CLASS

Visual Overview

DIAGRAM
  SINGLE_TABLE:
  ┌──────────────────────────────────────────────────────────┐
  │                     accounts table                       │
  ├────────────┬──────────────┬─────────┬───────┬────────────┤
  │ account_id │ account_type │ balance │ rate  │ overdraft  │
  │ (PK)       │ (discriminator)        │(SAV)  │ (CUR)      │
  ├────────────┼──────────────┼─────────┼───────┼────────────┤
  │ SAV2001    │ SAVINGS      │ 50000   │ 6.50  │ NULL       │
  │ CUR3001    │ CURRENT      │ 120000  │ NULL  │ 50000      │
  └────────────┴──────────────┴─────────┴───────┴────────────┘
  (one table, discriminator, nullable subtype columns)
DIAGRAM
  JOINED:
  ┌────────────────────────────┐
  │     accounts (base)        │
  │ account_id │ holder │ bal  │
  │ SAV2001    │ Priya  │50000 │
  │ CUR3001    │ Rajesh │120k  │
  └─────┬──────────────┬───────┘
        │ JOIN         │ JOIN
        ▼              ▼
  ┌──────────────┐ ┌──────────────────┐
  │savings_accts │ │current_accts     │
  │ account_id   │ │ account_id       │
  │ interest_rate│ │ overdraft_limit  │
  │ SAV2001│6.50 │ │ CUR3001│50000    │
  └──────────────┘ └──────────────────┘
  (base + subclass tables, joined by PK)
DIAGRAM
  TABLE_PER_CLASS:
  ┌────────────────────────────┐  ┌─────────────────────────────┐
  │   savings_accounts         │  │   current_accounts          │
  │ id│acct_id│holder│bal│rate │  │ id│acct_id│holder│bal│limit │
  │ 1 │SAV2001│Priya │50k│6.50│  │ 2 │CUR3001│Rajesh│120k│50k  │
  └────────────────────────────┘  └─────────────────────────────┘
  (separate complete tables, no shared base table)
  Polymorphic query = UNION ALL of both tables

Key Takeaway

When you use @Inheritance for an entity hierarchy, JPA gives you three strategies that each lay out the database differently. SINGLE_TABLE, the default, stores the whole hierarchy in one table with a discriminator column identifying each row's type. Subtype-specific columns are nullable because they do not apply to every row. It is the fastest because queries need no joins, but subtype-only columns cannot have NOT NULL constraints at the database level.

JOINED normalizes the model by giving the base class a table with common columns and each subclass its own table with only its extra columns, linked by a primary key that is also a foreign key. This enforces NOT NULL on subtype columns because they live in their own tables, but every read pays a join cost, and polymorphic queries join across all subclass tables. TABLE_PER_CLASS gives each concrete subclass its own complete standalone table with all inherited columns, so a single-subtype read has no join, but polymorphic queries require UNION ALL across every table, which is slow, and IDENTITY id generation does not work because ids must be unique across the union.

At National Bank of India, Pankaj chooses based on query patterns and integrity needs. The account hierarchy uses SINGLE_TABLE because accounts are read constantly and polymorphically, and the join cost of JOINED would hurt, accepting a couple of nullable subtype columns. The loan product hierarchy uses JOINED because each loan type has many required subtype-specific fields that need NOT NULL constraints. TABLE_PER_CLASS is reserved for the rare case where subtypes are queried in isolation and polymorphic queries are nearly nonexistent.

Remember one sentence: SINGLE_TABLE is fastest with one table and a discriminator; JOINED is normalized with a join per read; TABLE_PER_CLASS is standalone tables with UNION ALL for polymorphic queries.

---

Share
Use ← → arrow keys to navigate