Hibernate Table Per Hierarchy (SINGLE_TABLE) Tutorial (2026)
Last Updated: | Author: Deep Singh
Hibernate supports three strategies for mapping an inheritance hierarchy to database tables: SINGLE_TABLE, JOINED, and TABLE_PER_CLASS. We've already covered the other two. Now we're looking at the third — SINGLE_TABLE, also called table per hierarchy.
It's the simplest of the three and, in many projects, the fastest. All classes in the hierarchy share one table, and a discriminator column tells Hibernate which rows belong to which class. Let's build the Person/Employee/Student example with this strategy.
How SINGLE_TABLE Differs from the Others
Here's how the three strategies compare at a glance:
- SINGLE_TABLE — one table for the whole hierarchy. A discriminator column identifies the concrete type. Fastest reads, no joins, but no NOT NULL on subclass columns. This is what we're covering here.
- JOINED — a base table plus a table per subclass, sharing the primary key. Normalized, supports NOT NULL, but every read uses JOINs.
- TABLE_PER_CLASS — an independent table per concrete class. Shared fields are duplicated. Polymorphic queries use UNION.
SINGLE_TABLE is the default in JPA (and Hibernate) when you use @Inheritance without a strategy. But being explicit is better — declare it as shown below.
Maven Dependencies
Add the following dependencies to your pom.xml inside the <dependencies> block.
<dependencies>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.6.15.Final</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
</dependency>
</dependencies>
Note on versions: The original tutorial used mysql:mysql-connector-java:5.1.6, which was released in 2010. The connector has moved to com.mysql:mysql-connector-j since Connector/J 8.0.31. The driver class also changed, which we'll reflect in the configuration file.
hibernate.cfg.xml Configuration
Place this file in src/main/resources.
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="hibernate.connection.driver_class">
com.mysql.cj.jdbc.Driver
</property>
<property name="hibernate.connection.url">
jdbc:mysql://localhost:3306/stds?useSSL=false&serverTimezone=UTC
</property>
<property name="hibernate.connection.username">root</property>
<property name="hibernate.connection.password">root</property>
<property name="hibernate.dialect">
org.hibernate.dialect.MySQL8Dialect
</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.show_sql">true</property>
<property name="hibernate.format_sql">true</property>
</session-factory>
</hibernate-configuration>
The Entity Classes
Person.java — The Base Class
package com.deepsingh44.model;
import javax.persistence.DiscriminatorColumn;
import javax.persistence.DiscriminatorType;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Inheritance;
import javax.persistence.InheritanceType;
import javax.persistence.Table;
@Entity
@Table(name = "person")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "role", discriminatorType = DiscriminatorType.STRING)
@DiscriminatorValue(value = "person")
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
What each annotation does:
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)— one table for all concrete classes in the hierarchy.@DiscriminatorColumn(name = "role", discriminatorType = DiscriminatorType.STRING)— tells Hibernate to add a column calledrolethat stores a string identifying each row's type.@DiscriminatorValue("person")— the value stored in the discriminator column when saving a plainPerson.
Changes from the original: The table name was "tph", which was probably a placeholder. I've changed it to "person" for clarity. I've also changed GenerationType.AUTO to GenerationType.IDENTITY — IDENTITY matches MySQL's AUTO_INCREMENT, which is what AUTO picks under the hood anyway. Being explicit avoids surprises when you switch databases.
Employee.java — Subclass
package com.deepsingh44.model;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue(value = "employee")
public class Employee extends Person {
private float salary;
public float getSalary() {
return salary;
}
public void setSalary(float salary) {
this.salary = salary;
}
}
Student.java — Subclass
package com.deepsingh44.model;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue(value = "student")
public class Student extends Person {
private float marks;
public float getMarks() {
return marks;
}
public void setMarks(float marks) {
this.marks = marks;
}
}
Notice what's missing: there's no @Table on the subclasses, and no @PrimaryKeyJoinColumn like in the JOINED strategy. Both subclasses inherit the table and primary key from Person. The only subclass-specific annotation is @DiscriminatorValue, which tells Hibernate what to store in the discriminator column for each type.
Saving Records — Test.java
package com.deepsingh44.model;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
public class Test {
public static void main(String[] args) {
StandardServiceRegistry ssr = new StandardServiceRegistryBuilder()
.configure("hibernate.cfg.xml")
.build();
Metadata meta = new MetadataSources(ssr)
.addAnnotatedClass(Person.class)
.addAnnotatedClass(Employee.class)
.addAnnotatedClass(Student.class)
.getMetadataBuilder()
.build();
SessionFactory factory = meta.getSessionFactoryBuilder().build();
Session session = factory.openSession();
Transaction tx = session.beginTransaction();
try {
Person p = new Person();
p.setName("Mala");
Employee emp = new Employee();
emp.setName("Sujeet");
emp.setSalary(50000);
Student s = new Student();
s.setName("Rohit");
s.setMarks(78.8f);
session.save(p);
session.save(emp);
session.save(s);
tx.commit();
System.out.println("Saved successfully");
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
session.close();
factory.close();
}
}
}
What's fixed: Same fixes as the other two inheritance tutorials. The transaction is opened before the saves, errors are caught with rollback, and both the session and factory are properly closed.
How the Table Looks
With hbm2ddl.auto=update, Hibernate creates exactly one table when the app starts:
CREATE TABLE person (
role VARCHAR(31) NOT NULL,
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
salary FLOAT,
marks FLOAT,
PRIMARY KEY (id)
);
Notice a few key things:
- One table for everything. All three classes share the same table. There's no
employeetable and nostudenttable. - All columns are present. Both
salary(from Employee) andmarks(from Student) live in the same table. - The
rolecolumn is the discriminator. It stores'person','employee', or'student'depending on the row type. Its default length is 31 because that's what JPA specifies for string discriminators. - No NOT NULL on subclass columns. This is the biggest limitation of SINGLE_TABLE. Since a row might be a Person, an Employee, or a Student, the
salaryandmarkscolumns must be nullable. A Person row hassalary = NULLandmarks = NULL.
What Hibernate Does When You Save
When you call session.save(emp), Hibernate generates:
INSERT INTO person (name, salary, marks, role)
VALUES ('Sujeet', 50000, NULL, 'employee');
For a plain Person:
INSERT INTO person (name, salary, marks, role)
VALUES ('Mala', NULL, NULL, 'person');
For a Student:
INSERT INTO person (name, salary, marks, role)
VALUES ('Rohit', NULL, 78.8, 'student');
The role column is what Hibernate uses to determine the concrete type when reading the row back.
What a Query Looks Like
When you load all Employee instances, Hibernate adds a WHERE clause on the discriminator:
SELECT id, name, salary, marks, role
FROM person
WHERE role = 'employee';
Loading a plain Person gets a UNION-style filter:
SELECT id, name, salary, marks, role
FROM person
WHERE role IN ('person', 'employee', 'student');
Since there's only one table, no JOIN is ever needed. That's the main performance win of SINGLE_TABLE.
Why This Strategy Is Fast
Every query on any class in the hierarchy hits a single table. No joins, no unions, no separate lookups. That's why SINGLE_TABLE is often the fastest choice — especially for read-heavy applications.
The trade-off is that your table accumulates columns from every subclass. If you have a hierarchy with five subclasses each adding two fields, your table has ten columns, and any row only uses a subset of them. Some rows will have many NULL columns.
Common Pitfalls
- Forgetting
@DiscriminatorColumn. Hibernate will use a default column calledDTYPE. That works, but being explicit makes your schema self-documenting. - Duplicate discriminator values. Two classes can't share the same
@DiscriminatorValue. Hibernate throws an exception at startup if they do. - Assuming subclass columns can be NOT NULL. They can't. In SINGLE_TABLE, every subclass column is nullable because a row might be a different type.
- Adding many subclasses. The table grows wider with every subclass. Beyond five or six subclasses, JOINED usually becomes the cleaner choice.
- Forgetting to register all classes. You must call
addAnnotatedClass()for the base class and every subclass. Missing one means Hibernate won't map it. - Making the base class abstract. It's optional. If you don't need to persist plain
Personrows, mark itabstractand drop the@DiscriminatorValueon it. The other subclasses keep their own discriminator values. - Deprecated MySQL driver class.
com.mysql.jdbc.Driverwas removed in Connector/J 8. Usecom.mysql.cj.jdbc.Driver. - No dialect set. Hibernate can usually guess, but on some setups it defaults poorly. Set
hibernate.dialectexplicitly. - Broken transaction order. Open the transaction before saving. Relying on Hibernate to auto-begin is fragile.
Wrapping Up
You now know how to map an inheritance hierarchy using the SINGLE_TABLE strategy in Hibernate. It's the simplest of the three, the fastest to query, and the one JPA uses by default.
Here's a summary of when to reach for each strategy:
- SINGLE_TABLE — small hierarchies, read-heavy workloads, no NOT NULL requirements on subclass columns.
- JOINED — deep hierarchies, normalized schema needed, foreign keys to subclasses, NOT NULL constraints required.
- TABLE_PER_CLASS — rarely polymorphic queries, fastest possible single-class reads, willing to duplicate columns.
In most production systems, SINGLE_TABLE and JOINED cover the majority of use cases. TABLE_PER_CLASS is the specialist.
No comments:
Post a Comment