better-drizzle
Plugins

Cache keys and invalidation

How the cache plugin builds keys, which versions an entry depends on, and what each write invalidates.

Read this when you need to predict whether a read hits, why a write invalidated an entry, or how to invalidate by hand. Setup and options are on the cache page.

How a cached read works

For each read the plugin decides to cache:

  1. Bypass check. Reads inside a transaction, reads with lock, and reads using automatic keys whose arguments or vary cannot be hashed go straight to the database.
  2. Key and dependencies. The plugin computes the entry key (below) and the list of versions the result depends on (below).
  3. Lookup and version initialization. The store fetches the entry and dependency versions. Missing versions receive fresh random tokens, stored before SQL runs. The Redis store uses one MGET outside cluster mode and concurrent individual GETs in cluster mode.
  4. Hit. The entry exists and every required version it stored matches the current one. The plugin deserializes the result and returns it without SQL.
  5. Miss. The entry is missing or a required version differs. The query runs, the result is serialized, and the plugin stores it together with the versions established in step 3 using SET ... EX ttl. Unsupported results are returned without being stored.

A hit with existing versions needs one store lookup and no SQL. A miss adds the query and one entry SET; missing versions add an earlier batch of SETs. A write adds a batch of version SETs after SQL, or after commit in a transaction. The Redis adapter sends each batch as concurrent commands; network round trips depend on the client and topology.

Identical reads that miss with the same dependency versions in the same plugin instance share one query. When the result can be serialized, waiting readers get their own deserialized copies; otherwise they run their own queries.

How the key is built

With cache: true you never pick a key. The plugin hashes the read and stores the entry at:

<namespace>:<version>:q:<sha256 of the read>
better-drizzle:1:q:Vb3V2d0l5aF5q0Y1rN8uZg0N7yH3cJm1eQx2sWkLpTo   # example

The hash covers, in this order:

  1. the model's schema key (users)
  2. the operation (findMany, count, paginate, ...)
  3. the final arguments, after every plugin before hook and transform has run, except cache and meta
  4. the call's tags and vary
  5. the value returned by the plugin's vary option

Arguments are normalized before hashing:

  • Query option keys and outer where field names are sorted, so { where: { a, b } } and { where: { b, a } } share an entry. orderBy object keys retain their priority, including in nested relation reads: { name: 'asc', id: 'asc' } differs from { id: 'asc', name: 'asc' }.
  • Array order counts: orderBy: [{ name: 'asc' }, { id: 'asc' }] and the reverse are different reads, and so are AND members in a different order.
  • Object order inside filter values is preserved: SQLite JSON text comparisons can distinguish differently ordered documents. Some equivalent compound filters can therefore use different entries.
  • undefined properties are dropped, so { take: undefined } is the same read as omitting take.
  • Types count: where: { id: 1 } and where: { id: 1n } are different keys. Dates hash by their timestamp.
These two callsSame entry?Why
findMany({ where: { a: 1, b: 2 } }) / findMany({ where: { b: 2, a: 1 } })yesobject keys are sorted
findMany({ cache: true }) / findMany({ cache: { ttl: '1h' } })yesttl, negativeTtl, refresh, and afterHooks are not part of the key
findMany({ meta: { requestId: 'a' } }) / findMany({ meta: { requestId: 'b' } })yesmeta is not part of the key
findMany() / findFirst()nodifferent operation
findMany({ take: 10 }) / findMany({ take: 20 })nodifferent arguments
findMany({ select: { id: true } }) / findMany()nodifferent projection
findMany({ cache: { tags: ['a'] } }) / findMany({ cache: true })notags are part of the key
findMany({ deleted: 'with' }) / findMany() with soft deletenoplugin arguments and the filters they add are part of the key
the same read from two $tenant() clientsnoactive model scope values are part of the key

Arguments added by other plugins (deleted from soft delete, validate from zod) and filters injected by transforms are part of the final arguments. Active model scopes, including tenant values, are keyed separately even when the query arguments are identical.

Some values cannot be hashed reliably: cyclic values, raw sql fragments, functions, and class instances other than Date, Buffer, and Uint8Array. With automatic keys, a read whose arguments or vary contain one is not cached, and after hooks see annotations.cache as 'bypass'.

Varying by request context

meta stays out of the key on purpose: a request id or trace id in meta would give every call its own entry. When something outside the arguments decides what a reader may see, return it from vary. The value is hashed into every automatic key.

cache({
	store,
	ttl: '5m',
	vary: ({ meta }) => (meta as { tenantId?: string } | undefined)?.tenantId,
});

await client.$withContext({ tenantId }).users.findMany({ cache: true });

vary receives { meta, state, model, operation, transactionContext } and may return any hashable value: a string, a number, an array, or a plain object. The tenant plugin does not need vary; its active scope values are already part of the key. Use vary when a locale, role, database session, or a tenant mechanism outside model scopes changes the result without changing the arguments.

A single call can add its own value with cache: { vary: locale }.

