skip to content

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