Entity Relationships & Associations

@ManyToMany

18 min Lesson 4 of 13

@ManyToMany

A many-to-many relationship exists when each row in table A can relate to multiple rows in table B, and each row in B can also relate to multiple rows in A. A classic example is the relationship between Student and Course: one student enrolls in many courses, and one course has many students. In a relational database this requires a join table (sometimes called a bridge or association table) that holds pairs of foreign keys. Hibernate can manage that join table automatically — or you can take control of it yourself when the relationship has extra attributes.

The Basic Mapping

Annotate the collection field on one side with @ManyToMany and tell Hibernate about the join table with @JoinTable:

import jakarta.persistence.*; import java.util.HashSet; import java.util.Set; @Entity @Table(name = "students") public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany @JoinTable( name = "student_course", // join table name joinColumns = @JoinColumn(name = "student_id"), // FK back to Student inverseJoinColumns = @JoinColumn(name = "course_id") // FK to Course ) private Set<Course> courses = new HashSet<>(); // constructors, getters, setters … }
@Entity @Table(name = "courses") public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany(mappedBy = "courses") // inverse side — does NOT own the join table private Set<Course> students = new HashSet<>(); // constructors, getters, setters … }

The entity that declares @JoinTable is the owning side. The other entity uses mappedBy to point back to the field on the owner. Hibernate generates a join table in DDL that looks like this:

CREATE TABLE student_course ( student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(id), FOREIGN KEY (course_id) REFERENCES courses(id) );
Why use a Set, not a List? Hibernate maps @ManyToMany with a List using a bag strategy: when you remove a single element it issues a DELETE ALL for that owner, then re-inserts the remaining rows. A Set avoids this by generating a single targeted DELETE for only the removed element. Always prefer Set for many-to-many collections.

Managing Both Sides — Helper Methods

With bidirectional associations you are responsible for keeping both ends of the in-memory object graph consistent. A helper method on the owning side is the conventional pattern:

// inside Student public void enrollIn(Course course) { this.courses.add(course); course.getStudents().add(this); // keep the inverse side in sync } public void dropCourse(Course course) { this.courses.remove(course); course.getStudents().remove(this); }
Always maintain both sides of a bidirectional relationship. Failing to add the entity to the inverse collection does not cause a database bug — Hibernate writes via the owning side — but it leaves your object graph inconsistent within the same session, which leads to subtle bugs when you traverse the inverse collection before the session flushes.

Fetch Type and the Default

The default fetch type for @ManyToMany is FetchType.LAZY, which is almost always what you want. Switching to EAGER causes Hibernate to always load the entire join table for every parent you touch, even when you do not need the associated collection.

@ManyToMany(fetch = FetchType.LAZY) // explicit, but this is already the default @JoinTable(…) private Set<Course> courses = new HashSet<>();

When the Join Table Has Extra Columns

Suppose enrollment also stores a grade and an enrolled_at timestamp. A plain @ManyToMany cannot model this because it cannot map extra columns on the join table. The solution is to promote the join table to a full entity:

@Entity @Table(name = "enrollments") public class Enrollment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne @JoinColumn(name = "student_id") private Student student; @ManyToOne @JoinColumn(name = "course_id") private Course course; private String grade; @Column(name = "enrolled_at") private LocalDateTime enrolledAt; // constructors, getters, setters … }

Now Student has a @OneToMany to Enrollment, and Course has the same. This pattern is sometimes called a join entity or association entity and is almost always the better long-term choice — real-world join tables almost always grow extra attributes over time.

Composite PK vs surrogate key on the join entity. You might be tempted to use a @EmbeddedId composed of both foreign keys. This works but is significantly more verbose. Using a simple surrogate @GeneratedValue primary key (as shown above) is cleaner and performs identically. Choose the composite PK only when the natural key is meaningful outside Hibernate.

Deleting and Cascading

By default, persisting or removing a Student does not cascade to the join table rows or to the Course entities. You can enable cascade for specific operations:

@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(…) private Set<Course> courses = new HashSet<>();
Never use CascadeType.REMOVE on a many-to-many. Cascading remove through the owning side will delete the associated entities (the Course rows themselves), not just the join table rows, destroying data shared with other entities. To remove only the relationship, call the helper method that removes the element from both collections and let Hibernate delete the orphaned join-table row.

Querying

Joining across a many-to-many in JPQL is straightforward; Hibernate translates the collection traversal into a join through the join table automatically:

// Find all students enrolled in a specific course List<Student> students = em.createQuery( "SELECT s FROM Student s JOIN s.courses c WHERE c.title = :title", Student.class) .setParameter("title", "Algorithms") .getResultList();

You do not need to reference the join table by name in JPQL — Hibernate knows it from the mapping metadata. This is one of the productivity advantages of working at the object level rather than at the SQL level.

Summary

@ManyToMany maps a bidirectional many-to-many relationship through an automatically-managed join table. The owning side declares @JoinTable; the inverse side uses mappedBy. Use Set instead of List for collection type, keep both sides of the in-memory graph consistent through helper methods, and never cascade REMOVE through this association. When the join table needs extra attributes, replace the annotation with a dedicated join entity backed by two @ManyToOne relationships — this is almost always the right call for production systems.