Spring Boot
Spring Data JPA @ManyToMany: Join Tables, Extra Columns, Fetching and Delete Pitfalls
How to map many-to-many relationships with Spring Data JPA and Hibernate 6: the plain @ManyToMany with a join table, the explicit join entity for tables that carry extra columns, the matching database schema, fetching without N+1 or in-memory pagination, service-layer code and a test. It also explains why Set beats List, why entities in a Set need equals and hashCode, and the delete and cascade mistakes that corrupt data.
Which Mapping Should You Use?
There are two ways to model a many-to-many relationship in JPA, and the choice depends on one question: does the link between the two rows need data of its own?
Use a plain @ManyToMany when the link carries nothing but the two ids. Examples are a user and their tags, or a post and its categories. The join table has just two foreign key columns, and JPA manages it for you.
Use an explicit join entity when the link has its own data, such as the date a student enrolled, a status, a grade, a quantity, or a role. @ManyToMany has nowhere to store those columns, so you model the join table as a real entity with a @ManyToOne to each side. In practice this is also the safer default for most real applications, because requirements tend to grow: a relationship that starts as a bare link usually gains a timestamp or status later, and moving from @ManyToMany to an entity afterward means a schema and code migration.
The sections below show both patterns using students and courses, then cover fetching, service code, testing, and the delete mistakes that cause data loss. All examples target Hibernate 6 with Spring Boot 3.x.
Plain @ManyToMany with @JoinTable
One side owns the relationship and declares the @JoinTable. The other side is the inverse and points at the owner with mappedBy. Only changes made to the owning side are written to the join table, so adding a student to course.getStudents() alone persists nothing. That is why the helper methods below update both sides.
Four details decide whether this mapping works well.
First, use Set, not List. With a List (Hibernate calls it a bag), removing one element makes Hibernate delete all join rows for that owner and re-insert the remaining ones. With a Set it deletes just the one row.
Second, entities stored in a Set need equals and hashCode. Without them, remove and contains compare object identity and silently fail on instances loaded in another session. The id-based version below works for lazy proxies because it uses instanceof and the getter, and the constant hashCode stays the same before and after the id is assigned. The trade-off is that all elements land in the same hash bucket, so lookups in very large sets are linear. If a collection can hold thousands of elements, use a stable natural key (such as a unique business code) in equals and hashCode instead.
Third, never put CascadeType.REMOVE or CascadeType.ALL on a @ManyToMany. Deleting one student would delete courses that other students still use.
Fourth, keep the collections private and change them through helper methods that keep both sides in sync. The helper methods on the inverse side are package-private so outside code cannot bypass them. Note that calling add or remove on a lazy collection may load it first, which is fine for small collections but costly for very large ones.
Java
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
// Owning side: defines the join table. Lazy by default for collections.
@ManyToMany
@JoinTable(
name = "student_courses",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private Set<Course> courses = new HashSet<>();
protected Student() { }
public Student(String name) {
this.name = name;
}
public void enrollIn(Course course) {
courses.add(course);
course.addStudent(this);
}
public void withdrawFrom(Course course) {
courses.remove(course);
course.removeStudent(this);
}
public Long getId() { return id; }
public String getName() { return name; }
public Set<Course> getCourses() { return Collections.unmodifiableSet(courses); }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Student.class.hashCode(); // constant: stable before and after the id is assigned
}
}
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
// Inverse side: no join table here. Changes to this set are NOT persisted on their own.
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
protected Course() { }
public Course(String title) {
this.title = title;
}
void addStudent(Student student) { students.add(student); } // package-private on purpose
void removeStudent(Student student) { students.remove(student); }
public Long getId() { return id; }
public String getTitle() { return title; }
public Set<Student> getStudents() { return Collections.unmodifiableSet(students); }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Course.class.hashCode();
}
}The Database Schema
Let Flyway or Liquibase create the tables and let Hibernate only validate them (the configuration is in the next section). For a plain many-to-many, the composite primary key on both join columns is what stops the same pair being stored twice. Without it, the database accepts duplicates that the Set in memory hides.
The primary key index serves lookups by student. Add a second index for lookups by course, as shown below. The explicit-entity pattern in the later section uses a table with its own id and a unique constraint on the pair, which gives the same duplicate protection.
The syntax is PostgreSQL; adjust the identity column for other databases. GenerationType.IDENTITY, used here for simplicity, disables JDBC insert batching. On PostgreSQL, if you insert many rows at once, prefer a sequence with an allocationSize.
sql
CREATE TABLE students (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE courses (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL
);
-- Pattern 1: plain @ManyToMany join table
CREATE TABLE student_courses (
student_id BIGINT NOT NULL REFERENCES students (id),
course_id BIGINT NOT NULL REFERENCES courses (id),
PRIMARY KEY (student_id, course_id) -- no duplicate pairs
);
CREATE INDEX idx_student_courses_course ON student_courses (course_id);
-- Pattern 2: explicit join entity with extra columns
CREATE TABLE enrollments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
student_id BIGINT NOT NULL REFERENCES students (id),
course_id BIGINT NOT NULL REFERENCES courses (id),
enrolled_at DATE NOT NULL,
status VARCHAR(20) NOT NULL,
grade NUMERIC(4,2),
CONSTRAINT uq_enrollment UNIQUE (student_id, course_id)
);
CREATE INDEX idx_enrollments_course ON enrollments (course_id);Schema Validation and Batch Fetch Configuration
Set ddl-auto to validate so Hibernate checks your entities against the real schema at startup and fails fast on a mismatch, instead of silently creating or altering tables. The default_batch_fetch_size setting is a broad safety net against N+1 queries: when a lazy collection is first touched, Hibernate loads the collections of up to that many owners in a single query instead of one query per owner. Values between 16 and 100 are common.
yaml
# application.yml
spring:
jpa:
open-in-view: false # avoid lazy loading in the web layer by accident
hibernate:
ddl-auto: validate # schema comes from Flyway/Liquibase
properties:
hibernate:
default_batch_fetch_size: 25
flyway:
enabled: trueJoin Table with Extra Columns: the Explicit Entity
When the relationship needs its own data (when the student enrolled, a status, a grade), replace @ManyToMany with a real entity, Enrollment. It has a @ManyToOne to each side, and the old collections on Student and Course become @OneToMany(mappedBy = ...). You can then query enrollments directly, add columns later without changing the model, and control the lifecycle of each link.
The code below replaces the Student and Course classes from the first section rather than adding to them. Enrollment has its own generated id plus a unique constraint on the pair. An alternative is a composite key with @EmbeddedId and @MapsId, which is correct but noticeably more code.
Two design choices are deliberate. Only Student cascades to Enrollment (with orphanRemoval), so deleting a student removes that student's enrollments, while Course has no cascade, so deleting a course can never remove enrollments by accident. And the Enrollment constructor is public so the service layer can create links using lightweight references, without loading both collections. The Student.enroll helper is convenient for small collections, but it touches the course's lazy enrollments set, which loads it and can be expensive for a popular course. For large data, create the Enrollment in a service as shown later.
Java
@Entity
@Table(name = "enrollments",
uniqueConstraints = @UniqueConstraint(name = "uq_enrollment",
columnNames = {"student_id", "course_id"}))
public class Enrollment {
public enum EnrollmentStatus { ACTIVE, COMPLETED, WITHDRAWN }
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id")
private Course course;
@Column(name = "enrolled_at", nullable = false, updatable = false)
private LocalDate enrolledAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EnrollmentStatus status = EnrollmentStatus.ACTIVE;
@Column(precision = 4, scale = 2)
private BigDecimal grade;
protected Enrollment() { }
public Enrollment(Student student, Course course) { // public: used by the service layer
this.student = student;
this.course = course;
this.enrolledAt = LocalDate.now();
}
public Long getId() { return id; }
public Student getStudent() { return student; }
public Course getCourse() { return course; }
public LocalDate getEnrolledAt() { return enrolledAt; }
public EnrollmentStatus getStatus() { return status; }
public void setStatus(EnrollmentStatus status) { this.status = status; }
public BigDecimal getGrade() { return grade; }
public void setGrade(BigDecimal grade) { this.grade = grade; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Enrollment other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Enrollment.class.hashCode();
}
}
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Enrollment> enrollments = new HashSet<>();
protected Student() { }
public Student(String name) {
this.name = name;
}
// Convenient for small collections. Loads both lazy sets, so avoid it for large data.
public Enrollment enroll(Course course) {
Enrollment enrollment = new Enrollment(this, course);
enrollments.add(enrollment);
course.addEnrollment(enrollment);
return enrollment;
}
public Long getId() { return id; }
public String getName() { return name; }
public Set<Enrollment> getEnrollments() { return Collections.unmodifiableSet(enrollments); }
// equals and hashCode as in the first section
}
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@OneToMany(mappedBy = "course") // no cascade: a course must not delete enrollments by accident
private Set<Enrollment> enrollments = new HashSet<>();
protected Course() { }
public Course(String title) {
this.title = title;
}
void addEnrollment(Enrollment enrollment) { enrollments.add(enrollment); }
public Long getId() { return id; }
public String getTitle() { return title; }
public Set<Enrollment> getEnrollments() { return Collections.unmodifiableSet(enrollments); }
// equals and hashCode as in the first section
}
@Repository
public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
Optional<Enrollment> findByStudentIdAndCourseId(Long studentId, Long courseId);
@Query("""
SELECT e FROM Enrollment e
JOIN FETCH e.course
WHERE e.student.id = :studentId AND e.status = :status
""")
List<Enrollment> findByStudentAndStatus(@Param("studentId") Long studentId,
@Param("status") Enrollment.EnrollmentStatus status);
}Fetching Without N+1 or In-Memory Pagination
Collections are lazy, so reading student.getCourses() for each of 100 students runs 100 extra queries. This is the N+1 problem. Fix it where you know you need the data, with JOIN FETCH or an entity graph, which loads the owner and its collection in one query. In Hibernate 6, the DISTINCT keyword is no longer needed to remove duplicate parent rows.
Two limits matter.
First, you cannot JOIN FETCH two List collections in one query: Hibernate throws MultipleBagFetchException. Even with Sets, joining two collections produces a Cartesian product of their rows. The reliable approach is one collection per query inside the same transaction. The second query fills the collection on the entity that is already loaded.
Second, pagination. Combining JOIN FETCH on a collection with a Pageable makes Hibernate log HHH90003004 and apply the page in memory after loading everything, which can exhaust memory on large tables. The fix is to page over ids first and then fetch the entities for just those ids, as the service below does. It also returns a proper Page so callers keep the total count.
As a broader safety net, set default_batch_fetch_size (see the configuration section above).
Java
public interface StudentRepository extends JpaRepository<Student, Long> {
@Query("SELECT s FROM Student s LEFT JOIN FETCH s.courses WHERE s.id = :id")
Optional<Student> findByIdWithCourses(@Param("id") Long id);
@EntityGraph(attributePaths = "courses")
Optional<Student> findWithCoursesById(Long id);
// Safe pagination: step 1 pages the ids, step 2 fetches those students with their courses
@Query("SELECT s.id FROM Student s ORDER BY s.name, s.id")
Page<Long> findStudentIds(Pageable pageable);
@Query("SELECT s FROM Student s LEFT JOIN FETCH s.courses WHERE s.id IN :ids")
List<Student> findAllWithCoursesByIdIn(@Param("ids") List<Long> ids);
// Second collection, assuming Student also has Set<Badge> badges (illustrative):
@Query("SELECT s FROM Student s LEFT JOIN FETCH s.badges WHERE s.id = :id")
Optional<Student> findByIdWithBadges(@Param("id") Long id);
}
@Service
public class StudentQueryService {
private final StudentRepository studentRepository;
public StudentQueryService(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
@Transactional(readOnly = true)
public Page<Student> page(Pageable pageable) {
Page<Long> ids = studentRepository.findStudentIds(pageable);
Map<Long, Student> byId = studentRepository.findAllWithCoursesByIdIn(ids.getContent())
.stream().collect(Collectors.toMap(Student::getId, Function.identity()));
List<Student> ordered = ids.getContent().stream().map(byId::get).toList(); // restore page order
return new PageImpl<>(ordered, pageable, ids.getTotalElements());
}
// Two collections: load each in its own query, in one transaction.
@Transactional(readOnly = true)
public Student loadWithCoursesAndBadges(Long id) {
Student student = studentRepository.findByIdWithCourses(id).orElseThrow();
studentRepository.findByIdWithBadges(id); // fills student.badges on the same instance
return student; // no Cartesian product between the two joins
}
}Managing the Relationship in the Service Layer
Put the association logic in a transactional service. For the explicit-entity pattern, use getReferenceById to obtain lightweight references without loading the full student and course rows. Hibernate only needs their ids to insert the enrollment. The service does not touch the lazy enrollments collections at all, which keeps the operation to a few cheap queries even for popular courses.
getReferenceById does not check that the row exists. With an unknown id, the failure only appears at insert time as a foreign key violation, which would surface as a confusing 500. The example checks existsById first so a missing student or course produces a clean not-found error.
Check for an existing enrollment first so that a student who withdrew can re-enroll instead of hitting the unique constraint, but still rely on that constraint as the real guard. Two simultaneous requests can both pass the check. Translate the resulting DataIntegrityViolationException into a 409 response in your exception handler.
Changes to a loaded entity, such as setting a grade, are saved automatically at commit by dirty checking, so no save call is needed. Return DTOs from controllers rather than entities: serializing bidirectional entities to JSON causes infinite recursion or triggers lazy loading outside a transaction.
Java
public record EnrollmentResponse(Long id, Long studentId, Long courseId,
LocalDate enrolledAt, Enrollment.EnrollmentStatus status) {
static EnrollmentResponse from(Enrollment e) {
return new EnrollmentResponse(e.getId(), e.getStudent().getId(), e.getCourse().getId(),
e.getEnrolledAt(), e.getStatus());
}
}
@Service
@Transactional
public class EnrollmentService {
private final StudentRepository studentRepository;
private final CourseRepository courseRepository;
private final EnrollmentRepository enrollmentRepository;
public EnrollmentService(StudentRepository studentRepository,
CourseRepository courseRepository,
EnrollmentRepository enrollmentRepository) {
this.studentRepository = studentRepository;
this.courseRepository = courseRepository;
this.enrollmentRepository = enrollmentRepository;
}
public EnrollmentResponse enrol(Long studentId, Long courseId) {
Optional<Enrollment> existing =
enrollmentRepository.findByStudentIdAndCourseId(studentId, courseId);
if (existing.isPresent()) {
Enrollment enrollment = existing.get();
if (enrollment.getStatus() != Enrollment.EnrollmentStatus.WITHDRAWN) {
throw new AlreadyEnrolledException(studentId, courseId);
}
enrollment.setStatus(Enrollment.EnrollmentStatus.ACTIVE); // re-enrol after withdrawal
return EnrollmentResponse.from(enrollment);
}
if (!studentRepository.existsById(studentId)) throw new StudentNotFoundException(studentId);
if (!courseRepository.existsById(courseId)) throw new CourseNotFoundException(courseId);
Student student = studentRepository.getReferenceById(studentId); // reference only, no full SELECT
Course course = courseRepository.getReferenceById(courseId);
return EnrollmentResponse.from(enrollmentRepository.save(new Enrollment(student, course)));
}
public void withdraw(Long studentId, Long courseId) {
Enrollment enrollment = enrollmentRepository.findByStudentIdAndCourseId(studentId, courseId)
.orElseThrow(() -> new EnrollmentNotFoundException(studentId, courseId));
enrollment.setStatus(Enrollment.EnrollmentStatus.WITHDRAWN); // soft delete keeps the history
}
public void recordGrade(Long studentId, Long courseId, BigDecimal grade) {
Enrollment enrollment = enrollmentRepository.findByStudentIdAndCourseId(studentId, courseId)
.orElseThrow(() -> new EnrollmentNotFoundException(studentId, courseId));
enrollment.setGrade(grade);
enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED); // dirty checking issues the UPDATE
}
}Testing the Mapping
A mapping test catches mistakes that only show up in the generated SQL. The test below uses @DataJpaTest with the plain @ManyToMany from the first section. It enrolls a student in two courses, clears the persistence context so everything is re-read from the database, withdraws one course and checks that exactly the other one remains. If you used a List instead of a Set, or forgot to keep both sides in sync, this is where it would surface.
Turn on spring.jpa.show-sql while writing it and confirm that withdrawing issues a single delete on the join table. A real database through Testcontainers gives the most faithful result (use @AutoConfigureTestDatabase(replace = NONE) so the test uses it), but the embedded database is enough to verify the mapping itself.
Java
@DataJpaTest
class StudentCourseMappingTest {
@Autowired
private TestEntityManager em;
@Autowired
private StudentRepository students;
@Test
void withdrawingRemovesOnlyThatCourse() {
Student alice = em.persist(new Student("Alice"));
Course java = em.persist(new Course("Java"));
Course sql = em.persist(new Course("SQL"));
alice.enrollIn(java);
alice.enrollIn(sql);
em.flush();
em.clear(); // force re-reading from the database
Student loaded = students.findByIdWithCourses(alice.getId()).orElseThrow();
Course javaCourse = loaded.getCourses().stream()
.filter(c -> c.getTitle().equals("Java")).findFirst().orElseThrow();
loaded.withdrawFrom(javaCourse);
em.flush();
em.clear();
Student reloaded = students.findByIdWithCourses(alice.getId()).orElseThrow();
assertThat(reloaded.getCourses())
.extracting(Course::getTitle)
.containsExactly("SQL");
}
}Deleting Safely
The most damaging mistake is cascading deletes. With CascadeType.ALL on a @ManyToMany, removing one student removes the shared courses too.
The second is deleting the inverse side directly. Deleting a Course that students still reference fails with a foreign key violation, because the join rows belong to the owning side. Detach the relationships first, as shown below. With the explicit-entity pattern, delete or archive the enrollments first.
The third is using List: it works in small tests and then deletes and re-inserts the whole join table for an owner on every removal. Finally, remember that only the owning side writes to the join table, which is why the helper methods update both sides. Hibernate runs collection removals before entity deletions at flush time, so detaching and then deleting in one transaction works.
Java
// Deleting the inverse side of a plain @ManyToMany: detach first
@Transactional
public void deleteCourse(Long courseId) {
Course course = courseRepository.findById(courseId).orElseThrow();
for (Student student : new ArrayList<>(course.getStudents())) { // copy: the view is unmodifiable
student.withdrawFrom(course);
}
courseRepository.delete(course);
}Symptoms and Their Causes
Use this list when something behaves strangely. Most many-to-many problems come from a small set of causes.
Shared rows vanish after a delete: CascadeType.REMOVE or CascadeType.ALL is set on a @ManyToMany. Remove the cascade.
Foreign key error when deleting a Course: join rows still reference it because you deleted the inverse side. Detach the students first, or delete the enrollments first.
Every removal rewrites the whole join table: a List (bag) is used instead of a Set. Switch to Set.
UnsupportedOperationException inside a helper method: the helper modified the unmodifiable view returned by the getter. Modify the private field instead.
Adding to the inverse side saves nothing: only the owning side writes the join table. Add through the owning side or use the helper methods.
MultipleBagFetchException: two List collections were fetched in one query. Use Sets and fetch one collection per query.
HHH90003004 warning and high memory use: JOIN FETCH on a collection was combined with a Pageable. Page over ids first, then fetch.
Infinite recursion when returning JSON: bidirectional entities were serialized directly. Return DTOs.
Set contains or remove does not find an element that is there: equals and hashCode are missing or use identity. Use the id-based versions shown above.
Frequently Asked Questions
Should I use List or Set for @ManyToMany?
Use Set. A List is treated as a bag, and removing one element makes Hibernate delete every join row for the owner and re-insert the remaining ones. A Set deletes only the removed row.
How do I add extra columns to a many-to-many join table?
You cannot with @ManyToMany. Create an entity for the join table, with a @ManyToOne to each side and the extra columns as fields, and turn the original collections into @OneToMany(mappedBy = ...).
Which side should own the relationship?
Choose the side that is changed more often or that conceptually controls the link (for example, the student enrolling in courses). The owner declares @JoinTable, the other side uses mappedBy. Only the owner's changes are written to the join table.
Why does my many-to-many query run so many SQL statements?
Collections are lazy, so each owner triggers its own query when you read the collection (N+1). Use JOIN FETCH or an entity graph for the queries that need the data, and set default_batch_fetch_size as a safety net.
Can I use CascadeType.ALL on a @ManyToMany?
Avoid it. REMOVE cascades to entities that other owners still use and deletes shared data. If you need cascading persist, use CascadeType.PERSIST and MERGE only, and only when the children are created together with the parent.
How do I paginate a query that fetches a collection?
Do not combine JOIN FETCH on a collection with a Pageable. Hibernate paginates in memory. Page over the owner ids first, then load those entities with their collections in a second query.
Do I need equals and hashCode on entities in a Set?
Yes. Without them, a Set uses object identity, so remove and contains fail for instances loaded in a different session. Use an id-based or natural-key implementation that works with Hibernate proxies.
How do I stop duplicate pairs in the join table?
Put a composite primary key (plain join table) or a unique constraint on the two foreign keys (explicit entity) in the database. Application checks alone are not safe because concurrent requests can both pass them.