You map a bidirectional one-to-one between two entities and set fetch = FetchType.LAZY on both sides, but Hibernate still issues an extra SELECT for the side declared with mappedBy. Why does that side ignore the lazy setting, and what can you do about it?
answer
- mappedBy side has no FK column
- null-or-proxy dilemma: existence unknown
- @MapsId shared PK = id known a priori
- optional=false + bytecode enhancement
- @LazyToOne(NO_PROXY) removed in Hibernate 6
basics
~20 sThe mappedBy side has no foreign key column, so Hibernate cannot tell whether a related row exists without querying. A proxy cannot represent "maybe absent", so it queries eagerly. Fixes: a shared primary key with @MapsId, optional = false plus bytecode enhancement, or drop the inverse side.
solid answer
~60 sLaziness on a to-one works because the owning row already contains the foreign key: Hibernate knows the target identifier, and whether it is null, without any SQL — so it can build a proxy. The **inverse** (`mappedBy`) side has no such column. To know whether a child row exists at all, Hibernate must query the other table. And it *must* know, because the field has to be either a proxy or `null` — a proxy that later turns out to reference nothing is not a legal state. So it runs the select eagerly and the LAZY hint is ignored. Remedies, in rough order of preference: 1. **Make the association unidirectional** from the owning side and look the child up by query when you need the reverse direction. 2. **Share the primary key** with `@MapsId` / `@PrimaryKeyJoinColumn`, so the child's id equals the parent's — Hibernate can then form a reference without a lookup (still assumes presence). 3. **`optional = false` plus build-time bytecode enhancement**, which lets Hibernate defer the load because absence is now impossible by contract. Older `@LazyToOne(NO_PROXY)` was removed in Hibernate 6.
code
java · 14 lines@Entity class User {
@Id Long id;
// inverse side: no FK column here -> extra SELECT anyway
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
ProfileDetails details;
}
@Entity class ProfileDetails {
@Id Long id;
// owning side: user_id column is on this row -> truly lazy
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
User user;
}go deeper
Recognise that the mappedBy side of a one-to-one can produce an unexpected extra query and know it is a known limitation, not a bug in your code.
Explain the cause — no foreign key column, so existence is unknown and a proxy cannot be built — and name at least one workaround.
Rank the remedies by structural impact, prefer unidirectional or shared-primary-key modelling, and mention the per-row cost when loading collections of parents.
Treat it as a modelling signal: a true one-to-one usually wants a shared primary key enforced by the schema, and a bidirectional convenience reference is a cost to justify rather than a default.
## The asymmetry at the root of it In a one-to-one, only one table carries the foreign key. Call the table that has it the **owning** side; the other side is mapped with `mappedBy` and is called the **inverse** side. When Hibernate loads the owning row, the FK column comes back with the row itself. Two facts are therefore free: - whether the association is set (the column is null or not), and - the identifier of the target (the column's value). That is exactly what a lazy proxy needs: a type and an id. So `fetch = LAZY` on the owning side works. When Hibernate loads the **inverse** row, it has nothing. There is no column on this table pointing anywhere. The only way to answer "does a matching row exist, and what is its id?" is `select ... from other_table where fk = ?`. And Hibernate cannot avoid the question, because the Java field must be assigned *something* right now: either a proxy or `null`. A proxy that initialises to nothing is not representable — the field type is the entity, not `Optional`. Hibernate therefore issues the select immediately, and the `LAZY` hint has no effect. This is the same reason a lazy to-one that is `optional = true` cannot be deferred without enhancement even on the owning side in some mappings: nullability, not distance, is the blocker. ## Why the symptom is easy to miss Loading one parent shows one extra select — invisible in a unit test. Loading a page of parents shows one extra select *per row*, which is where it becomes a production problem. The mapping looks correct in code review because both sides say `LAZY`, so the usual reaction is disbelief; verifying with SQL logging is the fastest way to settle it. ## The fixes ### 1. Drop the inverse side The cheapest fix is usually to not map the reverse direction at all. Keep the association unidirectional from the FK holder, and when you need to go the other way, run a query: `select c from Child c where c.parent = :parent`. You lose `parent.getChild()` sugar and gain complete control over when the query runs. For read-mostly domains this is very often the right answer. ### 2. Share the primary key If the child's primary key *is* the parent's primary key — a genuine one-to-one extension table — map it with a derived identifier: ```java @Entity class ProfileDetails { @Id Long id; @MapsId @OneToOne(fetch = LAZY) @JoinColumn(name = "id") User user; } ``` Now the parent's inverse side knows the child's identifier a priori: it equals its own. Hibernate can create a reference without a lookup. The remaining catch is existence — if the child row may be missing, Hibernate still has to check unless you declare it non-optional. Shared-key one-to-ones are also better modelling: they make the 1:1 cardinality enforceable by the database via a primary-key/unique constraint rather than by convention. ### 3. `optional = false` + bytecode enhancement Declare `@OneToOne(mappedBy = "...", fetch = LAZY, optional = false)` and turn on build-time bytecode enhancement with lazy initialisation enabled (Gradle/Maven Hibernate enhance plugin). `optional = false` is a promise that the row always exists, which removes the null-or-proxy dilemma; enhancement gives Hibernate interception inside the entity itself rather than relying on a subclass proxy, so it can defer the load until the field is read. If you break the promise — the row is sometimes missing — you get an exception at access time instead of a silent null, so only use it when a constraint actually enforces presence. ### 4. Historical option Hibernate 5 offered `@LazyToOne(LazyToOneOption.NO_PROXY)`, which combined with enhancement achieved the same effect. It was deprecated and removed in Hibernate 6; do not cite it as a current answer, but recognising it in legacy code is useful. ## How to talk about it The strong answer names the cause in one line — *no FK column on the inverse side, so existence is unknown, so no proxy* — then ranks the fixes by how much of the model they change. Reaching straight for enhancement without mentioning the option of dropping the inverse side reads as tool-first thinking.
- Does the same limitation apply to a lazy @ManyToOne?No, because a @ManyToOne is always the owning side — the foreign key column is on the row being loaded, so both the target identifier and its nullability are known for free and a proxy can be created. The inverse of a @ManyToOne is a collection, and collections have their own lazy wrapper that can legitimately represent 'not yet loaded' because an empty result is a valid state for a collection.
- What is the risk of declaring optional = false when the row can actually be missing?You have told Hibernate it may skip the existence check, so at access time it will try to initialise a reference to a row that is not there and fail — typically with an EntityNotFoundException — instead of giving you null. It also affects join generation: Hibernate may use an inner join, silently dropping parents that have no child from query results. Only assert optional = false when a database constraint guarantees it.
The FK side is a person holding a claim ticket — they can hand you the ticket instantly and fetch the coat later. The inverse side is the coat, asked whether anyone owns it; the only way to know is to search the entire ticket book right now.
saying these in an interview costs you the question
- Claiming Hibernate is simply buggy or that the annotation is ignored arbitrarily.
- Suggesting fetch = LAZY on both sides is enough, without the shared key or enhancement.
- Proposing @LazyToOne(NO_PROXY) as a current Hibernate 6/7 solution.
- Declaring optional = false purely to get laziness when the row can be absent.
- Confusing this with the collection case — a lazy @OneToMany has no such problem.