# Tag invalidation (/docs/infrastructure/kv/tags)

---
title: Tag invalidation
description: Group KV keys under a tag and drop the whole group in one call. Every built-in adapter implements it, but the methods sit on the adapter rather than on ctx.kv, so reaching them takes one deliberate step.
kind: guide
package: questpie
---

Cache a rendered page under `page:/posts/hello` and its OG image under
`og:/posts/hello`, tag both `post:123`, and one call after an edit drops both.
That is the whole feature. It saves you from tracking, by hand, which derived
keys a record fed.

## The three methods

They are optional members of `KVAdapter`. All three built-in adapters implement
all three.

| Method                                | What it does                                          |
| ------------------------------------- | ----------------------------------------------------- |
| `setWithTags(key, value, tags, ttl?)` | Writes the value and files the key under each tag.    |
| `invalidateByTag(tag)`                | Deletes every key filed under that tag, then the tag. |
| `invalidateByTags(tags)`              | The same for several tags at once.                    |

`ttl` is in seconds, like everywhere else in KV.

## Reaching the adapter

`KVService` exposes `get`, `set`, `delete`, `has` and `clear` and nothing more,
so `ctx.kv.setWithTags` does not exist. The tag methods live one level down. The
reliable way to reach them is to keep the reference you built.

```ts title="src/questpie/server/questpie.config.ts"
import { runtimeConfig } from "questpie/app";
import { redisKVAdapter } from "questpie/adapters/redis-kv";

// getRedis returns a connected client, as on the Key-value store page
export const kvAdapter = redisKVAdapter({ client: getRedis });

export default runtimeConfig({ kv: { adapter: kvAdapter } });
```

```ts
import { kvAdapter } from "@/questpie/server/questpie.config";

await kvAdapter.setWithTags?.("page:/posts/hello", html, ["post:123"], 3600);
await kvAdapter.setWithTags?.("og:/posts/hello", ogImage, ["post:123"]);

// After an edit, one call drops both
await kvAdapter.invalidateByTag?.("post:123");
```

`app.config.kv?.adapter` reads back that same instance, since `app.config` is
your raw config object. The `?.` before each call is not defensive style,
TypeScript requires it because the three members are optional.

<Callout
	type="warn"
	title="On the zero-config default there is no adapter to read"
>
	Omit `kv.adapter` and `KVService` constructs `MemoryKVAdapter` internally,
	never writing it back. So `app.config.kv?.adapter` is `undefined` and every
	`?.` tag call above silently does nothing, even though `MemoryKVAdapter`
	implements all three methods. Construct the adapter yourself to use tags.
</Callout>

## What each backend stores

On Redis and Cloudflare the index is a second set of keys beside your values,
written under `tagIndexPrefix` (`"__questpie_tag:"` by default) after
`keyPrefix`. `MemoryKVAdapter` takes no options at all, so it keeps its index
off to one side instead.

| Adapter               | Tag index                                                 |
| --------------------- | --------------------------------------------------------- |
| `MemoryKVAdapter`     | A `Map` of tag to a `Set` of keys                         |
| `redisKVAdapter`      | One Redis SET per tag                                     |
| `cloudflareKVAdapter` | A JSON array per tag, since Cloudflare KV has no SET type |

`delete()` keeps the index honest on all three, dropping the key from every tag
that held it. On Redis that means a `SCAN` across the tag-index keys per delete,
which is worth knowing before you delete in a tight loop.

<Callout type="info" title="Tag indexes do not expire">
	No adapter puts a TTL on the index itself, and nothing prunes it when a value
	expires. A tag keeps listing keys that are already gone, so invalidating it
	still issues a delete for each one before dropping the tag. `clear()` removes
	the index along with the values.
</Callout>

## What tags do not do

`defaultTtl` does not apply. It is a `KVService` fallback, and `setWithTags` is
an adapter call that never passes through the service, so a `setWithTags`
without a `ttl` writes a value that never expires.

Neither is invalidation transactional. The deletes and the index cleanup go out
as separate commands with nothing rolling them back, so a failure part way
leaves some keys gone and some present. Treat a tag drop as a cache hint rather
than a guarantee, and let the next read recompute.

## Related

- [Key-value store](/docs/infrastructure/kv), the service, the adapters and the swap.
- [Hooks](/docs/schema/hooks), where to invalidate after a committed write.
