Batch reads need a result contract
Replacing a loop of primary-key queries with one ANY query removes round
trips. It can also change the response shape. A caller might ask for
[42, 7, 42, NULL, -1] and expect five result positions. SQL set semantics do
not promise that alignment.
A set of rows is not a list of answers
With WHERE id = ANY($1::bigint[]), a duplicate ID normally matches its table
row once. A missing ID contributes no row. An input NULL does not match a
non-null primary key, and the result has no guaranteed input order. Adding
ORDER BY id sorts by key; it still does not reproduce the requested positions.
If consumers need a set, that is fine. If they need a result for every input, make the positions part of the query or restore them in the application.
Keep positions explicit in SQL
Start the local demo and run this in its psql session:
WITH requested AS (
SELECT key, position
FROM unnest(ARRAY[42, 7, 42, NULL, -1]::bigint[])
WITH ORDINALITY AS input(key, position)
)
SELECT requested.position,
requested.key,
CASE WHEN items.id IS NULL THEN NULL
ELSE row_to_json(items)::text END AS row
FROM requested
LEFT JOIN public.items AS items ON items.id = requested.key
ORDER BY requested.position;
The ordinality column distinguishes both occurrences of 42. The left join
retains all five positions, including the null input and any absent key.
For a missing key, row is SQL NULL. In application code, pass the array as a
parameter rather than concatenating IDs into SQL. The
Node.js example demonstrates client-side alignment.
Compare the whole-row API
On an attached table, the corresponding explicit cache call is:
SELECT local_cache.mget(
'public.items'::regclass,
ARRAY[42, 7, 42, NULL, -1]::bigint[]
) AS rows;
It returns text[], with input order and duplicates preserved. Missing keys
and input nulls produce aligned SQL NULL elements; each present element is a
serialized complete row. A cache miss or bypass reads PostgreSQL. The API
accepts at most 1,024 keys per call. It is not a replacement for projections,
joins, row locks or arbitrary query-result caching.
Keep batching bounded and observable
For more than 1,024 keys, split requests explicitly or retain an ordinary SQL
query. Chunking across statements can observe different READ COMMITTED
snapshots; choose transaction semantics deliberately. Larger batches also
increase response size and client decoding work, so “fewer queries” alone does
not prove a faster request.
For GraphQL, a DataLoader batch function must return one answer per input key in the same order. Request-local memoization and PostgreSQL’s shared cache are separate layers; clear affected loader entries after mutations. See the full batching guide and compare latency, payloads and throughput with the benchmark runner.