Skip to content
CampusEduX

Database · Lesson 61 of 95

Many to Many Mapping

Learn many-to-many mapping in Spring Boot with JPA: join tables, owning and inverse sides, Set vs List and a runnable book and author example.

9 min read

A library has a shelf of books and a list of writers. Look closely and you will see a puzzle. One writer can write many books. One book can have many writers. Neither side "owns" the other. If you tried to store this in one table, you would end up copying names again and again, or squeezing lists into a single cell. There is a cleaner way, and JPA gives it to you with one annotation.

Let's learn how many-to-many mapping works, why it needs a third table behind the scenes, and how to write it without falling into the usual traps.

What is many-to-many mapping?

Think about a wedding invitation list. Every guest can be invited to many events, and every event has many guests. The caterer does not write guests inside the event, and does not write events inside the guest. They keep a separate sheet with two columns: guest and event. Each row says, "this guest comes to that event."

In JPA you write it with @ManyToMany. The join table is not a Java class you write. Hibernate creates and fills it for you.

Why is it used?

Real data is full of these links:

  • Books and authors in a library.
  • Movies and actors in a cinema database.
  • Doctors and patients in a hospital, when a patient sees several doctors.
  • Recipes and tags in a cooking app.

You use @ManyToMany so that you can work with plain Java collections. You call book.getAuthors() and get a set of Author objects. You call book.addAuthor(author) and Hibernate inserts the right row in the join table. You never write the join SQL by hand.

How it works

Every many-to-many link is really two one-to-many links glued to a small table. Here is the picture for one book with two authors.

text
Book "Monsoon Diaries" | v book_author (join table) book_id=1 -> author_id=1 book_id=1 -> author_id=2 | v Author "Meera" Author "Arjun"

The join table holds only two foreign keys. Each row is one link. To find the authors of book 1, the database reads the rows with book_id=1 and follows the author_id values.

Here is the same design as an ER diagram, the kind you draw before writing any code.

text
+-----------+ +-----------+ | BOOK | | AUTHOR | | PK id | | PK id | | title | | name | +-----------+ +-----------+ 1 1 | | * * +-------------------------------+ | BOOK_AUTHOR (join table) | | FK book_id FK author_id | +-------------------------------+

Each book and each author sit in their own table. The join table in the middle points to both. One book row can appear in many join rows, and so can one author row. That is what "many to many" means in practice.

EntityTableKey columns
Bookbookid (primary key), title
Authorauthorid (primary key), name
Join tablebook_authorbook_id and author_id, both foreign keys

Owning side and inverse side

A many-to-many link has two Java sides, but only one may control the join table. This is the owning side. It carries @JoinTable. The other side is the inverse side. It uses mappedBy to say, "the field on the other class already describes this link." Hibernate writes join rows only when the owning side changes. If you add a book to author.getBooks() but forget the owning side, nothing is saved.

Real-Life Example

Picture a school canteen coupon system. Each family gets a coupon card, and each stall accepts many cards. Nobody prints stall names on the card, and nobody writes card numbers on the stall board. The canteen manager keeps one register with two columns, card number and stall. When a stall stops accepting a card, the manager strikes out one line. The cards and stalls are untouched. The join table works exactly like that register.

Code Example

Let's build a small app for Riverside Library. It has books, authors and a join table between them. We use the H2 in-memory database, so nothing needs to be installed.

text
riverside-library/ ├─ pom.xml └─ src/main/ ├─ java/com/riverside/library/ │ ├─ LibraryApplication.java │ ├─ Book.java │ ├─ Author.java │ ├─ BookRepository.java │ ├─ AuthorRepository.java │ └─ LibraryService.java └─ resources/ └─ application.properties

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.riverside</groupId> <artifactId>library</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.application.name=riverside-library spring.main.banner-mode=off logging.level.root=warn

File: Book.java in package com.riverside.library

java
package com.riverside.library; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany @JoinTable(name = "book_author", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "author_id")) private Set<Author> authors = new HashSet<>(); protected Book() { } public Book(String title) { this.title = title; } public void addAuthor(Author author) { authors.add(author); author.getBooks().add(this); } public String getTitle() { return title; } public Set<Author> getAuthors() { return authors; } }

