Many-to-Many Relationships: @ManyToMany and Join Tables
Introduction
A many-to-many relationship means records in table A can relate to many records in table B, and vice versa. Products have many tags; tags apply to many products. In SQL this requires a join table. In JPA, @ManyToMany and @JoinTable handle this automatically — but when you need extra data on the join (like a creation date or a relevance score), you need a different approach.
Simple @ManyToMany: Product and Tag
products (*) ──── product_tags ──── (*) tags
The product_tags join table has two columns: product_id and tag_id.
Owner side (Product)
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
@ManyToMany
@JoinTable(
name = "product_tags",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
public void addTag(Tag tag) {
tags.add(tag);
tag.getProducts().add(this);
}
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getProducts().remove(this);
}
}
Inverse side (Tag)
@Entity
@Table(name = "tags")
@Getter @Setter @NoArgsConstructor
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
// Inverse side — mappedBy points to the field in Product
@ManyToMany(mappedBy = "tags")
private Set<Product> products = new HashSet<>();
}
@JoinTable explained
| Attribute | Meaning |
|---|---|
name | The join table name |
joinColumns | FK column pointing to the owning entity (Product) |
inverseJoinColumns | FK column pointing to the inverse entity (Tag) |
Only the owner side has @JoinTable. The inverse side uses mappedBy = "tags".
Working with Many-to-Many
@Transactional
public Product createProduct(String name, BigDecimal price, Set<String> tagNames) {
Product product = new Product();
product.setName(name);
product.setPrice(price);
product.setCreatedAt(LocalDateTime.now());
product.setUpdatedAt(LocalDateTime.now());
for (String tagName : tagNames) {
Tag tag = tagRepository.findByName(tagName)
.orElseGet(() -> {
Tag newTag = new Tag();
newTag.setName(tagName);
return tagRepository.save(newTag);
});
product.addTag(tag); // maintains both sides
}
return productRepository.save(product);
}
Hibernate inserts:
INSERT INTO products (name, price, ...) VALUES (?, ?, ...)
INSERT INTO product_tags (product_id, tag_id) VALUES (?, ?)
INSERT INTO product_tags (product_id, tag_id) VALUES (?, ?)
Why Set and Not List for @ManyToMany
Always use Set (not List) for @ManyToMany collections. Using a List with @ManyToMany triggers Hibernate’s “bag” semantics, which causes:
- Deleting one item from the join table deletes all join table rows for that entity, then re-inserts the remaining ones — O(N) deletes+inserts for every single removal
- Possible
HibernateException: cannot simultaneously fetch multiple bags
// Correct — use Set
@ManyToMany
private Set<Tag> tags = new HashSet<>();
// Wrong — causes performance issues
@ManyToMany
private List<Tag> tags = new ArrayList<>();
equals() and hashCode() for @ManyToMany Sets
Because we use Set<Tag>, Tag must correctly implement equals() and hashCode(). Use the natural key (name) not the surrogate id (which is null before persist):
@Entity
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
@Override
public boolean equals(Object o) {
if (!(o instanceof Tag)) return false;
return Objects.equals(name, ((Tag) o).name);
}
@Override
public int hashCode() {
return name == null ? 0 : name.hashCode();
}
}
Adding Extra Data to the Join Table
Pure @ManyToMany only works when the join table has exactly two foreign key columns. If you need extra columns (e.g., when a tag was applied, or a display priority), you need an intermediate entity.
Example: Product-Tag with an applied date
Instead of @ManyToMany, create a ProductTag entity:
// Composite key for the join entity
@Embeddable
public class ProductTagId implements Serializable {
@Column(name = "product_id")
private Long productId;
@Column(name = "tag_id")
private Long tagId;
// constructors, equals, hashCode
}
@Entity
@Table(name = "product_tags")
@Getter @Setter @NoArgsConstructor
public class ProductTag {
@EmbeddedId
private ProductTagId id;
// FK relationship to Product
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("productId")
@JoinColumn(name = "product_id")
private Product product;
// FK relationship to Tag
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("tagId")
@JoinColumn(name = "tag_id")
private Tag tag;
// Extra column on the join
@Column(name = "applied_at", nullable = false, updatable = false)
private LocalDateTime appliedAt;
@Column(name = "applied_by", length = 100)
private String appliedBy;
public ProductTag(Product product, Tag tag, String appliedBy) {
this.id = new ProductTagId(product.getId(), tag.getId());
this.product = product;
this.tag = tag;
this.appliedAt = LocalDateTime.now();
this.appliedBy = appliedBy;
}
}
Update Product and Tag to reference the join entity:
@Entity
public class Product {
// Replace @ManyToMany with a @OneToMany to the join entity
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<ProductTag> productTags = new HashSet<>();
public void addTag(Tag tag, String appliedBy) {
ProductTag pt = new ProductTag(this, tag, appliedBy);
productTags.add(pt);
tag.getProductTags().add(pt);
}
}
@Entity
public class Tag {
@OneToMany(mappedBy = "tag")
private Set<ProductTag> productTags = new HashSet<>();
}
Flyway migration for the extra columns:
ALTER TABLE product_tags
ADD COLUMN applied_at DATETIME(6) NOT NULL DEFAULT NOW(),
ADD COLUMN applied_by VARCHAR(100);
Fetch Type for @ManyToMany
The default fetch type for @ManyToMany is EAGER — loading a Product immediately loads all its Tag rows. Always override to LAZY:
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(...)
private Set<Tag> tags = new HashSet<>();
Cascade with @ManyToMany
Be careful with cascade on @ManyToMany. CascadeType.ALL or CascadeType.REMOVE would delete the tag itself when you remove it from a product’s tag set — removing shared entities from all products that use them.
Typically, @ManyToMany should have no cascade or only PERSIST/MERGE:
// Safe — cascade only persist/merge, never remove
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@JoinTable(...)
private Set<Tag> tags = new HashSet<>();
Removing a tag from a product’s set only removes the join table row — not the tag itself.
Key Takeaways
@ManyToManycreates a join table;@JoinTableon the owner side defines the table and column names- Always use
Set, notList, for@ManyToMany— List triggers inefficient bag semantics - Entities in a
Setmust have correctequals()/hashCode()— use a natural key, not the surrogate id - For join tables with extra columns (applied date, priority), replace
@ManyToManywith an intermediate entity using@EmbeddedId+ two@ManyToOnerelationships - Never use
CascadeType.REMOVEon@ManyToMany— it deletes shared entities - Always use
FetchType.LAZY— the defaultEAGERis dangerous for collections
What’s Next
Article 12 covers cascade types and orphan removal in depth — what each cascade type does, when to use orphanRemoval, and the common mistakes that lead to unexpected deletes or detached children.