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:
- Bypass check. Reads inside a transaction, reads with
lock, and reads using automatic keys whose arguments orvarycannot be hashed go straight to the database. - Key and dependencies. The plugin computes the entry key (below) and the list of versions the result depends on (below).
- 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
MGEToutside cluster mode and concurrent individualGETs in cluster mode. - Hit. The entry exists and every required version it stored matches the current one. The plugin deserializes the result and returns it without SQL.
- 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 # exampleThe hash covers, in this order:
- the model's schema key (
users) - the operation (
findMany,count,paginate, ...) - the final arguments, after every plugin before hook and transform has run, except
cacheandmeta - the call's
tagsandvary - the value returned by the plugin's
varyoption
Arguments are normalized before hashing:
- Query option keys and outer
wherefield names are sorted, so{ where: { a, b } }and{ where: { b, a } }share an entry.orderByobject 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 areANDmembers 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.
undefinedproperties are dropped, so{ take: undefined }is the same read as omittingtake.- Types count:
where: { id: 1 }andwhere: { id: 1n }are different keys. Dates hash by their timestamp.
| These two calls | Same entry? | Why |
|---|---|---|
findMany({ where: { a: 1, b: 2 } }) / findMany({ where: { b: 2, a: 1 } }) | yes | object keys are sorted |
findMany({ cache: true }) / findMany({ cache: { ttl: '1h' } }) | yes | ttl, negativeTtl, refresh, and afterHooks are not part of the key |
findMany({ meta: { requestId: 'a' } }) / findMany({ meta: { requestId: 'b' } }) | yes | meta is not part of the key |
findMany() / findFirst() | no | different operation |
findMany({ take: 10 }) / findMany({ take: 20 }) | no | different arguments |
findMany({ select: { id: true } }) / findMany() | no | different projection |
findMany({ cache: { tags: ['a'] } }) / findMany({ cache: true }) | no | tags are part of the key |
findMany({ deleted: 'with' }) / findMany() with soft delete | no | plugin arguments and the filters they add are part of the key |
the same read from two $tenant() clients | no | active 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-userOn 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 key | Replaced by | Read depends on it when |
|---|---|---|
v:all | $cache.clear() | always |
v:m:<model>:epoch | writes whose target rows are unknown, primary key changes, cascades, invalidate({ models }) | the read touches the model at all |
v:m:<model>:rows | every 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 key | the read's where pins that primary key |
v:t:<tag> | invalidate({ tags }), write and raw cache.invalidate.tags | the 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 missesWhat each write invalidates
Mutations that return no matched rows skip automatic invalidation. Explicit cache.invalidate hints still apply.
| Write | Replaces |
|---|---|
create, createMany | model 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 where | model epoch and rows |
updateEach keyed (by) on the primary key | those rows' versions and model rows. Any other by replaces the epoch |
upsertMany whose target is the primary key and whose rows all carry it | those rows' versions and model rows. Anything else replaces the epoch |
connect, disconnect, set in data | the related model's epoch and its junction table's epoch |
| a write that changes a primary key, including bulk upserts | model epoch, plus epochs reachable through declared foreign-key dependencies |
| a write that changes another column referenced by a declared relation | epochs reachable through declared foreign-key dependencies |
delete, deleteMany | also 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 versionHints 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.