Hibernate Many To Many Annotation Tutorial

Hibernate Many-to-Many Annotation Mapping Tutorial (2026) | D.Tech Academic

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. The mappedBy value 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 Set instead of a List avoids duplicate rows and makes some Hibernate operations cleaner.
  • Table renamed from "client" to "clients" — client isn'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 a Product cascades to its associated Clients. 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 to Product.
  • inverseJoinColumns — the column on the join table that points to Client.
  • 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 (txt instead of tx). 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 used List<Client> with new ArrayList() — but the field type in Product was a List, so it worked. Since we switched the entity fields to Set, we use HashSet here to match.
  • Cascade works. Because Product.clients has CascadeType.ALL, saving the product also saves the two clients. Without the cascade, you'd get a TransientObjectException.

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_id and client_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):

  1. Hibernate inserts c1 and c2 into clients (thanks to cascade).
  2. It inserts p into products.
  3. 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 List instead of Set. A List allows duplicate rows in the join table, which is almost never what you want in a many-to-many. Use Set unless you have a specific reason to allow duplicates.
  • Not setting mappedBy on 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 use mappedBy.
  • Forgetting CascadeType. Without cascade on the owning side, saving a Product with unsaved clients throws TransientObjectException. You'd have to save the clients first.
  • Using CascadeType.ALL when 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. Consider PERSIST and MERGE instead of ALL.
  • Not using HashSet for the field initializer. A null collection throws when Hibernate tries to add rows. Always initialise the collection (= new HashSet<>()).
  • Implementing equals() and hashCode() incorrectly. For entities in a Set, Hibernate relies on identity semantics. If you override equals() without a solid understanding, you can get subtle bugs. A common recommendation is to base equals() 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.Driver was removed in Connector/J 8. Use com.mysql.cj.jdbc.Driver.
  • No dialect set. Hibernate can usually guess, but on some setups it defaults poorly. Set hibernate.dialect explicitly.

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 with new HashSet<>().
  • Add CascadeType carefully — PERSIST and MERGE are usually safer than ALL.

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 @ManyToMany directly.
  • Bi-directional navigation. We've set this up already — from Client, you can get to Product, and vice versa.
  • Lazy vs eager loading. Both sides default to FetchType.LAZY, which is what you want. If you need to eagerly load, use JOIN FETCH in 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: