skip to content

Batch & Subquery Fetching

Filling many deferred links in a few statements: loading in identifier batches, or re-running a query's predicate as a subquery. Asked because it is the middle path most candidates never reach for.

on this pageshow

questions

5

What does it mean for a data-access layer to fill deferred links in identifier batches rather than one at a time?

level: juniorimportance: must knowfreq 52%

answer

  1. fewer statements, not zero statements
  2. one touch pays for many stand-ins
  3. keys gathered from the unit of work
  4. a set-membership predicate over identifiers
  5. N loads become N over batch size

basics

~20 s

Rather than one statement per touched stand-in, the layer gathers the keys of other unfilled stand-ins of the same kind and fetches them together with a set-membership predicate, turning N secondary statements into roughly N divided by the batch size.

solid answer

~40 s

A deferred link is filled on first touch, so a loop over many owners normally fires one small statement per owner. With identifier batching the layer, at the moment it has to fill one stand-in, looks at the other unfilled stand-ins of the same kind already registered in the current unit of work, takes up to a configured number of their keys, and fetches them all in one statement whose predicate is `... where id in (?, ?, ?, ...)`. Each returned row is matched back to its stand-in by key, so every link in that batch is now filled and costs nothing on the next touch. It does not remove the secondary statements; it divides their number by the batch size.

go deeper

for a junior

Recall the shape: one touch, one statement, many links filled. Be able to say that the statement uses a set-membership predicate over keys and that the count of statements drops rather than reaching zero.

for a middle

Explain where the keys come from — the unfilled stand-ins of the same kind in the current unit of work — and that rows are matched back by key, not by position. Name the over-fetch cost.

for a senior

Show when the trade turns bad: narrow children and a loop that touches everything is a clear win, wide children touched once is not. Reason about round trips versus rows transferred, out loud.

for a principal

Frame it as a per-link decision with a measurable payoff, not a global switch, and note that the size interacts with page sizes and with how many differently shaped statements the system emits.

