Skip to content
Spring Boot sb data-jpa 4 min read

@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 Set rather than List for @ManyToMany. With a List, 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 + @JoinTableJoin entity (Enrollment)
Extra columns on the linkNot possibleYes (timestamp, grade, status…)
Primary keyComposite of both FKsOwn surrogate key (or composite)
Mapping shapeOne @ManyToMany each sideTwo @OneToMany + two @ManyToOne
Update behaviorCan delete-all-reinsert with ListPredictable per-row inserts/deletes
Query the link directlyHardEasy — it is a normal entity
When to usePure tag/role link, no metadataAnything with link attributes

Warning: Do not put cascade = REMOVE (or ALL) on a @ManyToMany. Deleting one Student would cascade to delete shared Course rows that other students still reference.

Common pitfalls

  • Using List instead of Set triggers 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 LAZY and use pagination or explicit joins.
Last updated June 13, 2026
Was this helpful?