Skip to main content

Hibernate 7 Association Mappings: The Query Counts Behind @OneToOne, @ManyToOne, and @ManyToMany

A companion repository measured the real query counts behind Hibernate 7 association mappings: 101 vs 1 vs 11 queries for four N+1 fixes, a 12-row cartesian product collapsing to 1 entity, a mappedBy @OneToOne that stays eager despite FetchType.LAZY, and a correction to the common claim that reassigning an orphanRemoval collection silently deletes it (it throws; only in-place mutation deletes silently).

Are you struggling with messy database schemas, redundant tables, or sluggish queries in your Java applications? Association mappings are where Hibernate’s ORM abstraction is thinnest — a single annotation choice decides whether loading a list of parents fires one query or a few hundred. A GET /api/orders endpoint that returns 5 orders locally and 2,000 in production is the same code either way. The difference is whether Customer is mapped LAZY or EAGER, whether the association is fetch-joined, and whether the collection type is a List or a Set. This article rewrites the mapping choices around the numbers a companion repository actually measured on Hibernate ORM 7.4.5.Final — not the numbers everyone assumes.
Verified against. Hibernate ORM 7.4.5.Final (resolved by Spring Boot 4.1.1, GA 2026-07), Jakarta Persistence 3.2.0, Java 25, on H2 2.4.240. Every query count below comes from Statistics.getPrepareStatementCount() in a JUnit test that fails if the number changes — see the repository links throughout.
PartRead this if…
1 — BeginnerYou need to choose @OneToOne, @ManyToOne/@OneToMany, or @ManyToMany and want the rules that keep you out of trouble
2 — IntermediateYour list of parents is firing one query per row and you need the actual fix, with real query counts for each option
3 — AdvancedYou’ve been bitten by MultipleBagFetchException, a cartesian product, a @OneToOne that’s lazy in name only, or an orphan-removal delete you didn’t expect
This article’s companion repository is hibernate-demo; the chapter behind it is 12 — Association mappings. It shares the lazy/eager boundary with Proxies and the LazyInitializationException: that article owns the exception itself and how to cure it; this one owns which mapping to choose and what it costs in queries.

Part 1 — Choosing a mapping shape

Object graphs and relational tables aren’t a 1:1 mirror. A user account links to a profile, an order contains line items, a student enrolls in courses across semesters. Getting the shape of that mapping wrong doesn’t just look ugly in code — it decides how many round trips the database sees per request.

@OneToOne: use @MapsId, not a shared nullable foreign key

For a strict 1:1 relationship (User and UserProfile), the recommended shape shares the primary key value between the two rows via @MapsId, rather than giving the child its own generated id plus a unique foreign key column. Sharing the key removes an index and simplifies every join between the two tables to an equality on the same value.
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private UserProfile profile;
}

@Entity
@Table(name = "user_profiles")
public class UserProfile {
    @Id
    private Long id;

    private String bio;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "user_id")
    private User user;
}
JSON serialization guardrail. A bidirectional @OneToOne serialized naively with Jackson infinitely recurses (user → profile → user → …). If you must serialize entities directly rather than mapping to a DTO, break the cycle with @JsonManagedReference on the parent side and @JsonBackReference on the child. The DTO approach below (Part 1, REST section) avoids needing this at all.
This shape looks safe — both sides say LAZY — but Part 3 measures what actually happens when you load a User with this exact mappedBy shape, and the annotation does not tell the truth. Keep that in mind; it’s the seed this article pays off later.

@ManyToOne / @OneToMany: the many side owns the relationship

For the workhorse case — Department and its Employees — the rule is simple: in a bidirectional relationship, the side holding the foreign key (the @ManyToOne, the “many” side) is always the owner. Mismatched ownership produces an extra UPDATE per row: Hibernate inserts the child with a null foreign key, then issues a second statement to set it, because it believed the inverse (mappedBy) side was in charge of persisting the link.
@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "dept_id", nullable = false)
    private Department department;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee)) return false;
        return id != null && id.equals(((Employee) o).id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}
The hashCode() returning a fixed getClass().hashCode() is deliberate, not an oversight: it guarantees the hash never changes when the id transitions from null (transient) to assigned (persisted), which is what a HashSet requires of anything it stores across that transition. One more trap in this shape: a unidirectional @OneToMany (no mappedBy, no inverse @ManyToOne at all) does not use the foreign key column on the child table by default — it creates a separate join table, exactly like a @ManyToMany would, unless you add an explicit @JoinColumn on the collection side. Prefer the bidirectional shape above; it avoids the redundant table entirely.

@ManyToMany: reach for a join entity before the raw annotation

