Hibernate Many-to-Many Annotation Mapping Tutorial (2026)
Last Updated: | Author: Deep Singh
Some relationships are simple — one order belongs to one customer. Others are inherently many-to-many: a product can be bought by many clients, and a client can buy many products. In a relational database, you can't model that directly. You need a third table in the middle, sometimes called a junction table or join table.
In this tutorial, you'll learn how to map a many-to-many relationship in Hibernate using the @ManyToMany and @JoinTable annotations. We'll build a small Client / Product example and save both sides of the relationship in a single call.
How Many-to-Many Works in Hibernate
Two entities are related many-to-many when each row in table A can be linked to many rows in table B, and vice versa. Since relational databases can't represent that directly, we introduce a third table — the join table — that contains pairs of foreign keys.
client join_table product
+------+ +-----------+-------+ +------+
| id | <-----> | client_id | pdt_id| <-| id |
| name | +-----------+-------+ | name |
+------+ +------+
In Hibernate, you mark one (or both) sides with @ManyToMany and specify the join table with @JoinTable. Hibernate creates the join table automatically and inserts the correct rows when you save either side.
In practice, you designate one side as the owning side (the one that has @JoinTable) and the other as the inverse side (with mappedBy). Only the owning side controls the contents of the join table.
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 from 2010. The connector has moved to com.mysql:mysql-connector-j since Connector/J 8.0.31. The driver class also changed from com.mysql.jdbc.Driver to com.mysql.cj.jdbc.Driver, which we'll reflect in the config 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
Client.java — The Inverse Side
package com.deepsingh44.model;
import java.util.HashSet;
import java.util.Set;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
@Entity
@Table(name = "clients")
public class Client {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToMany(mappedBy = "clients")
private Set<Product> products = new HashSet<>();
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;
}
public Set<Product> getProducts() {
return products;
}
public void setProducts(Set<Product> products) {
this.products = products;
}
}
Key points:
@ManyToMany(mappedBy = "clients")— this side is inverse. ThemappedByvalue points to the field on the owning side (Product.clients). It tells Hibernate "don't create a second join table; the join table is defined on the other entity."- Using a
Setinstead of aListavoids duplicate rows and makes some Hibernate operations cleaner. - Table renamed from "client" to "clients" —
clientisn't reserved, but plural table names are the common convention.
What's new compared to the original: The original only had @ManyToMany on Product, with no back-reference on Client. That's a valid one-directional mapping — Hibernate can save it — but you can't navigate from Client to Product in code. Making the mapping bidirectional is more useful in practice and matches how most real applications model many-to-many relationships.
Product.java — The Owning Side
package com.deepsingh44.model;
import java.util.HashSet;
import java.util.Set;
import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(
name = "client_product",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "client_id")
)
private Set<Client> clients = new HashSet<>();
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;
}
public Set<Client> getClients() {
return clients;
}
public void setClients(Set<Client> clients) {
this.clients = clients;
}
}
Key points:
@ManyToMany(cascade = CascadeType.ALL)— saving aProductcascades to its associatedClients. Without this, you'd have to save the clients separately first.@JoinTable(name = "client_product", ...)— declares the join table explicitly. The original just used@JoinTable(name = "third_table"), letting Hibernate pick column names.joinColumns— the column on the join table that points back toProduct.inverseJoinColumns— the column on the join table that points toClient.- Table renamed from "pdt" to "products", and join table from "third_table" to "client_product" — much clearer names.
Note: Hibernate 5.4+ actually accepts a plain @JoinTable(name = "...") and will fall back to sensible column names. But declaring joinColumns and inverseJoinColumns explicitly is better practice: you know exactly what schema you're getting, and future migrations won't surprise you.
Saving Records — Test.java
package com.deepsingh44.model;
import java.util.HashSet;
import java.util.Set;
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(Client.class)
.addAnnotatedClass(Product.class)
.getMetadataBuilder()
.build();
SessionFactory factory = meta.getSessionFactoryBuilder().build();
Session session = factory.openSession();
Transaction tx = session.beginTransaction();
try {
Client c1 = new Client();
c1.setName("Rohit");
Client c2 = new Client();
c2.setName("Syed");
Set<Client> clients = new HashSet<>();
clients.add(c1);
clients.add(c2);
Product p = new Product();
p.setName("Pen");
p.setClients(clients);
session.save(p);
tx.commit();
System.out.println("Saved successfully");
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
session.close();
factory.close();
}
}
}
What's fixed:
- Correct transaction order. The original opened the transaction after constructing the objects but before saving — which is fine — but used inconsistent naming (
txtinstead oftx). Now the transaction is opened once, commits cleanly, and rolls back on failure. - Try/catch/finally. Ensures the session and factory are closed even when something throws.
- Switched to
Set. The original usedList<Client>withnew ArrayList()— but the field type inProductwas aList, so it worked. Since we switched the entity fields toSet, we useHashSethere to match. - Cascade works. Because
Product.clientshasCascadeType.ALL, saving the product also saves the two clients. Without the cascade, you'd get aTransientObjectException.
How the Tables Are Created
With hbm2ddl.auto=update, Hibernate creates three tables:
CREATE TABLE clients (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
PRIMARY KEY (id)
);
CREATE TABLE products (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
PRIMARY KEY (id)
);
CREATE TABLE client_product (
product_id INT NOT NULL,
client_id INT NOT NULL,
PRIMARY KEY (product_id, client_id),
FOREIGN KEY (product_id) REFERENCES products(id),
FOREIGN KEY (client_id) REFERENCES clients(id)
);
Notice the structure of the join table:
- Two foreign key columns,
product_idandclient_id. - A composite primary key on both columns together — this ensures the same pairing can't be inserted twice.
- Two foreign keys back to the source tables, enforcing referential integrity.
What Hibernate Does When You Save
When you call session.save(p):
- Hibernate inserts
c1andc2intoclients(thanks to cascade). - It inserts
pintoproducts. - It inserts two rows into
client_product: one for (Pen, Rohit) and one for (Pen, Syed).
The generated SQL looks roughly like:
INSERT INTO clients (name) VALUES ('Rohit');
INSERT INTO clients (name) VALUES ('Syed');
INSERT INTO products (name) VALUES ('Pen');
INSERT INTO client_product (product_id, client_id) VALUES (1, 1);
INSERT INTO client_product (product_id, client_id) VALUES (1, 2);
Common Pitfalls
- Using
Listinstead ofSet. AListallows duplicate rows in the join table, which is almost never what you want in a many-to-many. UseSetunless you have a specific reason to allow duplicates. - Not setting
mappedByon the inverse side. If both sides declare@JoinTable, Hibernate will try to create two separate join tables. Only one side should declare it — the owning side — and the other side must usemappedBy. - Forgetting
CascadeType. Without cascade on the owning side, saving aProductwith unsaved clients throwsTransientObjectException. You'd have to save the clients first. - Using
CascadeType.ALLwhen you don't want it. Cascading to delete is dangerous — deleting a Product would delete all its Clients, even if those Clients are linked to other Products. ConsiderPERSISTandMERGEinstead ofALL. - Not using
HashSetfor the field initializer. A null collection throws when Hibernate tries to add rows. Always initialise the collection (= new HashSet<>()). - Implementing
equals()andhashCode()incorrectly. For entities in a Set, Hibernate relies on identity semantics. If you overrideequals()without a solid understanding, you can get subtle bugs. A common recommendation is to baseequals()on the ID once it's assigned. - Bidirectional cascades. If both sides cascade to each other with
CascadeType.ALL, saving or deleting can cause infinite recursion. Only the owning side should cascade broadly. - Broken transaction order. The original called
session.beginTransaction()after the object construction but before the save — worked by accident. Open the transaction before any persistence operation. - 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.
Wrapping Up
You now know how to map a many-to-many relationship in Hibernate using @ManyToMany and @JoinTable. The key ideas are:
- Designate one side as the owning side with
@JoinTable. - Mark the other side as inverse with
mappedBy. - Use
Set<T>and initialise it withnew HashSet<>(). - Add
CascadeTypecarefully —PERSISTandMERGEare usually safer thanALL.
From here, you can extend the example with:
- Extra columns on the join table. If the relationship itself has attributes — like "date added" — you'll need to model the join table as its own entity rather than using
@ManyToManydirectly. - Bi-directional navigation. We've set this up already — from
Client, you can get toProduct, and vice versa. - Lazy vs eager loading. Both sides default to
FetchType.LAZY, which is what you want. If you need to eagerly load, useJOIN FETCHin a query rather than changing the fetch type. - Second-level cache. Many-to-many relationships benefit significantly from caching once the schema is stable.
No comments:
Post a Comment