skip to content

How do you map a many-to-many association in JPA with @JoinTable, and why do experienced teams often replace @ManyToMany with an explicit link entity?

level: middleimportance: must knowfreq 68%

answer

  1. Link table = two FKs; @JoinTable describes it
  2. joinColumns = this side, inverseJoinColumns = target
  3. Set not List — bag causes delete-all + re-insert
  4. No orphanRemoval on @ManyToMany; REMOVE cascade is dangerous
  5. Link entity the moment the relationship has attributes

basics

~20 s

@ManyToMany with @JoinTable maps a link table of two FK columns: joinColumns points at the owner, inverseJoinColumns at the target; the other side uses mappedBy. Teams replace it with a link entity when the relationship needs its own columns or stable identity.

solid answer

~50 s

A many-to-many needs a third table holding two foreign keys. The owning side declares it: ```java @ManyToMany @JoinTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) private Set<Tag> tags = new HashSet<>(); ``` `joinColumns` points back at **this** entity, `inverseJoinColumns` at the target — mixing them up is the classic mistake. The inverse side is `@ManyToMany(mappedBy = "tags")`. Use `Set`, not `List`: with a `List` Hibernate treats the collection as a bag and, on any change, deletes every link row for that owner and re-inserts them. A `Set` produces targeted single-row deletes and inserts. Teams move to an explicit **link entity** (two `@ManyToOne`s plus a composite or surrogate key) when the relationship gains attributes — `added_at`, `role`, `quantity` — or needs its own identity, auditing, or to be queried directly. `@ManyToMany` cannot carry a payload, and retrofitting one later is a migration.

code

java · 11 lines
java
@Entity
class Post {
  @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
  @JoinTable(name = "post_tag",
    joinColumns = @JoinColumn(name = "post_id"),
    inverseJoinColumns = @JoinColumn(name = "tag_id"))
  private Set<Tag> tags = new HashSet<>();

  void addTag(Tag t) { tags.add(t); t.getPosts().add(this); }
  void removeTag(Tag t) { tags.remove(t); t.getPosts().remove(this); }
}

go deeper

for a junior

Explain that a many-to-many needs a third table and that @JoinTable names it plus its two FK columns, with mappedBy on the other side.

for a middle

Get the joinColumns/inverseJoinColumns orientation right, explain Set-over-List and why, and name the cascade restrictions.

for a senior

Lead with the link-entity migration argument: attributes, auditing and direct querying arrive eventually, and promoting later touches schema and every call site.

for a principal

Treat it as a modelling decision — is this a set or an event? — and weigh the cost of an early link entity against a later migration, including read-model and query implications.