File: Author.java in package com.riverside.library

java
package com.riverside.library; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; @Entity public class Author { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany(mappedBy = "authors") private Set<Book> books = new HashSet<>(); protected Author() { } public Author(String name) { this.name = name; } public String getName() { return name; } public Set<Book> getBooks() { return books; } }

File: BookRepository.java in package com.riverside.library

java
package com.riverside.library; import org.springframework.data.jpa.repository.JpaRepository; public interface BookRepository extends JpaRepository<Book, Long> { }

File: AuthorRepository.java in package com.riverside.library

java
package com.riverside.library; import org.springframework.data.jpa.repository.JpaRepository; public interface AuthorRepository extends JpaRepository<Author, Long> { }

File: LibraryService.java in package com.riverside.library

java
package com.riverside.library; import java.util.List; import java.util.Map; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class LibraryService { private final BookRepository books; private final AuthorRepository authors; private final JdbcTemplate jdbc; public LibraryService(BookRepository books, AuthorRepository authors, JdbcTemplate jdbc) { this.books = books; this.authors = authors; this.jdbc = jdbc; } @Transactional public void loadSampleData() { Author meera = authors.save(new Author("Meera Nair")); Author arjun = authors.save(new Author("Arjun Rao")); Author kabir = authors.save(new Author("Kabir Shah")); Book diaries = new Book("Monsoon Diaries"); diaries.addAuthor(meera); diaries.addAuthor(arjun); Book tea = new Book("Tea Garden Tales"); tea.addAuthor(meera); Book train = new Book("Night Train to Pune"); train.addAuthor(arjun); train.addAuthor(kabir); books.saveAll(List.of(diaries, tea, train)); } @Transactional(readOnly = true) public void printCatalogue() { for (Book book : books.findAll()) { System.out.println(book.getTitle() + " -> " + book.getAuthors().size() + " author(s)"); } Author meera = authors.findAll().stream() .filter(a -> a.getName().startsWith("Meera")).findFirst().orElseThrow(); System.out.println(meera.getName() + " wrote " + meera.getBooks().size() + " book(s)"); List<Map<String, Object>> rows = jdbc.queryForList( "select book_id, author_id from book_author order by book_id, author_id"); System.out.println("Join table rows: " + rows.size()); rows.forEach(System.out::println); } }

File: LibraryApplication.java in package com.riverside.library

java
package com.riverside.library; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } @Bean CommandLineRunner demo(LibraryService service) { return args -> { service.loadSampleData(); service.printCatalogue(); }; } }

Run it with Maven:

bash
mvn spring-boot:run

Output:

text
Monsoon Diaries -> 2 author(s) Tea Garden Tales -> 1 author(s) Night Train to Pune -> 2 author(s) Meera Nair wrote 2 book(s) Join table rows: 5 {BOOK_ID=1, AUTHOR_ID=1} {BOOK_ID=1, AUTHOR_ID=2} {BOOK_ID=2, AUTHOR_ID=1} {BOOK_ID=3, AUTHOR_ID=2} {BOOK_ID=3, AUTHOR_ID=3}

The app prints these lines and exits, because there is no web starter. Three books produced five rows in the join table.

Code Explained

  • @ManyToMany on Book.authors makes Book the owning side. @JoinTable names the join table and its two columns.
  • @ManyToMany(mappedBy = "authors") on Author.books makes it the inverse side. It only reads the link.
  • addAuthor() updates both collections, so the objects match the database.
  • We use Set, not List. With a List, Hibernate may delete and re-insert every join row when one link changes. A Set avoids that.
  • @Transactional keeps one database session open while we touch the lazy collections. The lazy loading topic explains why that matters.
  • JdbcTemplate reads the raw join table, so you can see that Hibernate really wrote it.

Common Mistakes

  • Forgetting the owning side. Adding only to the inverse collection saves nothing. Always update the side that has @JoinTable, or use a helper that updates both.
  • Using `List` instead of `Set`. Hibernate handles a Set much more efficiently when links are removed.
  • Printing both sides in `toString()`. A book prints its authors, each author prints its books, and you get an endless loop. Keep toString() to simple fields.
  • Needing extra columns on the link. If you want to store "role" or "date joined" on each link, @ManyToMany cannot hold it. Create a real entity, such as BookContribution, with two @ManyToOne fields.

