Skip to content
CalliCoder

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.