Custom keys

cache: 'newest-user' or cache: { key: 'newest-user' } replaces the hash with your key:

<namespace>:<version>:k:newest-user

On a scoped client, the key also gets a hash suffix of the active scopes, so two tenants using newest-user cannot share an entry. Its model and relation dependencies still come from the query, so a write to users still invalidates it. Use a custom key when you need to invalidate entries with $cache.invalidate({ keys: ['newest-user'] }); that call invalidates every scope variant of the key and changes their shared custom-key version, so an older in-flight query cannot repopulate a valid entry. You are responsible for uniqueness within each scope: two different queries that use the same custom key overwrite each other, and vary does not apply to them.

What an entry depends on

The plugin never searches Redis for keys to delete. Instead, every entry stores the versions it was computed from, and a write replaces those versions with new random values. An entry whose required stored versions no longer match stops being a hit and expires through its TTL. Missing versions are initialized before queries run, so version expiry or eviction cannot revive an old entry by matching a missing-value sentinel.

Version keyReplaced byRead depends on it when
v:all$cache.clear()always
v:m:<model>:epochwrites whose target rows are unknown, primary key changes, cascades, invalidate({ models })the read touches the model at all
v:m:<model>:rowsevery write to the model, invalidate({ models })the read is a list, count, or page, or its result is empty
v:e:<model>:<primary key>writes whose where pins that primary keythe read's where pins that primary key
v:t:<tag>invalidate({ tags }), write and raw cache.invalidate.tagsthe read has the tag
v:k:<key>invalidate({ keys })the read uses that custom key

<model> is the schema key (users), not the database table name. Use the same key in models options and invalidation hints.

"The read touches the model" covers the root model and every model reached through include, a relation select, _count, or a relation filter in where such as { posts: { some: { published: true } } }. .through() relations also depend on the junction table.

A where pins a primary key when its top level, or one of its top-level AND members, sets every primary key column to a value ({ id: 1 } or { id: { equals: 1 } }). Other conditions next to it may narrow the result further, and the read still depends only on that row.

A worked example

await client.users.findUnique({ where: { id: 1 }, include: { posts: true }, cache: true });
// depends on: all, users epoch, user 1, posts epoch, posts rows
// (and users rows, but only while the cached result is null)
await client.users.findMany({ where: { active: true }, cache: true });
// depends on: all, users epoch, users rows

await client.users.update({ where: { id: 2 }, data: { name: 'Grace' } });
// replaces: user 2, users rows
// -> the findUnique for user 1 is still a hit; the findMany misses

await client.posts.create({ data: { authorId: 1, title: 'Hello' } });
// replaces: posts rows
// -> the findUnique with its posts misses; the findMany is still a hit

await client.users.updateMany({ where: { active: false }, data: { active: true } });
// target rows unknown: replaces users epoch and users rows
// -> every cached read that touches users misses

What each write invalidates

Mutations that return no matched rows skip automatic invalidation. Explicit cache.invalidate hints still apply.

WriteReplaces
create, createManymodel rows
update, delete, upsert whose where pins primary keys, or uses id: { in: [...] }those rows' versions and model rows
update, updateMany, delete, deleteMany, upsert with any other wheremodel epoch and rows
updateEach keyed (by) on the primary keythose rows' versions and model rows. Any other by replaces the epoch
upsertMany whose target is the primary key and whose rows all carry itthose rows' versions and model rows. Anything else replaces the epoch
connect, disconnect, set in datathe related model's epoch and its junction table's epoch
a write that changes a primary key, including bulk upsertsmodel epoch, plus epochs reachable through declared foreign-key dependencies
a write that changes another column referenced by a declared relationepochs reachable through declared foreign-key dependencies
delete, deleteManyalso epochs reachable through declared foreign-key dependencies, including transitive, inverse, and self relations, since the database may cascade or set them to NULL

Creates only replace model rows because a new row cannot change a cached row that already exists. It can change lists, counts, pages, and empty lookups. This is why a primary key lookup that returned null also depends on the model rows version: creating that row invalidates the cached null.

Invalidating by hand

Writes and raw SQL can invalidate more than the plugin infers:

await client.users.create({ data, cache: { invalidate: { tags: ['feed'] } } });

await client.$executeRaw(sql`UPDATE users SET active = false`, {
	cache: { invalidate: { models: ['users'] } },
});

await client.$cache.invalidate({ models: ['users'], tags: ['feed'], keys: ['newest-user'] });
await client.$cache.clear(); // every entry under this namespace and version

Hints on writes and raw calls apply after the call succeeds, or after the commit inside a transaction. $cache.invalidate() and $cache.clear() apply immediately and throw a CACHE_STORE_ERROR when the store fails.

Writes the plugin cannot see

Raw SQL without a cache.invalidate hint, raw Drizzle calls on db, other services, and trigger effects beyond declared dependencies are not inferred. Call $cache.invalidate() after them, or rely on the TTL.

On this page