Interview Questions

What is the difference between the owning side and the inverse side?

Ans:The owning side declares @JoinTable and controls the join rows. The inverse side uses mappedBy and only mirrors the link. Hibernate saves changes made on the owning side.

How does a database store a many-to-many relationship?

Ans:It uses a third table, the join table, with a foreign key to each of the two tables.

Why prefer Set over List in a many-to-many mapping?

Ans:When one link is removed from a List, Hibernate can delete every join row for that entity and insert them again. A Set lets it remove just one row.

How do you add extra data, such as a date, to the link?

Ans:Replace @ManyToMany with a join entity that has its own fields and two @ManyToOne links.

Key Points to Remember

  • Use @ManyToMany when both sides can have many related rows.
  • A join table with two foreign keys stores the links.
  • Only the owning side, the one with @JoinTable, writes join rows.
  • Prefer Set and keep both sides in sync with a helper method.
  • Avoid cascade remove, and use a join entity when the link needs extra columns.

Frequently Asked Questions

Do I have to create the join table for a many-to-many mapping myself?

No. With spring.jpa.hibernate.ddl-auto set to create, as it is for the embedded H2 database, Hibernate builds the table from @JoinTable. In production you write it in a migration script instead.

Can I make the mapping one-directional?

Yes. Keep @ManyToMany and @JoinTable on one class and leave the other class without any field. You can then navigate only from that side.

What is the default fetch type for @ManyToMany?

It is lazy. Hibernate loads the related rows only when you first touch the collection. The lazy vs eager loading topic covers this in detail.

What is the difference between one-to-many and many-to-many?

In one-to-many, each child row belongs to exactly one parent, and a foreign key on the child is enough. In many-to-many, a child can have many parents, so a join table is needed.

Practice Problems

Try each problem on your own first. Both use the H2 in-memory database, so nothing needs to be installed.

Easy: Movie Cast List

A cinema keeps movies and actors. One actor appears in many movies, and one movie has many actors. Build an app with Movie and Actor entities linked by @ManyToMany. Save two movies and three actors, then print each movie with its cast.

Show answer
Movie owns the join table movie_actor. Save the actors first, because a link cannot point to an unsaved actor. A helper method adds the actor on both sides, and a transactional method prints the cast.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.cinego</groupId> <artifactId>cast</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.main.banner-mode=off logging.level.root=warn

File: Movie.java in package com.cinego.cast

java
package com.cinego.cast; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; @Entity public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany @JoinTable(name = "movie_actor", joinColumns = @JoinColumn(name = "movie_id"), inverseJoinColumns = @JoinColumn(name = "actor_id")) private Set<Actor> cast = new HashSet<>(); protected Movie() { } public Movie(String title) { this.title = title; } public void addActor(Actor actor) { cast.add(actor); actor.getMovies().add(this); } public String getTitle() { return title; } public Set<Actor> getCast() { return cast; } }

File: Actor.java in package com.cinego.cast

java
package com.cinego.cast; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; @Entity public class Actor { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany(mappedBy = "cast") private Set<Movie> movies = new HashSet<>(); protected Actor() { } public Actor(String name) { this.name = name; } public String getName() { return name; } public Set<Movie> getMovies() { return movies; } }

File: MovieRepository.java in package com.cinego.cast

java
package com.cinego.cast; import org.springframework.data.jpa.repository.JpaRepository; public interface MovieRepository extends JpaRepository<Movie, Long> { }

File: ActorRepository.java in package com.cinego.cast

java
package com.cinego.cast; import org.springframework.data.jpa.repository.JpaRepository; public interface ActorRepository extends JpaRepository<Actor, Long> { }

File: CastService.java in package com.cinego.cast