@ManyToMany (Student and Course) hides the join table from your domain model entirely. The moment you need to know when or how the relationship formed — an enrollment date, a grade — the plain annotation has nowhere to put that data, and you refactor to two @ManyToOne relationships through an explicit join entity (Enrollment) anyway. Doing that from the start avoids the refactor:
@Entity
public class Enrollment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY)
    private Course course;

    private LocalDate enrollmentDate;
    private String grade;
}
If the relationship genuinely carries no metadata and never will, the plain annotation is fine — but implement the collection as a Set, not a List:
@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(
        name = "enrollments",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private Set<Course> courses = new HashSet<>();
}
Set avoids what Hibernate calls “bag” semantics on a List — a collection with no unique-element constraint, which Hibernate handles by deleting and re-inserting the entire collection on certain mutations rather than issuing a targeted change. Part 3 measures a second, sharper cost of bags: two of them in the same entity cannot be fetch-joined together at all.

Cascade and orphan removal, and one rule that has no exceptions

CascadeType.ALL propagates every operation from parent to child — appropriate when the child cannot exist independently of the parent. orphanRemoval = true goes further: removing a child from the parent’s collection deletes its row, not just its foreign key. Both are correct, standard tools for a true parent-child relationship (an Author and its Books). What they do when you reassign the collection versus mutate it in place is not what the last version of this article said — Part 3 has the real answer, with the exact exception message.
Never cascade REMOVE upward. If Employee has CascadeType.REMOVE (or ALL) on its @ManyToOne to Department, deleting one employee deletes the entire department. Cascades flow down from parent to child, never up from child to parent. There is no configuration that makes cascading REMOVE on a @ManyToOne safe — if you need it, the mapping is inverted.

Part 2 — The N+1 problem, measured four ways

Locally, with 5 authors, a page that lazily iterates author.getBooks() for each of them is fast. In production, with 100 authors and 3 books each, that same loop fires 101 queries — one to load the authors, then one per author to load their books, because FetchType.LAZY defers each collection until it’s touched and nothing tells Hibernate to fetch them together.
100 authors × 3 books each — total queries fired Naive lazy iteration 101 JPQL JOIN FETCH 1 @EntityGraph 1 @BatchSize(10) 11 @BatchSize(10) = ceil(100 / 10) batched IN(…) selects for books, plus 1 select for authors = 11 total. Doubling the batch size to 20 would give ceil(100/20)+1 = 6 — the arithmetic, not a fixed number, is what to remember.
Three tools close that gap, and the repository’s NPlusOneTest measured all four side by side against the identical seeded data:
StrategyQuery count
Naive lazy iteration101
JPQL JOIN FETCH1
@EntityGraph (jakarta.persistence.fetchgraph hint)1
@BatchSize(size = 10)11
-- Strategy A: JPQL fetch join, fires exactly one query
SELECT a FROM AssocAuthor a JOIN FETCH a.books
JOIN FETCH and @EntityGraph both collapse the collection load into the same query as the parent — the difference is that an entity graph is a reusable, declarative fetch plan you attach to a repository method, while a fetch join is baked into the HQL string itself. @BatchSize is the safety net for the cases where you can’t fetch-join — multiple unrelated collections on the same entity that would otherwise cartesian-product each other (Part 3), or an association reached from many different query paths. Setting the global hibernate.default_batch_fetch_size=25 catches most N+1 problems automatically, with zero code changes, at the cost of turning one N+1 into a small, bounded number of batched selects.

Part 3 — What the mapping choices above don’t tell you

MultipleBagFetchException, and what it actually looks like in a catch block

Fetch-joining two List collections on the same entity in one JPQL query throws. Concretely, an author entity with two bag (List) collections — books and awards —
SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards
produces this, verbatim, from the repository’s BagFetchTest:
wrapper class: java.lang.IllegalArgumentException
root cause class: org.hibernate.loader.MultipleBagFetchException
message: cannot simultaneously fetch multiple bags: [com.ankurm.hibernatedemo.association.BagAuthorList.awards, com.ankurm.hibernatedemo.association.BagAuthorList.books]
Correction to a common assumption. Through EntityManager.createQuery(...).getResultList(), this surfaces as java.lang.IllegalArgumentException, not jakarta.persistence.PersistenceException. A catch block written to handle Hibernate query failures uniformly by catching PersistenceException lets this one through.
Two fixes, both measured:
  • Use Set instead of List. An identically shaped entity with Set<Book>/Set<Award> runs the same double fetch-join query with zero exceptions and 1 query total.
  • Two separate queries, one JOIN FETCH each, assembled in the application. 2 queries total, no exception.

The cartesian-product trap: fixing the exception doesn’t fix the row count

Switching both collections to Set stops the exception, but it doesn’t avoid the underlying join math. Fetch-joining two allowed Sets in one query, for 1 author with 4 books and 3 awards, returns:
  • Raw SQL join row count: 12 (4 × 3 — one row per book/award pair)
  • Entities returned to the application, after Hibernate’s root-entity de-duplication: 1, fully populated with all 4 books and all 3 awards
The row explosion happens at the database and JDBC layer regardless of how clean the final entity graph looks — for large collections, this is exactly where “the query is fast in isolation but the app is slow under load” reports come from. Fetch-joining more than one collection at once is rarely the right call once either collection has meaningful size; batch fetching each one separately avoids the multiplication entirely.

