Hibernate Table Per Subclass Inheritance with JOINED (2026)
Last Updated: | Author: Deep Singh
When your domain model has an inheritance hierarchy — say a Person base class with Employee and Student subclasses — you need to decide how that hierarchy maps to database tables. Hibernate supports three strategies, and the one we'll look at here is table per subclass, activated by InheritanceType.JOINED.
In this tutorial, you'll build a small Person/Employee/Student model and save one of each. Along the way, you'll see exactly what tables Hibernate creates, how the JOINs work, and why this strategy is usually the best default for production systems.
The Three Inheritance Strategies
Before we jump into code, here's a quick comparison of the three strategies Hibernate supports:
- SINGLE_TABLE — one table for the whole hierarchy, with a discriminator column. Fast reads, but no NOT NULL constraints on subclass columns.
- JOINED (table per subclass) — a table per class. Shared fields go into the parent table; subclass-specific fields go into their own tables, linked by primary key. Normalized, supports NOT NULL, but requires JOINs for queries.
- TABLE_PER_CLASS — a fully independent table per concrete class, with duplicated columns. Rarely used; polymorphic queries become expensive UNIONs.
In this tutorial we're using JOINED. It's the most normalized of the three and works well when you need foreign keys pointing to the base type.
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 is 15 years old. The MySQL connector has since moved to the group com.mysql and the artifact mysql-connector-j. The driver class name changed too, from com.mysql.jdbc.Driver to com.mysql.cj.jdbc.Driver.
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.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.JOINED)
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's important here:
@Inheritance(strategy = InheritanceType.JOINED)— tells Hibernate to use table per subclass.@Table(name = "person")— explicitly names the parent table. The original used"tph"(which stands for "table per hierarchy" — the wrong abbreviation for a JOINED strategy). "person" is clearer.@GeneratedValue(strategy = GenerationType.IDENTITY)— the original usedAUTO, which works but may choose an unexpected generation strategy depending on the database. IDENTITY is explicit and matches MySQL'sAUTO_INCREMENT.
Employee.java — Subclass
package com.deepsingh44.model;
import javax.persistence.Entity;
import javax.persistence.PrimaryKeyJoinColumn;
@Entity
@PrimaryKeyJoinColumn(name = "id")
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.Entity;
import javax.persistence.PrimaryKeyJoinColumn;
@Entity
@PrimaryKeyJoinColumn(name = "id")
public class Student extends Person {
private float marks;
public float getMarks() {
return marks;
}
public void setMarks(float marks) {
this.marks = marks;
}
}
Why @PrimaryKeyJoinColumn? In the JOINED strategy, each subclass table has its own primary key that's also a foreign key to the parent table. @PrimaryKeyJoinColumn(name = "id") tells Hibernate which column name to use for that shared key. Without it, Hibernate falls back to the default name id, which works here, but being explicit is good practice — especially if you ever rename the parent column.
Note: The original tutorial didn't include @PrimaryKeyJoinColumn. It worked because Hibernate defaulted to the field name, but the code was relying on an implicit convention rather than declaring intent.
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 here:
- Transaction opened before saving. The original called
session.save()three times beforesession.beginTransaction().commit()— the order was wrong. It worked by accident because Hibernate auto-began on the first save, but that's fragile. Now the transaction is opened first. - Try/catch/finally. Ensures rollback on failure and proper cleanup.
- Session and factory closed. The original never closed either. In a short-lived test this doesn't matter, but the habit of leaving connections open is a leading cause of production bugs.
How the Tables Are Created
With hbm2ddl.auto=update, Hibernate creates three tables when the app starts:
CREATE TABLE person (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
PRIMARY KEY (id)
);
CREATE TABLE employee (
id INT NOT NULL,
salary FLOAT,
PRIMARY KEY (id),
FOREIGN KEY (id) REFERENCES person(id)
);
CREATE TABLE student (
id INT NOT NULL,
marks FLOAT,
PRIMARY KEY (id),
FOREIGN KEY (id) REFERENCES person(id)
);
Notice the structure:
- Shared fields (
name) live only inperson. - Subclass-specific fields (
salary,marks) live in their own tables. - Each subclass table's primary key is a foreign key back to
person. That's what "table per subclass" means — the identifier is shared, not duplicated.
What Hibernate Does When You Save
When you call session.save(emp):
- Hibernate inserts a row into
personwith the name. - It reads back the generated ID.
- It inserts a row into
employeeusing that same ID as the primary key.
For a plain Person, only the person table gets a row. For a Student, both person and student get rows.
What a Query Looks Like
When you load an Employee by ID, Hibernate generates a JOIN:
SELECT e.id, p.name, e.salary
FROM employee e
INNER JOIN person p ON e.id = p.id
WHERE e.id = ?;
This is the trade-off with JOINED: you get a clean, normalized schema, but every read requires a JOIN. For most applications this is a fine trade-off. For extremely read-heavy workloads where the JOIN becomes a bottleneck, SINGLE_TABLE can be more efficient.
Common Pitfalls
- Forgetting
@Inheritanceon the base class. Without it, Hibernate has no idea you have a hierarchy. It will treat each class as independent and you'll get separate, unlinked tables. - Mixing strategies across subclasses. If you want to override the strategy for a specific subclass, use
@Inheritanceon the subclass itself. Don't assume the base class's strategy always applies. - Wrong
@GeneratedValuestrategy.GenerationType.AUTOpicks a strategy based on the dialect. On MySQL it may pick TABLE or SEQUENCE, which is slower than IDENTITY. Be explicit. - Assuming the parent is abstract. It doesn't have to be — you can save a plain
Personrow, as this tutorial does. If you want to force subclassing, mark the base classabstract. - Forgetting to register all classes. You must call
addAnnotatedClass()for the base class and each subclass. Missing one means Hibernate won't map it. - Not closing the session. Even in a short test, close the session. In a real application, an unclosed session holds a JDBC connection from the pool.
- Deprecated MySQL driver class.
com.mysql.jdbc.Driverwas removed in Connector/J 8. Usecom.mysql.cj.jdbc.Driver. - Missing dialect. Hibernate can often guess the dialect from the connection URL, but on some setups it warns and defaults poorly. Set
hibernate.dialectexplicitly. - Expecting JOINED to be fast at scale. It's normalized and correct, but deep hierarchies plus large tables can make polymorphic queries slow. Benchmark before committing to it for very high-volume systems.
Wrapping Up
You now know how to map an inheritance hierarchy in Hibernate using the JOINED (table per subclass) strategy. It's the most normalized of the three, supports foreign keys back to the base type, and gives you clean NOT NULL constraints on subclass columns.
The key annotations to remember are @Inheritance(strategy = InheritanceType.JOINED) on the base class and @PrimaryKeyJoinColumn on each subclass to declare the shared primary key.
From here, you can compare strategies by switching the InheritanceType and observing the schema Hibernate generates. Try SINGLE_TABLE and TABLE_PER_CLASS on the same class structure — it's the fastest way to understand the trade-offs.
No comments:
Post a Comment