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

AttributeMeaning
nameThe join table name
joinColumnsFK column pointing to the owning entity (Product)
inverseJoinColumnsFK 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:

  1. 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
  2. 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

  • @ManyToMany creates a join table; @JoinTable on the owner side defines the table and column names
  • Always use Set, not List, for @ManyToMany — List triggers inefficient bag semantics
  • Entities in a Set must have correct equals()/hashCode() — use a natural key, not the surrogate id
  • For join tables with extra columns (applied date, priority), replace @ManyToMany with an intermediate entity using @EmbeddedId + two @ManyToOne relationships
  • Never use CascadeType.REMOVE on @ManyToMany — it deletes shared entities
  • Always use FetchType.LAZY — the default EAGER is 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.