Definition
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.
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
}
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:
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.
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;
}
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:
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.
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;
}
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:
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 |
// 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.
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
@PrimaryKeyJoinColumnto 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
@DiscriminatorColumnis not used. - Some JPA providers may not fully support this strategy (it is optional in the JPA spec).
// 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.
// 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.
// 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.
// 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!
}
// 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.
// 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!
// 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.
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
}
}
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.
// 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
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)
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)
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.
---