In relational databases, there are cases where entities have many-to-many relationships. For example:
- A Student can enroll in multiple Courses.
- A Course can have multiple Students.
In JPA, this association is represented with the @ManyToMany
annotation. This mapping allows developers to avoid writing complex join tables manually, letting JPA handle the underlying join management.
In this tutorial, we’ll explore Many-to-Many mapping in JPA, including setup, CRUD operations, queries, pitfalls, and best practices.
Core Definition of Many-to-Many Mapping
- A Many-to-Many relationship exists when multiple entities of one type relate to multiple entities of another type.
- Implemented in DB using a join table.
- Can be unidirectional or bidirectional.
Setup and Configuration
persistence.xml
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0">
<persistence-unit name="my-pu" transaction-type="RESOURCE_LOCAL">
<class>com.example.Student</class>
<class>com.example.Course</class>
<properties>
<property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="jakarta.persistence.jdbc.url" value="jdbc:h2:mem:testdb"/>
<property name="jakarta.persistence.jdbc.user" value="sa"/>
<property name="jakarta.persistence.jdbc.password" value=""/>
<property name="hibernate.hbm2ddl.auto" value="update"/>
<property name="hibernate.show_sql" value="true"/>
</properties>
</persistence-unit>
</persistence>
Entity Mapping with @ManyToMany
Example: Student ↔ Course
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(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
@JoinTable(
name = "student_courses",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
// Getters and setters
}
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
// Getters and setters
}
Key Points
@ManyToMany
defines the relationship.@JoinTable
specifies the join table and its columns.mappedBy
makes the relationship bidirectional.- Cascade ensures persistence propagates properly.
CRUD Operations with EntityManager
Create (Insert)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-pu");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
Course java = new Course();
java.setTitle("Java");
Course sql = new Course();
sql.setTitle("SQL");
Student s1 = new Student();
s1.setName("Alice");
s1.getCourses().add(java);
s1.getCourses().add(sql);
Student s2 = new Student();
s2.setName("Bob");
s2.getCourses().add(java);
java.getStudents().add(s1);
sql.getStudents().add(s1);
java.getStudents().add(s2);
em.persist(s1);
em.persist(s2);
tx.commit();
SQL Generated:
insert into courses (title) values ('Java');
insert into courses (title) values ('SQL');
insert into students (name) values ('Alice');
insert into students (name) values ('Bob');
insert into student_courses (student_id, course_id) values (1, 1);
insert into student_courses (student_id, course_id) values (1, 2);
insert into student_courses (student_id, course_id) values (2, 1);
Read
Student found = em.find(Student.class, 1L);
System.out.println(found.getName() + " enrolled in " + found.getCourses().size() + " courses");
Update
tx.begin();
Course python = new Course();
python.setTitle("Python");
found.getCourses().add(python);
python.getStudents().add(found);
em.merge(found);
tx.commit();
SQL:
insert into courses (title) values ('Python');
insert into student_courses (student_id, course_id) values (1, 3);
Delete
tx.begin();
em.remove(found);
tx.commit();
SQL:
delete from student_courses where student_id=?;
delete from students where id=?;
Querying with JPA
JPQL
List<Student> students = em.createQuery("SELECT s FROM Student s JOIN FETCH s.courses", Student.class).getResultList();
Criteria API
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Student> cq = cb.createQuery(Student.class);
Root<Student> root = cq.from(Student.class);
root.fetch("courses", JoinType.INNER);
List<Student> results = em.createQuery(cq).getResultList();
Native SQL
List<Object[]> results = em.createNativeQuery("SELECT s.name, c.title FROM students s JOIN student_courses sc ON s.id = sc.student_id JOIN courses c ON c.id = sc.course_id").getResultList();
Persistence Context and Fetching
- Many-to-Many defaults to
LAZY
fetching. - Use
EAGER
only when always needed. - Think of
LAZY
as ordering food when hungry vsEAGER
as ordering everything upfront.
@ManyToMany(fetch = FetchType.LAZY)
private Set<Course> courses;
Real-World Use Cases
- Students ↔ Courses in academic systems.
- Authors ↔ Books in publishing.
- Users ↔ Roles in authentication/authorization.
Anti-Patterns and Pitfalls
- Avoid unidirectional Many-to-Many → creates poor readability.
- Watch out for N+1 problem when fetching collections.
- Don’t abuse CascadeType.REMOVE → may delete unrelated entities.
Best Practices
- Use bidirectional mapping for clarity.
- Default to
LAZY
fetching. - Always manage both sides of the relationship (add/remove).
- Prefer intermediate entity (e.g., Enrollment) if extra attributes are needed.
📌 JPA Version Notes
- JPA 2.0: Introduced Criteria API, Metamodel.
- JPA 2.1: Added entity graphs, stored procedures.
- Jakarta Persistence: Package moved from
javax.persistence
→jakarta.persistence
.
Conclusion and Key Takeaways
- Many-to-Many models complex associations naturally.
- Use
@JoinTable
to map join tables. - Keep collections LAZY to improve performance.
- Use an intermediate entity if attributes exist in join tables.
By mastering Many-to-Many mapping, you’ll design scalable, maintainable, and production-ready JPA applications.
FAQ
1. What’s the difference between JPA and Hibernate?
JPA is a specification, Hibernate is one implementation.
2. How does JPA handle the persistence context?
It manages entity state across transactions.
3. What are the drawbacks of eager fetching in JPA?
Loads unnecessary data and impacts performance.
4. How can I solve the N+1 select problem with JPA?
Use JOIN FETCH
or batch fetching.
5. Can I use JPA without Hibernate?
Yes, with EclipseLink or OpenJPA.
6. What’s the best strategy for inheritance mapping in JPA?JOINED
for normalization, SINGLE_TABLE
for performance.
7. How does JPA handle composite keys?
With @IdClass
or @EmbeddedId
.
8. What changes with Jakarta Persistence?
The package namespace moved to jakarta.persistence
.
9. Is JPA suitable for microservices?
Yes, but DTOs are recommended for communication.
10. When should I avoid using JPA?
In heavy batch jobs or NoSQL-dominant architectures.