@ManyToMany
A many-to-many relationship links each row on one side to many rows on the other and vice versa — a Student enrolls in many Course rows, and each Course has many students. JPA models this with @ManyToMany and a join table that stores pairs of foreign keys. This page shows the plain @ManyToMany mapping, then explains why a dedicated join entity is usually the better long-term choice. See Entity Mapping for the fundamentals.
Plain @ManyToMany with @JoinTable
One side is the owning side and declares @JoinTable, naming the link table and both FK columns. The other side uses mappedBy to point back. Only the owning side’s collection is read when persisting.
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
@JoinTable(
name = "student_course", // join table
joinColumns = @JoinColumn(name = "student_id"), // this side's FK
inverseJoinColumns = @JoinColumn(name = "course_id") // other side's FK
)
private Set<Course> courses = new HashSet<>();
// helper methods keep both collections in sync
public void enroll(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void drop(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
protected Student() { }
public Student(String name) { this.name = name; }
public Set<Course> getCourses() { return courses; }
// other getters and setters
}
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses") // inverse side
private Set<Student> students = new HashSet<>();
protected Course() { }
public Course(String title) { this.title = title; }
public Set<Student> getStudents() { return students; }
// other getters and setters
}
Tip: Use
Setrather thanListfor@ManyToMany. With aList, removing one link can make Hibernate delete all rows for that owner and re-insert the survivors.
Generated join-table SQL
Hibernate creates a third table holding only the two foreign keys, with a composite primary key.
create table student (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
create table course (
id bigint generated by default as identity,
title varchar(255),
primary key (id)
);
create table student_course (
student_id bigint not null,
course_id bigint not null,
primary key (student_id, course_id),
constraint fk_sc_student foreign key (student_id) references student (id),
constraint fk_sc_course foreign key (course_id) references course (id)
);
Enrolling a student inserts a row into the join table only:
Student alice = new Student("Alice");
Course math = courseRepository.save(new Course("Calculus"));
alice.enroll(math);
studentRepository.save(alice);
insert into student (name) values ('Alice');
insert into student_course (student_id, course_id) values (1, 1);
Why a join entity is usually better
Plain @ManyToMany works until you need to store data about the link itself — when the student enrolled, their grade, whether they completed it. A raw join table has no place for those columns. The moment you need them, you must promote the join table to a real entity.
Modeling the link explicitly also gives more stable behavior: you control its primary key, you avoid the surprising delete-all-and-reinsert behavior, and the relationship becomes two ordinary one-to-many associations that Hibernate handles predictably.
import jakarta.persistence.*;
import java.time.Instant;
@Entity
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;
private Instant enrolledAt = Instant.now(); // extra column
private String grade; // extra column
protected Enrollment() { }
public Enrollment(Student student, Course course) {
this.student = student;
this.course = course;
}
// getters and setters
}
Student and Course now each hold a @OneToMany of Enrollment instead of referencing each other directly:
@Entity
public class Student {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Enrollment> enrollments = new HashSet<>();
// ...
}
The join entity gives you a normal table with its own columns:
create table enrollment (
id bigint generated by default as identity,
enrolled_at timestamp(6),
grade varchar(255),
student_id bigint,
course_id bigint,
primary key (id),
constraint fk_en_student foreign key (student_id) references student (id),
constraint fk_en_course foreign key (course_id) references course (id)
);
@ManyToMany vs join entity
| Aspect | @ManyToMany + @JoinTable | Join entity (Enrollment) |
|---|---|---|
| Extra columns on the link | Not possible | Yes (timestamp, grade, status…) |
| Primary key | Composite of both FKs | Own surrogate key (or composite) |
| Mapping shape | One @ManyToMany each side | Two @OneToMany + two @ManyToOne |
| Update behavior | Can delete-all-reinsert with List | Predictable per-row inserts/deletes |
| Query the link directly | Hard | Easy — it is a normal entity |
| When to use | Pure tag/role link, no metadata | Anything with link attributes |
Warning: Do not put
cascade = REMOVE(orALL) on a@ManyToMany. Deleting oneStudentwould cascade to delete sharedCourserows that other students still reference.
Common pitfalls
- Using
Listinstead ofSettriggers inefficient delete-and-reinsert on updates. - Forgetting helper methods leaves the in-memory collections inconsistent even though the join table is correct.
- Reaching for extra link columns later forces a painful migration — start with a join entity if you suspect you will need them.
- Eager many-to-many loads large graphs; keep them
LAZYand use pagination or explicit joins.