skip to content

How do you return DataLoader batch results when the bulk lookup loses the key order?

level: middleimportance: should knowfreq 54%

answer

  1. Row order is not a promise
  2. The count can match and still be wrong
  3. Which list do you iterate over?
  4. Build a map, then walk the keys
  5. Test with three keys, one absent, shuffled

basics

~20 s

Never return the rows as they arrived. Index them into a map keyed by the lookup key, then walk the original key list in order, emitting each key's mapped row or null when the map has nothing.

solid answer

~50 s

A bulk lookup — an `IN` list, a multi-get, an upstream bulk endpoint — makes no promise to return rows in the order of the identifiers you sent, and returns nothing at all for identifiers it did not find. Since the loader aligns values to keys **by position**, returning rows verbatim produces two bugs: missing rows shorten the list and shift every later value forward, and reordered rows attach every value to the wrong key while the count still looks right. The fix is one shape, always: index the rows by key into a map, then build the result by iterating the *key list*, emitting `byKey[k]` or the appropriate empty value. The output is then the same length and correctly aligned by construction. A to-one field gets `null` for an absent key; a to-many field gets an empty list, so a non-null list type stays satisfiable.

code

pseudocode · 18 lines
pseudocode
# WRONG: trusts the store's row order and row count
function loadMenuItems_broken(keys):
    return catalogue.findAllByIds(keys).rows

# RIGHT: rebuild from the key list
function loadMenuItems(keys):
    rows = catalogue.findAllByIds(keys).rows
    byId = {}
    for r in rows:
        byId[normalizeKey(r.id)] = r          # key types must match exactly
    return [ byId.get(normalizeKey(k), null) for k in keys ]

# to-many field: the empty value is a list, not null
function loadLinesByOrder(orderIds):
    grouped = {}
    for row in lines.findAllByOrderIds(orderIds).rows:
        grouped.setdefault(row.orderId, []).append(row)
    return [ grouped.get(oid, []) for oid in orderIds ]

go deeper

for a junior

Remember the rule as a shape you can write from memory: build a map from the rows, then produce one value per key by walking the key list. Never hand the query's result straight back.

for a middle

Explain why a bulk lookup gives no ordering guarantee and why a matching row count is not evidence of correct alignment. Be ready to name what fills the slot for an absent key on a to-one versus a to-many field.

for a senior

Diagnose it from the symptom — plausible data attached to the wrong parents, or holes that appear only at scale — and connect it to the query planner rather than to the loader. Show the test that closes the class: several keys, one absent, rows shuffled.

for a principal

Treat the mapping step as infrastructure, not per-service code: one shared helper that takes a key list and rows and returns an aligned result, so correctness stops depending on each author remembering the invariant, and a review can spot a batch function that does not use it.

