# Cache keys and invalidation

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

URL: https://better-drizzle.com/docs/plugins/cache-keys

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](/docs/plugins/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](#how-the-key-is-built)) and the list of versions the result depends on ([below](#what-an-entry-depends-on)).
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 `GET`s 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 `SET`s. A write adds a batch of version `SET`s 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:

```txt
<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 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.

```ts
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](/docs/plugins/tenant) 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:

```txt
<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 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

```ts
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.

| 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:

```ts
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.
