An application maps a deep entity hierarchy with JPA's JOINED inheritance strategy and its polymorphic queries have become slow. Explain the SQL these queries generate, what makes them expensive, and the levers available to reduce the cost without abandoning the hierarchy.
answer
- JOINED base query = LEFT JOIN every subclass table + CASE clazz_
- Cost grows with hierarchy width, on every legacy query
- TYPE() / TREAT() prune and narrow
- DTO projection of base columns needs no subclass join
- SINGLE_TABLE is the strategy immune to this cost
basics
~20 sA base-type query under JOINED outer-joins every subclass table so Hibernate can build each concrete instance, so cost grows with hierarchy width. Levers: query the concrete subtype instead, restrict with TYPE()/TREAT(), select a DTO projection of base columns only, index the join keys, or flatten to SINGLE_TABLE.
solid answer
~1 minUnder `JOINED`, `SELECT p FROM Payment p` cannot know each row's concrete type without looking, so Hibernate emits the base table LEFT OUTER JOINed to **every** subclass table, plus a synthetic `CASE` expression that tells it which class to instantiate. Ten subtypes means a ten-way outer join for a query that may only need the base columns — and the optimizer must still probe every subclass table per row. The levers, roughly in order of how often they work: 1. **Ask for less type.** Query the concrete subtype (`SELECT c FROM CardPayment c`) when you know it; Hibernate then joins one branch only. 2. **Restrict polymorphism in the query**: `WHERE TYPE(p) IN (CardPayment, BankTransfer)` lets Hibernate prune the other joins; `TREAT` narrows in a join path. 3. **Project instead of hydrating.** A DTO `SELECT new ...(p.id, p.amount)` over base columns needs no subclass joins at all. 4. **Index the subclass primary keys** — they are the join keys; and keep the hierarchy shallow. 5. **Flatten the mapping** to SINGLE_TABLE if the width is the real problem and nullable columns are acceptable. Beware the associated N+1 shape: a lazy `@ManyToOne` to the base type must query to learn the concrete class, so it cannot be proxied cheaply in all cases.
code
sql · 13 lines-- JPQL: select d from Document d where d.createdAt > :since
SELECT d.id, d.title, d.created_at,
i.number, i.total, c.party, c.signed_at, r.amount, m.body,
CASE WHEN i.id IS NOT NULL THEN 1
WHEN c.id IS NOT NULL THEN 2
WHEN r.id IS NOT NULL THEN 3
WHEN m.id IS NOT NULL THEN 4 END AS clazz_
FROM document d
LEFT JOIN invoice i ON i.id = d.id
LEFT JOIN contract c ON c.id = d.id
LEFT JOIN receipt r ON r.id = d.id
LEFT JOIN memo m ON m.id = d.id
WHERE d.created_at > ?;go deeper
Know that a base-type query under JOINED has to look at every subclass table, so it produces many joins.
Sketch the generated SQL including the outer joins and the type-discriminating CASE, and name concrete-type queries and DTO projections as fixes.
Diagnose from logged SQL and plans, apply TYPE/TREAT pruning, spot polymorphic to-one N+1, and weigh migrating the strategy against the constraint loss it causes.
Push the question upstream: whether the hierarchy earns its keep at all, what the schema migration to SINGLE_TABLE costs in constraints and downtime, and how to stop each new subclass taxing every existing query.
## What the SQL actually looks like Take `Document` with subclasses `Invoice`, `Contract`, `Receipt`, `Memo` under `@Inheritance(strategy = InheritanceType.JOINED)`. Schema: ``` document(id, title, created_at) invoice (id -> document.id, number, total) contract(id -> document.id, party, signed_at) ... ``` `SELECT d FROM Document d WHERE d.createdAt > :since` compiles to something like: ```sql SELECT d.id, d.title, d.created_at, i.number, i.total, c.party, c.signed_at, r.…, m.…, CASE WHEN i.id IS NOT NULL THEN 1 WHEN c.id IS NOT NULL THEN 2 … ELSE 0 END AS clazz_ FROM document d LEFT JOIN invoice i ON i.id = d.id LEFT JOIN contract c ON c.id = d.id LEFT JOIN receipt r ON r.id = d.id LEFT JOIN memo m ON m.id = d.id WHERE d.created_at > ? ``` Hibernate must join every subclass table because the base row alone does not say what type it is (unless you added a discriminator column, which JOINED permits and which lets Hibernate know the type but *not* skip the join for the columns it must read). The `CASE` expression is how the concrete class is decided per row. ## Why it gets expensive - **Width scales with the hierarchy.** Each new subclass adds a join to every base-type query in the system, including ones written years earlier. This is the compounding cost that makes deep JOINED hierarchies regretted. - **Projection width.** The result set carries every subclass's columns, mostly NULL. On wide subclasses this is real network and memory traffic. - **Optimizer behaviour.** Many-way outer joins constrain plan choice; predicates on base columns are usually pushed fine, but predicates on subclass columns force the join first. - **Deep hierarchies join per level.** Three levels means the leaf load joins its parent and grandparent too, even for a single-type query. - **Writes pay as well.** Inserting a leaf writes one row per level; deletes likewise. TABLE_PER_CLASS has the analogous problem in a different shape: the base-type query becomes `UNION ALL` over all concrete tables with NULL padding, so cost also grows with hierarchy width and predicate pushdown into the union is optimizer-dependent. SINGLE_TABLE is the strategy that does *not* degrade this way — one table, one scan or index lookup, at the cost of nullable columns and table width. ## The levers **1. Query the type you actually need.** Most "polymorphic" queries in real systems are not: the caller knows it wants invoices. `SELECT i FROM Invoice i` joins `document` to `invoice` only. This single change fixes most reported slowness. **2. Narrow with TYPE() and TREAT().** `WHERE TYPE(d) = Invoice` or `TYPE(d) IN (Invoice, Receipt)` lets Hibernate restrict — and, in Hibernate 6, prune joins for excluded subtypes. `TREAT(d AS Invoice).total > 100` narrows within a path so you can filter on subclass state without hand-written SQL. **3. Project a DTO.** `SELECT new com.acme.DocSummary(d.id, d.title) FROM Document d` reads only base columns; no subclass join is needed because no subclass instance is being built. For list screens this is usually both the fastest and the most honest fix — you did not need entities at all. **4. Fetch strategy for associations to the base type.** A `@ManyToOne(fetch = LAZY) Document doc` is awkward under JOINED: creating a proxy requires knowing the concrete class, which requires a query. Hibernate can proxy the *base* type in some configurations, but polymorphic to-one associations are a classic source of extra selects. Where the association is always dereferenced, an explicit `JOIN FETCH` is cheaper than the surprise. **5. Index the join keys.** The subclass primary key is the join column; it is a primary key so it is indexed, but confirm the plan uses index nested loops rather than hash joins over full scans, and that `document`'s filtering predicate is itself indexed. **6. Keep the hierarchy shallow and narrow.** Two levels and a handful of subclasses is fine; five levels and fifteen subclasses is a design problem no query hint will fix. **7. Change strategy.** Migrating to SINGLE_TABLE removes the joins entirely. It is a real schema migration and it costs you `NOT NULL` on subclass columns (recoverable via CHECK constraints conditioned on the discriminator), but for read-heavy polymorphic workloads it is frequently the right call. **8. Question the hierarchy.** If subtypes share almost no behaviour and are never handled uniformly, `@MappedSuperclass` with independent entities — or separate aggregates altogether — removes the problem at the root. ## How to diagnose it Turn on SQL logging with statistics and look at the emitted statement for the slow endpoint: count the joins, count the selected columns, and check whether the caller ever uses subclass state. Then look at the execution plan for whether the base-table predicate is index-driven. In most investigations the finding is the same: an entity query returning the base type, feeding a screen that displays three base columns. ## The answer that lands Show the SQL shape from memory, explain that width scales with the number of subclasses, and give the levers in order of intrusiveness — query the concrete type, restrict with `TYPE`/`TREAT`, project a DTO, then consider changing the strategy. Mentioning that SINGLE_TABLE is the strategy immune to this particular cost demonstrates you understand the trade you originally made.
- Does adding a discriminator column to a JOINED hierarchy remove the joins?No. It lets Hibernate determine the concrete type from the base row without inspecting the CASE over subclass keys, which can simplify the generated expression, but the subclass columns still live in their own tables, so a query that builds full entities must still join them. It helps type resolution, not column retrieval.
- Why can a lazy @ManyToOne to a polymorphic base type still cause extra queries?To hand you a proxy, Hibernate must know which class to subclass; under JOINED the concrete type is not knowable from the foreign key alone, so in configurations where the exact type is required it issues a select to resolve it. That turns a supposedly free lazy reference into a query per parent row — a polymorphic flavour of N+1. An explicit JOIN FETCH removes the surprise where the association is always used.
Asking for 'any document' under JOINED is like asking a clerk for a file when the details live in four different rooms — they must check every room before handing you anything. Asking for 'an invoice' sends them to one room.
saying these in an interview costs you the question
- "Just add indexes" — the join keys are already primary keys; the width is the problem
- Believing a polymorphic base-type query touches only the base table
- Assuming TABLE_PER_CLASS avoids the cost — it trades joins for a UNION ALL of the same width
- Reaching for native SQL before trying the concrete-type query or a DTO projection
- Not knowing TYPE() and TREAT() exist in JPQL