Hibernate Composite Key with @EmbeddedId: Complete Tutorial (2026)
Last Updated: | Author: Deep Singh
Not every table has a single-column primary key. Sometimes the uniqueness of a row depends on a combination of two or three columns. That's called a composite key (or composite primary key), and Hibernate gives you clean ways to model it.
In this tutorial, you'll learn how to map a composite key using @EmbeddedId and @Embeddable. We'll build a small Student entity whose uniqueness is defined by the combination of mobile and email. By the end, you'll know exactly how to set it up, save records, and avoid the most common traps.
What is a Composite Key?
A composite key is a primary key made up of two or more columns. In practice, this happens when no single column uniquely identifies a row, but a combination of columns does.
Common examples include:
- An enrollment table, where the key is
(student_id, course_id). - An order_items table, where the key is
(order_id, product_id). - A user_permissions table, where the key is
(user_id, permission_id).
Hibernate supports two ways to model composite keys:
@EmbeddedId— the key is a separate class embedded into the entity. This is what we'll use in this tutorial.@IdClass— the key fields are declared directly on the entity, and a separate class mirrors them. Less common but occasionally useful.
For most cases, @EmbeddedId is the cleaner choice because it groups all key fields into a single, reusable class.
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:
- I've upgraded Hibernate to
5.6.15.Final— the latest stable 5.x release. It's the last version before Hibernate 6, which changed many APIs. - I've switched the MySQL connector to
com.mysql:mysql-connector-j— the new group and artifact ID used since Connector/J 8.0.31. The oldmysql:mysql-connector-javaand version5.1.6from the original tutorial are years out of date.
hibernate.cfg.xml Configuration
Place this file in your src/main/resources folder.
<?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>
Fixed in this version: The original used com.mysql.jdbc.Driver, which is deprecated and prints warnings on Connector/J 8. I've replaced it with com.mysql.cj.jdbc.Driver. I've also added the MySQL dialect, which Hibernate needs to generate correct SQL.
Entity and Composite Key Classes
Two classes make up the composite key setup: the entity itself, and the key class that it embeds.
StudentKey.java — The Composite Key
package com.deepsingh44.model;
import java.io.Serializable;
import java.util.Objects;
import javax.persistence.Embeddable;
@Embeddable
public class StudentKey implements Serializable {
private String mobile;
private String email;
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof StudentKey)) return false;
StudentKey that = (StudentKey) o;
return Objects.equals(mobile, that.mobile)
&& Objects.equals(email, that.email);
}
@Override
public int hashCode() {
return Objects.hash(mobile, email);
}
}
Key points about the key class:
- It's marked
@Embeddableso Hibernate knows it can be embedded into an entity. - It must implement
Serializable. Hibernate requires this because composite keys can be used as identifiers in caches and detached entities. - You must override
equals()andhashCode(). Without these, Hibernate's session cache can't properly identify entities, and you'll get subtle bugs where the same logical key is treated as different. - Use
Objects.equals()andObjects.hash()to write clean implementations.
Student.java — The Entity
package com.deepsingh44.model;
import javax.persistence.EmbeddedId;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "students")
public class Student {
@EmbeddedId
private StudentKey studentKey;
private String name;
private String address;
public StudentKey getStudentKey() {
return studentKey;
}
public void setStudentKey(StudentKey studentKey) {
this.studentKey = studentKey;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
}
Key points about the entity:
@Entitymarks it as a persistent entity.@Table(name = "students")specifies the table name explicitly — "student" is safer as a plural anyway, and "user" / "order" / "group" would be reserved words.@EmbeddedIdtells Hibernate that the primary key is the embeddedStudentKey.- The other fields (
nameandaddress) are regular columns.
Saving a Record — Test.java
Here's a complete program that bootstraps Hibernate, creates a student with a composite key, and saves it to the database.
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) {
// 1. Build the service registry from hibernate.cfg.xml
StandardServiceRegistry ssr = new StandardServiceRegistryBuilder()
.configure("hibernate.cfg.xml")
.build();
// 2. Register annotated classes and build metadata
Metadata meta = new MetadataSources(ssr)
.addAnnotatedClass(Student.class)
.getMetadataBuilder()
.build();
// 3. Build the session factory
SessionFactory factory = meta.getSessionFactoryBuilder().build();
// 4. Open a session
Session session = factory.openSession();
// 5. Start a transaction (required for save/update/delete)
Transaction tx = session.beginTransaction();
try {
// 6. Build the composite key
StudentKey sk = new StudentKey();
sk.setEmail("d@gmail.com");
sk.setMobile("98XXXXXXXX");
// 7. Build the entity
Student s = new Student();
s.setName("Deep Singh");
s.setAddress("India");
s.setStudentKey(sk);
// 8. Save
session.save(s);
// 9. Commit
tx.commit();
System.out.println("Saved successfully");
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
session.close();
factory.close();
}
}
}
What's been fixed here:
- Transaction handling. The original code called
session.save(s)and thensession.beginTransaction().commit()— committing a transaction that was never opened before the save. In Hibernate 5.x this usually works becausesave()auto-begins, but it's fragile. The corrected version opens the transaction first, commits after the save, and rolls back on failure. - Try/catch/finally. Ensures the session is closed even if something fails. This prevents connection leaks.
- Swapped the swapped values. The original had
sk.setEmail("98XXXXXXXX")andsk.setMobile("d@gmail.com")— the values were assigned to the wrong fields. Fixed.
How the Code Works
Let's walk through the key ideas.
1. What @Embeddable and @EmbeddedId Do
@Embeddable tells Hibernate "this class can be embedded inside another entity as part of its state." @EmbeddedId tells Hibernate "this field is my primary key, and its columns make up my composite primary key."
Hibernate looks at the fields inside the embeddable and maps each one to a column in the entity's table. So the StudentKey's mobile and email become the mobile and email columns in the students table — both of them, together, forming the primary key.
2. Why equals and hashCode Matter
Hibernate's first-level cache is keyed on the entity's identifier. When you load or save an entity, Hibernate checks the cache by calling equals() on the key. Without a proper implementation, two StudentKey objects with the same mobile and email would be treated as different keys, causing duplicate rows or unexpected session behavior.
Use Objects.equals() and Objects.hash() — they handle nulls correctly and are less error-prone than writing it out manually.
3. The Session Lifecycle
Opening a session is cheap, but you should still close it when you're done. A session holds a JDBC connection from the pool, so leaving it open can exhaust the pool over time.
The transaction ensures the save is atomic. If something throws between beginTransaction() and commit(), the rollback undoes the changes.
4. What the Database Table Looks Like
After running the program, Hibernate creates the following table (thanks to hbm2ddl.auto=update):
CREATE TABLE students (
mobile VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
address VARCHAR(255),
PRIMARY KEY (mobile, email)
);
Notice the composite primary key on (mobile, email). You can have multiple rows with the same mobile as long as the email is different, and vice versa. But the same combination can't repeat.
Common Pitfalls
- Forgetting
Serializable. Hibernate won't start if the composite key class doesn't implement it. This is a hard requirement, not a suggestion. - Missing
equals()andhashCode(). Without them, Hibernate's session cache misbehaves and you'll see duplicate entities or incorrect updates. - Saving without a transaction. In modern Hibernate (5.x and 6.x), you should always open a transaction before
save(),update(), ordelete(). Relying on auto-begin is fragile. - Not closing the session. Connection leaks build up fast in production. Always use try-with-resources or a finally block.
- Deprecated MySQL driver. The old
com.mysql.jdbc.Driveris removed from Connector/J 8. Usecom.mysql.cj.jdbc.Driverinstead. - Using
save()on an already-persisted entity. UsesaveOrUpdate()if you're not sure whether the entity is new or detached. - Version mismatch. If you're on Hibernate 6 or Java 17+, you'll need to migrate from
javax.persistence.*tojakarta.persistence.*. The APIs are otherwise similar. - Assuming a single transaction covers all operations. Each logical unit of work needs its own transaction. Don't span user interactions.
An alternative worth knowing: Hibernate 6 and JPA 2.1 introduced a simpler option — just use @Id on multiple fields if you're using @IdClass, or use a @ManyToOne relationship with @MapsId for the foreign-key-based composite keys common in join tables. The @EmbeddedId approach shown here is still the most explicit and readable.
Wrapping Up
You now know how to map a composite primary key in Hibernate using @EmbeddedId and @Embeddable. The pattern is simple: put the key fields into a small Serializable class with equals() and hashCode(), mark it @Embeddable, and reference it from the entity with @EmbeddedId.
From here, you can extend the example with:
- Named queries that use the composite key in WHERE clauses.
- Second-level caching with the composite key — as long as equals and hashCode are correct, caching works cleanly.
- Relationship mapping — using the composite key as the foreign key target in a one-to-many association.
- Migration to Hibernate 6 when you're ready — the annotations are largely the same, just the package names change.
No comments:
Post a Comment