java
package com.cinego.cast; import java.util.List; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class CastService { private final MovieRepository movies; private final ActorRepository actors; public CastService(MovieRepository movies, ActorRepository actors) { this.movies = movies; this.actors = actors; } @Transactional public void loadAndPrint() { Actor asha = actors.save(new Actor("Asha Menon")); Actor dev = actors.save(new Actor("Dev Kapoor")); Actor zoya = actors.save(new Actor("Zoya Khan")); Movie orbit = new Movie("The Silent Orbit"); orbit.addActor(asha); orbit.addActor(dev); Movie express = new Movie("Monsoon Express"); express.addActor(dev); express.addActor(zoya); movies.saveAll(List.of(orbit, express)); movies.flush(); for (Movie movie : movies.findAll()) { String names = movie.getCast().stream().map(Actor::getName).sorted() .collect(Collectors.joining(", ")); System.out.println(movie.getTitle()); System.out.println(" cast: " + names); } } }

File: CastApplication.java in package com.cinego.cast

java
package com.cinego.cast; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class CastApplication { public static void main(String[] args) { SpringApplication.run(CastApplication.class, args); } @Bean CommandLineRunner demo(CastService service) { return args -> service.loadAndPrint(); } }

Running the app prints:

text
The Silent Orbit cast: Asha Menon, Dev Kapoor Monsoon Express cast: Dev Kapoor, Zoya Khan

Dev Kapoor appears in both movies, which is exactly why a join table is needed. The cast names are sorted so the line does not depend on set order.

Medium: Playlist Cleanup

A music app has playlists and songs. A song can be in many playlists and a playlist has many songs. Using @ManyToMany, save two playlists that share one song. Then remove that song from the first playlist only. Print how many rows the join table has before and after, and confirm the song itself is not deleted.

Show answer
Remove the song from the owning side, Playlist.songs, and also from the inverse side to keep both in sync. Hibernate deletes one join row. The song row stays, because no cascade is used.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.tunebox</groupId> <artifactId>playlists</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.main.banner-mode=off logging.level.root=warn

File: Playlist.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; @Entity public class Playlist { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany @JoinTable(name = "playlist_song", joinColumns = @JoinColumn(name = "playlist_id"), inverseJoinColumns = @JoinColumn(name = "song_id")) private Set<Song> songs = new HashSet<>(); protected Playlist() { } public Playlist(String name) { this.name = name; } public void addSong(Song song) { songs.add(song); song.getPlaylists().add(this); } public void removeSong(Song song) { songs.remove(song); song.getPlaylists().remove(this); } public String getName() { return name; } }

File: Song.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import java.util.HashSet; import java.util.Set; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; @Entity public class Song { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany(mappedBy = "songs") private Set<Playlist> playlists = new HashSet<>(); protected Song() { } public Song(String title) { this.title = title; } public Set<Playlist> getPlaylists() { return playlists; } }

File: PlaylistRepository.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import org.springframework.data.jpa.repository.JpaRepository; public interface PlaylistRepository extends JpaRepository<Playlist, Long> { }

File: SongRepository.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import org.springframework.data.jpa.repository.JpaRepository; public interface SongRepository extends JpaRepository<Song, Long> { }

File: PlaylistService.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class PlaylistService { private final PlaylistRepository playlists; private final SongRepository songs; private final JdbcTemplate jdbc; public PlaylistService(PlaylistRepository playlists, SongRepository songs, JdbcTemplate jdbc) { this.playlists = playlists; this.songs = songs; this.jdbc = jdbc; } @Transactional public void run() { Song rain = songs.save(new Song("Rain on Tin Roof")); Song dawn = songs.save(new Song("Dawn Raga")); Playlist study = new Playlist("Study Time"); study.addSong(rain); study.addSong(dawn); Playlist sleep = new Playlist("Sleepy Evening"); sleep.addSong(rain); playlists.save(study); playlists.save(sleep); playlists.flush(); System.out.println("Links before: " + count("playlist_song")); study.removeSong(rain); playlists.flush(); System.out.println("Links after: " + count("playlist_song")); System.out.println("Songs still stored: " + count("song")); } private int count(String table) { return jdbc.queryForObject("select count(*) from " + table, Integer.class); } }

File: PlaylistsApplication.java in package com.tunebox.playlists

java
package com.tunebox.playlists; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class PlaylistsApplication { public static void main(String[] args) { SpringApplication.run(PlaylistsApplication.class, args); } @Bean CommandLineRunner demo(PlaylistService service) { return args -> service.run(); } }

Running the app prints:

text
Links before: 3 Links after: 2 Songs still stored: 2

One join row disappeared and both songs are still in the song table.