skip to content

How do you key a DataLoader batch when the value depends on more than one input?

level: middleimportance: should knowfreq 48%

answer

  1. One input is not always enough
  2. Two identical keys, two separate objects
  3. Something must turn a key into a primitive
  4. Every field that changes the value
  5. Colliding keys do not raise

basics

~10 s

Use a composite key holding every input that changes the value, and give the loader a cache-key function that projects that object or tuple onto a stable primitive string so structurally identical keys deduplicate.

solid answer

~50 s

When a field's value depends on a parent id **and** something else — an as-of date, a currency, a statement period — the key becomes a tuple or small object carrying all of it. That creates a second problem: most runtimes compare objects by reference, so two structurally identical keys look distinct and the loader's per-key memoization stops firing. The remedy is a **cache-key function** that turns each key into a primitive the loader can compare, typically a canonical string. It must be *total* — every field that can change the value goes in — *deterministic*, with stable field order and normalized formatting, and *injective*, so two different keys never produce the same string. A function that drops or over-normalizes a field is worse than no loader: two distinct loads collide and one field silently returns the other's value. None of this is specified; it is loader convention.

code

graphql · 6 lines
graphql
query Reconcile($id: ID!) {
  account(id: $id) {
    quarterEnd: balance(asOf: "2026-03-31")
    today:      balance(asOf: "2026-09-03")
  }
}

go deeper

for a junior

Recall that a key can be more than an id, and that a key made of several parts needs a way to be compared by content. Knowing the phrase 'cache-key function' and what it produces is enough at this level.

for a middle

Explain the mechanics: reference comparison, what the loader's per-key map needs, and the three requirements on the projection — include every value-affecting field, produce a canonical string, and never let two different keys collide.

for a senior

Demonstrate the diagnosis. A collision returns a plausible wrong value only when one request loads both keys, so single-selection tests stay green; be able to describe how you would reproduce it and where you would assert on it.

for a principal

Set the house convention for key encoding and review, and decide the tradeoff you want defaulted: an over-specific key costs throughput, a colliding key costs correctness, and only one of those is safe to discover in production.

## When one input stops being enough Plenty of fields are keyed by a single identifier, and for those the key is just that id. The interesting cases are the ones where it is not: * `Account.balance(asOf: Date!)` — the value depends on the account **and** the date. * `Account.statements(period: StatementPeriod!)` — account **and** period. * A conversion rate on a transaction — source currency, target currency **and** the value date. For these the key becomes a **composite key**: a tuple or a small object carrying every input that can change the answer. That much is straightforward. What surprises people is the second-order problem it creates. ## Why a composite key breaks deduplication Inside one request the loader keeps a map from key to the in-flight or completed value. That is what makes repeated `load` calls for the same thing collapse into one backend fetch. A map needs an equality test, and in most host runtimes an object or an array compares by **reference**. Two keys built independently — same account, same date, different allocations — are not equal. So: * the memoization never hits; * the batch carries duplicate work; * any operation that addresses a key by value, such as priming or clearing one after a write, silently misses. Nothing errors. The graph is correct and slower, which is the hardest class of defect to notice. ## The cache-key function The conventional fix, offered by essentially every implementation of the pattern, is a **cache-key function**: you hand the loader a function that maps a key to a primitive, and the loader keys its map by that primitive instead of by the object. ```pseudocode cacheKeyFn(key) = key.accountId + "\u001f" + isoDate(key.asOf) ``` Three properties make it correct. **Total.** Every field that can change the returned value must appear. This is the sufficiency rule from key design, now enforced one layer down: if the key carries the date but the cache-key function drops it, you have re-created the missing-input bug with an extra place to hide it. **Deterministic and canonical.** The same logical key must always produce the same string, no matter how the object was built. That means fixed field order (not "whatever order the object was constructed in"), a normalized date representation rather than a locale-dependent one, and normalization of case or whitespace *only* where the backend genuinely treats the values as equivalent. **Injective.** Two different keys must never produce the same string. The classic breach is naive concatenation with a separator that can occur inside the values: joining with `-` collides `("A-44", "71")` with `("A", "44-71")`. Use a delimiter that cannot appear in any component, or a length-prefixed or canonical-JSON encoding with sorted keys. It also runs on the hot path — once per `load` call, potentially thousands of times in one request — so it should be cheap. String building is fine; hashing large structures or serializing an entire parent object is not. ## The failure this produces, told properly A four-person platform team ships a bank statements graph. `Account.balance(asOf:)` gets a composite key, and the cache-key function is written as just the account id, because in the first screen that used it `asOf` was always today, and the author reasoned the date "was constant anyway". Months later a reconciliation screen ships one document that selects the same account's balance twice, aliased, at two different dates: ```graphql query Reconcile($id: ID!) { account(id: $id) { quarterEnd: balance(asOf: "2026-03-31") today: balance(asOf: "2026-09-03") } } ``` Both loads produce the same cache key. The second one never reaches the backend; it receives the first one's already-loaded value. The response is well-formed, both fields are non-null, and one of them reports a stale figure — the quarter-end number labelled as today. It gets triaged as a caching bug somewhere else entirely, because the tell is subtle: it only reproduces when **one request** selects the field twice, so hitting each screen alone looks perfect and a test with a single selection is green. The lesson is that a collision in a cache-key function does not raise — it returns the wrong value confidently. That asymmetry is why the injectivity requirement is worth stating out loud in an interview: an over-eager key is a correctness bug, while a key that is too specific is only a performance loss. ## Two shapes people reach for instead **Encode the composite as a single string key up front.** Then the key *is* a primitive and no cache-key function is needed — but the batch function must parse it back apart, and every parse is a chance to disagree with the encoder. Reasonable for two stable components, unpleasant beyond that. **Keep the key simple and move the extra input onto the loader instance** — one loader per argument shape, built lazily in the request context. This trades key complexity for loader proliferation and matters most when the extra input would otherwise fragment the batch. None of this is in any specification. Composite keys and cache-key functions are conventions of the batching pattern, and their exact spelling differs per implementation; what transfers between implementations is the three requirements.

  • What is the failure mode of a cache-key function that is too specific rather than too loose?
    You lose deduplication, not correctness. Including a field that cannot change the value — a timestamp captured at load time, say — makes every key unique, so repeated loads for the same thing each reach the backend and the per-request memoization is dead weight. It is a performance regression, which is recoverable; a collision is a wrong-value bug, which is not.
  • Why is naive string concatenation a risky way to build a composite cache key?
    Because the delimiter can occur inside the components, and then two different keys serialize identically — joining with a hyphen makes account `A-44` with suffix `71` indistinguishable from account `A` with suffix `44-71`. Use a delimiter that cannot appear in any component, or a canonical encoding with fixed field order and length or quoting rules.
  • Is the cache-key function defined anywhere in the GraphQL specification?
    No. The GraphQL specification says nothing about batching, loaders or keys; the DataLoader pattern is a convention and the cache-key function is a facility that loader implementations offer. The three requirements — total, canonical, injective — are properties of correct key design that transfer between implementations, not specified rules.

Two order slips written on separate pieces of paper are still two orders unless someone reads them and files them by what they say — and if the filing clerk ignores the date line, yesterday's order gets handed back for today's.

saying these in an interview costs you the question

  • Uses an object as a key and still expects deduplication
  • Writes a cache-key function that omits a value-affecting field
  • Joins key parts with a separator the values can contain
  • Says the specification defines a cache-key function
  • Serializes a key with unstable field ordering
  • Thinks a colliding key raises rather than returning wrong data

context