## The failure this prevents You add a loader behind `OrderLine.menuItem`, the request count collapses from 1,137 to 1, the latency graph looks wonderful — and the response comes back half empty, with `menuItem: null` on hundreds of lines and, worse, a handful of lines showing the wrong item's name and price. Nothing errored. Nothing logged. The schema is unchanged. What broke is the alignment between the batch function's key list and the value list it returned. ## Why the rows come back scrambled A batch function is handed a list of keys and typically turns it into one bulk lookup — an `IN` list against a table, a multi-get against a key-value store, a bulk endpoint on an upstream service. **None of those preserve the order of the identifiers you sent.** A relational engine is free to return rows in whatever order its plan produces: index order, physical order, the order of a hash-join probe. A multi-get may return only the keys it found. An upstream bulk endpoint may sort by its own primary key, or return a map. Row order is an artefact of the plan, not a promise, and it can change when statistics change, when the table grows past a threshold, or when an index is added — which is why this bug is famous for shipping green and surfacing weeks later on a real data volume. Two distinct things therefore go wrong if you return rows as they arrived: * **Missing rows shorten the list.** Ask for 214 ids, get 211 rows because three items were archived, and every value from the first gap onward slides one position toward the front. Positions after the gap are wrong. * **Reordered rows misalign the list** even when the count matches. Every value can be present and every single one attached to the wrong key. The second is the dangerous one, because there is no null to notice. A line for a side salad renders the price of a steak. ## The one correct shape Build the result **by iterating the keys**, never by iterating the rows: 1. Run the bulk lookup with the key list. 2. Index the returned rows into a map from key to row. 3. Walk the original key list in order, emitting the mapped row for each key, or the appropriate empty value when the map has nothing. ```pseudocode function loadMenuItems(keys): rows = catalogue.findAllByIds(keys) byId = {} for r in rows: byId[r.id] = r return [ byId.get(k, null) for k in keys ] ``` The output list is now the same length as `keys` by construction, and each element sits at its key's index by construction. There is no ordering assumption left to be violated. ## What goes in an empty slot That depends on the field's type, not on the loader: * A **to-one** field (`OrderLine.menuItem`) gets `null` — and if the schema declared that field non-null, the null is a field error that propagates, which is a signal you want rather than a silent hole. * A **to-many** field (`Order.lines`) gets an **empty list**. Returning `null` for a key with no children makes "this order has no lines" indistinguishable from "something went wrong", and breaks a non-null list type. The mapping step also has to match the key type. If the key list holds strings and the rows carry integer ids, the map lookup misses for every key and the whole page comes back null — the same symptom for a different reason, and worth checking before you suspect the database. ## Sorting is not a fix A tempting shortcut is to sort the rows by id and return them. It fails on both counts: the key list is not sorted (it is in the order fields were resolved), and a missing row still shifts everything after it. Another is adding an explicit ordering clause to the bulk lookup that reproduces the key order. Some engines can do it, the syntax is dialect-specific, and it makes correctness depend on a query detail a future optimisation may rewrite. The map-and-map-back step costs one pass over a few hundred rows; on an 8,400-row page it is irrelevant next to the lookup itself. Pay it. ## How to catch it in tests A test with one key passes under every one of these bugs — one row cannot be out of order and cannot be missing without you noticing. Write the batch function's test with at least three keys, one of which has no matching row, and shuffle the rows the fake store returns. Assert on the returned list's length and on the value at each index, not merely on the set of values present. That is a five-line test that permanently closes the entire class of defect.

  • Why can this bug pass every test and only appear on a large page in production?
    Because a one-key or one-row test cannot expose it: a single row is trivially in order and trivially present. Row order is also an artefact of the query plan, so a small table scanned in insertion order looks correct until statistics change, an index is added, or the page grows to 8,400 rows and the planner switches strategy. Test with several keys, one of them absent, and shuffle the rows the fake store returns.
  • Rather than mapping in code, why not add an ordering clause to the bulk lookup that reproduces the key order?
    Some engines can express it, but it makes correctness depend on dialect-specific syntax and on a query detail a later optimisation may rewrite, and it still does nothing about keys with no row — those simply do not appear, so the list is short. The map-and-walk step is a single pass over a few hundred rows and is negligible next to the lookup itself. Keep the invariant in code where it is testable.
  • A page comes back with every nested object null even though the lookup clearly returned rows. What do you check?
    Key type mismatch in the map step. If the key list holds strings and the rows carry integer identifiers, every map lookup misses and the batch function faithfully returns a full-length list of nulls. Normalise both sides to one representation before indexing. It is the same symptom as an ordering bug for a completely different reason, and it is cheaper to rule out first than to go looking at the database.

It is the difference between handing out a stack of passports by counting down the queue and handing them out by reading the names: only one of the two survives someone stepping out of line.

saying these in an interview costs you the question

  • Assuming rows come back in the order of the IN list
  • Returning the query result directly from the batch function
  • Dropping missing keys instead of leaving a null slot
  • Sorting rows by id and hoping the keys line up
  • Testing the batch function with a single key
  • Returning null for a to-many key with no children

context