The @OneToOne lazy trap: the annotation says LAZY, the runtime says EAGER

Here is the payoff for the @OneToOne shape from Part 1. A non-owning (mappedBy) @OneToOne declared FetchType.LAZY cannot actually be lazy without bytecode enhancement: it holds no foreign key of its own, so Hibernate cannot defer the question of whether a row exists on the other side. Measured in OneToOneLazyTest:
LazyUser.find(): 2 queries fired BEFORE touching getProfile() at all
after touching getProfile(): 2 queries total (profile=loaded)
The fix from Part 1’s @MapsId shape is not @MapsId alone — it’s removing the inverse (mappedBy) field entirely and looking the profile up by its shared primary key only when you actually need it:
MiUser.find() (no mappedBy field at all): 1 query
explicit MiProfile.find() by shared PK when actually needed: 2 total queries
Loading the user alone costs exactly 1 query; the profile is fetched only when code asks for it by primary key. This is the mapping-side half of the lazy story — if you land here after a LazyInitializationException rather than a surprise extra SELECT, Proxies and the LazyInitializationException covers the proxy mechanics and the cures for that exception specifically.

Cascade and orphan removal: the correction

This is the other payoff planted in Part 1. The commonly repeated claim — including in an earlier version of this article — is that assigning a new collection to an orphanRemoval = true field silently deletes the old rows. It doesn’t. Reassigning the collection reference throws at commit time:
cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: jakarta.persistence.RollbackException
cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: org.hibernate.HibernateException: A collection with orphan deletion was no longer referenced by the owning entity instance: com.ankurm.hibernatedemo.association.CascadeAuthor.books
// This throws HibernateException at flush -- it does NOT silently delete
public void replaceBooksWithNewList(List<CascadeBook> newBooks) {
    this.books = new ArrayList<>(newBooks);
}
The scenario that does silently delete is different: mutating the same managed collection instance in place, the pattern that actually reaches most production code (managed.getBooks().removeIf(...)):
cascade=ALL + orphanRemoval=true, in-place removeIf(): books before=3, books after=1
Same mapping, cascade=ALL + orphanRemoval=true author.setBooks(new ArrayList<>(…)) reassign the collection reference author.getBooks().removeIf(…) mutate the same managed collection throws at flush HibernateException: no longer referenced by the owning entity instance deletes silently, no exception books before=3, books after=1 DELETE issued for each removed row Same annotation, opposite failure modes — the reassignment fails loud, the in-place mutation is the one to watch for in review.
With no orphanRemoval at all, removing a child from the collection and flushing does nothing to the database — no DELETE, no FK update; the row and its foreign key are untouched:
orphanRemoval=false: after removing book2 from author.books and flushing,
  book2 row still exists = true, author_id still = 1

The owning side is the only side Hibernate reads

Mutating only the inverse (mappedBy) side of a bidirectional association — adding a book to author.getBooks() without ever calling book.setAuthor(author) — persists nothing. Hibernate looks exclusively at the owning side (the one holding @JoinColumn) when deciding what to write:
owning side test: mutated only author2.getBooks().add(book) (inverse side),
  book.author after flush = null (FK not written)

Frequently asked questions

What’s the difference between @JoinColumn and mappedBy? @JoinColumn defines the physical foreign key column on the owning side. mappedBy marks the inverse side and points at the field on the owner that actually manages the relationship. Is it better to use List or Set? Set is generally preferred, and Part 3 gives the concrete reason: two List (bag) collections on the same entity cannot be fetch-joined together at all — Sets can, though the cartesian-product row count still applies. Why does Hibernate throw LazyInitializationException? This happens when code accesses a LAZY association after the Session/transaction that loaded it has closed. Part 2’s JOIN FETCH and @EntityGraph strategies prevent it by loading the association up front; Proxies and the LazyInitializationException covers the exception and its cures in full, including the cases those two fixes don’t reach. Should I expose entities directly from REST APIs? No. Direct entity exposure creates tight coupling, risks leaking fields that were never meant to be public, and reintroduces the serialization-recursion problem the JSON guardrail in Part 1 works around. Map to DTOs or Java records; MapStruct removes most of the boilerplate. What is the N+1 problem, concretely? One query loads N parent rows, then one additional query fires per row to resolve a lazy association — N+1 total. Part 2’s measured numbers (101 queries for 100 authors) show exactly how that scales, and the three fixes (JOIN FETCH, @EntityGraph, @BatchSize) that bring it back down to 1 or a small batched count.
Should you hand-write every association? Not always. If a screen is purely read-heavy reporting, a DTO projection or a native query with no entity graph at all is often simpler and faster than any of the fetch strategies above — there’s no lazy trap to fall into if there’s no managed association to traverse. Reach for full bidirectional entity mappings when you actually need to walk and mutate the graph in a transaction; reach for projections when you’re just reading it back out.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.