## The situation batching is answering A **deferred link** is an association the data-access layer chose not to fill when it loaded the owning row. In its place it puts a **stand-in**: an object that carries the key and knows how to fetch the rest the first time anything touches it. That is what keeps the first read cheap and the object graph finite. The cost is deferred, not removed. A loop over fifty loaded owners that touches one deferred link each will fill fifty stand-ins, and if each fill is its own statement, that is fifty round trips to satisfy one screen. Batch fetching is the middle path between that and pulling the whole graph in the first statement. It keeps the loading deferred, and it keeps the extra statements — it just makes there be very few of them. ## What the layer actually does 1. Something touches a stand-in, so the layer must now fill it. 2. Before emitting anything, it inspects the other stand-ins **of the same kind** that are registered in the current unit of work and still unfilled. 3. It collects up to a configured maximum number of their keys — the **batch size** — including the key of the one that was touched. 4. It emits a single statement whose predicate is a set-membership test over those keys: `select ... from owner where id in (?, ?, ?, ?)`. 5. It routes each returned row to the stand-in that owns that key. Every stand-in in the batch is now filled. The touch that triggered the load pays for the whole batch. The next several touches in the loop find their objects already filled and emit nothing at all; only when the loop reaches a stand-in that was outside the batch does another statement fire. Fifty pending links at a batch size of ten produce five statements instead of fifty. ## Two things get batched, and they emit different statements | What is being filled | Statement the batch emits | What comes back | |---|---|---| | Stand-ins for single-valued links (the key sits on the owner row) | `select ... from target where id in (...)` | At most one row per key; a missing key means that link is genuinely absent | | Deferred collections (the key sits on the child rows) | `select ... from child where owner_id in (...)` | Many rows per key, filed under their owner; an owner with no children ends up with an empty collection | The collection case is the one that matters most in practice, because it is the case where the per-owner alternative is a statement per owner per screen. ## What it requires, and what it does not change - It needs the **keys already in hand**. Batching can only include stand-ins the unit of work already knows about; it cannot guess at links that have not been loaded or registered yet. - It needs the objects to be filled **in the same unit of work**. Once the owners are detached from the unit of work that loaded them, there is no shared registry to batch against. - It is **opportunistic, not a plan**. Nothing about the first query changes; the driving statement keeps the shape you wrote, with its own ordering and its own row count. - It can **over-fetch**. The batch fills links nobody has asked for yet. That is usually a win, because they are about to be asked for, and a waste when they are not. - The rows come back in whatever order the engine likes; the layer matches by key rather than by position, so the returned order carries no meaning. ## The partly filled last batch The pending keys rarely divide evenly by the batch size. The final statement of a run therefore covers fewer keys than the maximum, and layers differ in how they handle that: some simply emit a shorter list, some round down to one of a few pre-agreed list lengths, and some pad the list by repeating a key so that a small number of statement shapes recur. Which one a layer chooses is a detail of that layer, not a property of batching. ## How to talk about it in an interview Say what it is (fewer statements, not zero), say what triggers it (a touch, plus the other pending stand-ins of the same kind), say what it emits (one set-membership predicate over keys), and say what it costs (some links filled that were never needed, and a statement whose parameter list varies with how many keys were pending). That is the whole mechanism, and it is the same mechanism whichever data-access layer you have used.

  • If the batch fills links nobody ends up touching, has the batch made things worse?
    Sometimes. Batching trades wasted rows for saved round trips, and the trade is good whenever the extra rows are narrow and the links usually are touched. On a path that touches one owner's link out of a page of fifty, the batch fetches forty-nine sets of rows for nothing, and a smaller size or an explicit single load is the better call.
  • Why can a batch not be assembled for owners that were loaded in an earlier, already-finished unit of work?
    Batching works off the registry of stand-ins the current unit of work is holding. Objects that left that unit of work are detached: their unfilled links have no live registry to be gathered into a shared statement, and touching one is either an error or a fresh single load, depending on the layer.
  • Does batching change the driving query at all?
    No. The driving query executes exactly as written, with its own predicate, ordering and row count, and the batches are separate statements issued afterwards. That is precisely why batching is safe to switch on for a paged screen: the page is still the page you asked for.

It is the difference between walking to the storeroom once per item on the list and walking once with ten item numbers written down.

saying these in an interview costs you the question

  • Thinks batching removes the secondary statements instead of reducing their number
  • Believes the database, not the data-access layer, decides what goes in a batch
  • Assumes a larger batch size is always better
  • Thinks batching can fill links the unit of work has never registered
  • Confuses batching reads with sending many writes in one round trip
  • Expects batched rows to come back in the order the keys were listed
open as a page

What does it mean to fill every owner's collection by re-running the original query's predicate as a subquery?

level: middleimportance: must knowfreq 46%

basics

~20 s

The layer issues one extra statement per deferred collection, reading children whose owner key comes from a subquery repeating the driving query's predicate. Statement count stops depending on how many owners returned, but that predicate is evaluated twice.

open as a page

When a data-access layer loads deferred links in identifier batches, how is each batch assembled and what happens to the final, partly filled batch?

level: middleimportance: should knowfreq 38%

basics

~20 s

The layer takes keys from unfilled stand-ins of the same kind in the current unit of work, up to a configured maximum, and emits one set-membership statement. Leftover keys form a shorter final list, sent as-is, rounded, or padded.

open as a page

Why might filling owner collections with a few extra batched statements beat one joined fetch, and what does each cost?

level: seniorimportance: should knowfreq 41%

basics

~20 s

A joined fetch buys one round trip and pays by repeating every owner column once per child row. Batched secondary statements send each row once, keeping bytes near owners plus children, and pay in extra round trips.

open as a page

How would you choose the identifier-batch size for a service that fills deferred links in batches, and what would you measure to justify it?

level: principalimportance: should knowfreq 30%

basics

~20 s

Set it per access path, anchored to the page size that path returns, keeping the service to a few recurring sizes. Justify it with statements per request, rows fetched against rows touched, and tail latency before and after.

open as a page