## The relational shape No relational table can hold a many-to-many directly; it requires an **association (link/junction) table** with one FK to each side: ```sql create table post_tag ( post_id bigint not null references posts(id), tag_id bigint not null references tags(id), primary key (post_id, tag_id) ); ``` `@JoinTable` is how JPA describes that table. ## Mapping it ```java @Entity class Post { @ManyToMany @JoinTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) private Set<Tag> tags = new HashSet<>(); } @Entity class Tag { @ManyToMany(mappedBy = "tags") private Set<Post> posts = new HashSet<>(); } ``` The orientation rule: **`joinColumns` describes the FK back to the entity the annotation is written on; `inverseJoinColumns` describes the FK to the other entity.** Swapping them compiles fine and produces a schema where the columns are reversed — usually caught only when data comes back wrong or a FK constraint fires. Omitting `@JoinTable` entirely lets the provider default the table name to `Post_Tag` and columns to `Post_id`/`tags_id`. Defaults are legal but brittle; name them. ## Set versus List — a real performance rule With `List` and no order column, Hibernate treats the collection as a **bag**: it cannot identify individual rows by position, so any modification triggers `delete from post_tag where post_id = ?` followed by re-inserting the whole collection. Adding one tag to a post with 200 tags becomes 1 delete plus 200 inserts, and it churns indexes and generates spurious write traffic. With `Set`, Hibernate can compute a delta and emits a single-row insert or delete. Always use `Set` for `@ManyToMany`. This also means the entity needs sane `equals`/`hashCode` — conventionally based on a business/natural key, not the generated id (which is null before persist) and not the default identity semantics that break after detach/merge. ## Cascade and removal pitfalls `CascadeType.REMOVE` on a `@ManyToMany` is almost always wrong: deleting a post would delete its tags, which other posts still reference. Only `PERSIST` and `MERGE` are usually appropriate. `orphanRemoval` is not even allowed on `@ManyToMany` — the spec restricts it to one-to-one and one-to-many, because "orphan" is meaningless when other owners may still point at the row. Removing a link is done by mutating the collection on the **owning** side; mutating only the inverse side changes nothing in the database, exactly as with a `mappedBy` one-to-many. ## Why teams replace it with a link entity `@ManyToMany` models a pure set membership. Real domains almost always accrete more: - *when* was the tag added, and by whom - an ordering or weight - a status (pending / approved) - a quantity, price, or role (`user_role` with a `granted_at`) A `@JoinTable` has room for exactly two columns of meaning. The moment you need a third, you must promote the link table to an entity: ```java @Entity class PostTag { @EmbeddedId private PostTagId id; @MapsId("postId") @ManyToOne @JoinColumn(name = "post_id") private Post post; @MapsId("tagId") @ManyToOne @JoinColumn(name = "tag_id") private Tag tag; private Instant addedAt; private String addedBy; } ``` The link entity is two `@ManyToOne` associations plus payload; each side becomes a `@OneToMany(mappedBy = ...)` of link entities. `@MapsId` lets the composite key reuse the FK columns rather than duplicating them. The practical argument for starting with the link entity: promoting later is a schema and code migration touching every call site and every query, whereas the link entity costs a little verbosity up front and never blocks you. Many experienced teams therefore treat `@ManyToMany` as suitable only for genuinely attribute-free, low-churn membership — and reach for the link entity by default otherwise. The counter-argument, worth acknowledging: for a truly pure membership set, `@ManyToMany` is less code, reads better, and lets Hibernate manage link rows without an extra entity in the persistence context. ## Querying With `@ManyToMany` you navigate `select p from Post p join p.tags t where t.name = :n`. With a link entity you query the link directly — `select pt.post from PostTag pt where pt.tag.name = :n and pt.addedAt > :since` — which is strictly more expressive, and is another reason the link entity wins once filtering on link attributes appears.

  • Why does using List instead of Set for a @ManyToMany hurt write performance?
    An unordered List is a bag: Hibernate cannot address individual link rows, so on any modification it deletes every row for that owner and re-inserts the whole collection. Adding one element to a 200-element collection becomes 1 delete plus 200 inserts. A Set lets Hibernate compute a delta and emit one targeted insert or delete, which is why Set is the standard choice.
  • Why is orphanRemoval not available on @ManyToMany, and what happens if you cascade REMOVE?
    orphanRemoval means "this child exists only for this parent", which cannot hold in a many-to-many where other owners may reference the same row — so the spec restricts it to one-to-one and one-to-many. Cascading REMOVE compiles but is usually a data-loss bug: deleting one post would delete the Tag entities themselves, breaking every other post that used them. Cascade only PERSIST and MERGE, and delete link rows by mutating the owning collection.
  • When would you still choose plain @ManyToMany over a link entity?
    When the relationship is genuinely attribute-free set membership that you never need to filter, order, or audit — a small tag set, a fixed permission set. It is less code, reads more naturally, and Hibernate maintains the link rows for you. The moment a column, an ordering, or a query over the link itself appears, promote it.

@ManyToMany is a guest list with only names; a link entity is a guest list with arrival time, seat, and dietary notes — you can't staple those onto the plain list later without reprinting it.

saying these in an interview costs you the question

  • Swapping joinColumns and inverseJoinColumns
  • Using List for a @ManyToMany and accepting the delete-all/re-insert write pattern
  • Cascading REMOVE across a @ManyToMany and deleting shared reference data
  • Expecting orphanRemoval to work on @ManyToMany
  • Mutating only the inverse (mappedBy) side and expecting link rows to change
  • Using generated-id-based equals/hashCode on entities stored in a Set collection

context