JPA Many to Many Mapping Example with Spring Boot
Spring Boot 13 min read
The join table you get by default and why you almost always want to name it, why removing from a many-to-many deletes more rows than you expected, and the point at which the relationship needs its own entity.
A many-to-many mapping is the one relationship JPA models with a table you never declared. Hibernate
creates a join table, names it after both entities, and manages its rows on your behalf, which works
until you need a column on that table, or until a remove deletes far more than you intended.
Written against Spring Boot 3.2, Hibernate 6.4 and Java 17.
The two entities
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
@JoinTable(
name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
// getters and setters
}
@Entity
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
@ManyToMany(mappedBy = "tags")
private Set<Post> posts = new HashSet<>();
}
mappedBy = "tags" marks Tag as the inverse side. There is no owner in the database sense, the
join table belongs to neither entity, but JPA still needs one side to be responsible for writing it,
and mappedBy says “not me”. Leave it out and you get two join tables, post_tags and
tag_posts, each half-populated. That is the single most common way this mapping goes wrong, and the
symptom is data that appears to vanish when read from the other direction.
Name the join table
Without @JoinTable, Hibernate derives posts_tags with columns posts_id and tags_id. It works
and it is nobody’s idea of a schema. Declaring the name and both columns costs four lines and means
the table is something you can write a migration against, index deliberately, and recognise in a slow
query log.
The generated table carries a composite primary key over both columns, so the same pair cannot be
inserted twice. That is also why Set is the right collection type here and List is not, see
below.
Use Set, not List
List<Tag> compiles. It also makes Hibernate delete every row for the owning entity and re-insert
the survivors on any change, because a bag has no stable identity per row. With a Set, removing one
tag from a post with fifty tags issues one DELETE; with a List it issues fifty-one statements.
A Set needs equals and hashCode, and the wrong implementation is worse than none:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tag other)) return false;
return name != null && name.equals(other.name);
}
@Override
public int hashCode() {
return getClass().hashCode(); // constant — deliberate
}
Two rules apply. Compare on a business key (name here), never on the generated id, because
the id is null until flush and an entity added to a HashSet before persisting would move buckets
afterwards. And return a constant hashCode, because that is the only value that stays stable across
the transition from transient to persistent. A constant hash degrades a HashSet of entities to a
linear scan, which is irrelevant at the sizes an entity collection reaches.
instanceof Tag rather than getClass() == o.getClass() matters because a lazily loaded association
is a Hibernate proxy, a generated subclass. Comparing classes returns false for the same row.
Keep both sides in sync
JPA does not maintain the inverse side for you. The object graph and the database will disagree inside a single transaction unless you write the helpers:
public void addTag(Tag tag) {
this.tags.add(tag);
tag.getPosts().add(this);
}
public void removeTag(Tag tag) {
this.tags.remove(tag);
tag.getPosts().remove(this);
}
Call addTag, never getTags().add(...). Making the getter return
Collections.unmodifiableSet(tags) is a blunt way to enforce that and is usually worth it.
Cascade: PERSIST and MERGE, never REMOVE
@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
CascadeType.ALL includes REMOVE, and on a many-to-many that means deleting a post deletes its
tags, including tags still attached to other posts. Deleting one blog post should not delete the
java tag for the whole site.
orphanRemoval = true is the same mistake with a different spelling and does not belong on a
many-to-many either.
What you do want on delete is the join rows to go. Hibernate handles that automatically from the
owning side. From the inverse side it does not, so deleting a Tag that posts still reference throws
a foreign key violation. Clear the association first:
@Transactional
public void deleteTag(Long tagId) {
Tag tag = tagRepository.findById(tagId).orElseThrow();
for (Post post : new HashSet<>(tag.getPosts())) {
post.removeTag(tag);
}
tagRepository.delete(tag);
}
The defensive copy matters, iterating tag.getPosts() while removeTag mutates it throws
ConcurrentModificationException.
Fetching, and the N+1
@ManyToMany is LAZY by default and should stay that way. FetchType.EAGER on both sides of a
many-to-many produces a cartesian product on every query.
Loading a page of posts and reading post.getTags() for each issues one query per post. The fix is a
fetch join:
@Query("SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.tags WHERE p.id IN :ids")
List<Post> findAllWithTags(@Param("ids") Collection<Long> ids);
Or an entity graph, which composes better with Spring Data’s derived queries:
@EntityGraph(attributePaths = "tags")
List<Post> findByTitleContaining(String fragment);
Two constraints worth knowing before you reach for either. A fetch join plus Pageable makes
Hibernate paginate in memory: it logs a warning and loads the whole result set. And fetching two
collections in one query throws MultipleBagFetchException when they are Lists; using Set
avoids the exception and still produces a cartesian product, so fetch one collection per query.
The same N+1 and its diagnosis are covered in the one-to-many mapping walkthrough.
Checking what actually ran
The mapping is easy to believe and hard to verify by reading. Turn on the SQL log:
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
Then run a test that adds a tag to a post with several tags already attached and count the
statements. One INSERT into post_tags is correct. A DELETE covering every row for that post
followed by a series of INSERTs means the collection is a List rather than a Set. A repeated
SELECT for each post in a list means the association is being walked lazily outside a fetch join.
Checking the generated schema is worth one command as well:
SHOW CREATE TABLE post_tags;
The expected result is two NOT NULL columns, a composite primary key over both, and a foreign key
to each side. If the primary key is a surrogate id column instead, something has been mapped as an
entity that you intended to be a plain join table.
When the join table needs a column
The moment you want to store when a tag was applied, or by whom, the mapping is no longer
many-to-many. Model the join table as an entity with two @ManyToOnes:
@Entity
@Table(name = "post_tags")
public class PostTag {
@EmbeddedId
private PostTagId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("postId")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("tagId")
private Tag tag;
private Instant taggedAt;
}
@MapsId ties each association to a field of the composite key so the foreign key and the primary
key are the same column: see the
composite primary key example for the
identifier class itself. This is more code, and it is the shape the relationship actually has.
Frequently asked questions
Why do I have two join tables?
The inverse side is missing mappedBy. Without it both sides
think they own the relationship and each creates its own table.
Which side should own a many-to-many?
Whichever one you will modify. The owning side writes the join table; the inverse side is read-only for persistence purposes regardless of what you add to it.
Should the collection be a Set or a List?
Set. A List makes Hibernate delete and re-insert
every join row on any change, and it triggers MultipleBagFetchException when two collections are
fetch-joined together.
Why must equals use a business key instead of the id?
The generated id is null until flush. An
entity hashed while transient lands in the wrong bucket once the id is assigned and can no longer be
found in its own HashSet.
Why should hashCode return a constant?
It is the only value that stays stable across the transient-to-persistent transition. The performance cost is a linear scan over a collection that is almost always small.
Can I use CascadeType.ALL on a many-to-many?
No. It includes REMOVE, so deleting one entity
deletes the entities on the other side, including ones still referenced elsewhere. Use PERSIST and
MERGE.
How do I delete from the inverse side?
Clear the association from every owning entity first, iterating over a defensive copy, then delete. Hibernate only removes join rows automatically from the owning side.
Why does adding a tag not show up on the tag’s posts?
JPA does not maintain the inverse
collection. Write addTag/removeTag helpers that update both sides, and call those instead of the
raw collection.
How do I avoid the N+1 when listing posts with tags?
A LEFT JOIN FETCH query or
@EntityGraph(attributePaths = "tags"). Fetch one collection per query, and do not combine a fetch
join with Pageable, Hibernate falls back to paginating in memory.
What if I need extra data on the relationship?
Promote the join table to an entity with an
@EmbeddedId and two @ManyToOne associations mapped with @MapsId. A relationship with attributes
is not a many-to-many.