skip to content

Remedy Selection by Cardinality

Picking between a join fetch, batch fetch, subquery fetch, a projection or a cache by link cardinality, page size and payload width. Interviewers ask whether you fix a use case or a mapping.

on this pageshow

questions

4

When a list read fires one query per row, what remedies remove the repeated queries?

level: juniorimportance: must knowfreq 72%

answer

  1. bounded statements, not per-row
  2. reshape the load, or avoid it
  3. join, bulk second query, projection, cache
  4. cardinality, page size, width, reuse

basics

~20 s

Four families: pull the link into the parents' statement with a join fetch, load the whole page's links in one extra bulk statement, project the read down to the fields it needs, or serve the link from a cache.

solid answer

~40 s

The repeated statements come from touching a deferred link once per row, so every remedy replaces the per-row loads with a bounded number of loads. A **join fetch** brings the link's columns back in the same statement as the parents. A **bulk second query** — batch fetch keyed by the page's parent identifiers, or a subquery fetch that re-runs the parent query inside a subquery — loads the whole page's links in one extra statement. A **projection** returns a narrow transfer model that already contains the linked values, so nothing is walked afterwards. A **cache** answers the link from memory instead of the database. Which one fits depends on the link's cardinality, the page size, how wide the payload is, and how often the same graph is read again.

go deeper

for a junior

Be able to name the families out loud: one wider statement, one extra bulk statement, a narrower projection, or a cache. Knowing the menu is most of the screening answer.

for a middle

Explain what each remedy changes about the statements issued, and why a to-one link and a collection do not want the same one.

for a senior

Show that you pick per read and confirm with a statement count, rather than switching a mapping default and hoping.

for a principal

Frame it as a policy question: which remedies a codebase should allow by default, and how teams keep loading decisions near the reads that own them.

## Why a list read turns into many statements Most mapping layers defer a linked object or collection. The parent row is materialised, the link holds a stand-in, and the real statement runs the first time code touches it. Read a page of `N` parents and then walk each parent's link — in a serialiser, a formatter, a total, a permission check — and the layer emits one statement for the page plus one per parent. The database is usually not slow; the round trips are, and their number grows with the page. A remedy is anything that makes the number of statements **bounded** — independent of `N` — while still producing the data the read needs. There are four families. Two of them change **how** the link is loaded, one changes **what** is loaded, and one changes **where the data comes from**. ## The four families | Remedy | Statements for a page of N | Fits | Main cost | |---|---|---|---| | **Join fetch** | 1 | a to-one link, or a single narrow collection | parent columns repeat on every child row | | **Bulk second query** (batch or subquery fetch) | 2, or a few | to-many links; several links at once | one extra round trip per link | | **Projection to a transfer model** | 1 | reads that display a handful of fields | results are not tracked objects; another shape to keep in step | | **Cache** | 0 on a hit | small, hot, reused, slow-changing data | staleness and invalidation | - A **join fetch** widens the parents' own statement so the linked rows arrive with them. One round trip, nothing left to touch later. - A **bulk second query** keeps the parent statement narrow and issues one more statement that answers the whole page at once: either by collecting the page's parent identifiers and asking for all their children together, or by re-using the parent query as a subquery inside the child query. - A **projection** sidesteps the graph entirely. If the read displays six fields, ask the database for six columns; the linked values come back as ordinary columns of a flat result, and there is no deferred link left to trip over. - A **cache** removes the statement rather than reshaping it. It only helps when the same linked data is read again and again and can tolerate being slightly out of date. The detailed mechanics of each fetch mode belong to the loading-strategy topics; what matters here is that they are alternatives with different shapes, not a ranking. ## What decides the choice 1. **Cardinality of the link.** A to-one link adds columns to a row. A to-many link adds rows, and a second to-many link in the same statement multiplies them. 2. **Page size.** A remedy that carries the page's identifiers into a second statement grows with the page and may need chunking; a remedy that re-derives the page inside a subquery does not, but pays for the parent query twice. 3. **Payload width.** A wide parent repeated once per child row moves far more bytes than the same data fetched separately, even though it costs one fewer round trip. 4. **Reuse.** Data read once per request argues for a fetch change; the same small reference data read on every request across many users argues for a cache. ## Remedies are chosen per read, not per link The same link can be right to join in one screen, batch in another, and ignore entirely in a third. That is why the remedy usually lives with the read that needs it — a fetch plan on that query, or a purpose-built projection — rather than being burned into the mapping as a global default. A global default has to be right for every read at once, and the reads disagree. ## Two things a remedy does not do - **It does not make an unnecessary read necessary.** If the screen never shows the children, the fix is to stop touching the link, not to fetch it more cleverly. - **It does not prove itself.** The only evidence that a remedy worked is the statement count for that code path before and after; a change that feels faster on a small fixture may simply not have enough rows to multiply. A useful default posture: keep links deferred, decide the loading per read, reach for a join when the link is to-one and narrow, reach for a bulk second statement when it is a collection, and reach for a projection when the read only ever needed a few columns.

  • Is removing the per-row statements always a win?
    Not automatically. Folding a collection into the parents' statement can move far more bytes than the round trips it saves, especially when the parent row is wide. The win is real when the round trips dominate; measure the statement count and the volume returned, not just the count.
  • What is the cheapest remedy of all?
    Not loading the link. Plenty of per-row statements come from code that touches a link nobody displays — a serialiser walking the whole graph, a helper computing a value that the response ignores. Removing that touch costs nothing and cannot go stale.

Restocking a shelf: you can carry everything in one overloaded trip, make a second trip with a full list, take only the items on today's order, or keep a small stash by the shelf. Which is best depends on how much there is and how often you need it.

saying these in an interview costs you the question

  • Says the fix is always to make the link load eagerly
  • Treats a join fetch as correct for every link regardless of cardinality
  • Thinks a cache is the first remedy for any slow list read
  • Believes fewer statements is always faster, whatever the payload
  • Forgets that the read may not need the linked data at all
open as a page

For a paged list read, how does link cardinality decide between a join fetch and a batch fetch?

level: middleimportance: must knowfreq 64%

basics

~20 s

A to-one link only adds columns, so a join fetch usually fits. A to-many link adds rows and interacts badly with paging and with a second collection, so a bulk statement covering the whole page usually fits better.

open as a page

When a list endpoint needs only a few fields from a widely reused graph, why might a projection or a cache beat any fetch-plan change?

level: seniorimportance: should knowfreq 54%

basics

~20 s

A fetch-plan change still loads whole objects into the tracked set for every row. A projection asks only for the columns the response shows, and a cache removes the statement entirely for data read far more often than it changes.

open as a page

Should an N+1 fix change the shared mapping default or only the read that triggered it?

level: principalimportance: should knowfreq 50%

basics

~20 s

Default to fixing the read. A mapping change applies to every read of that type, so it trades one screen's extra statements for over-fetching everywhere else. Change the mapping only when every read genuinely needs the link.

open as a page