---
title: "Caching"
description: "How PgBeam caches query results at the edge, when it bypasses cache, and how to turn it on safely for your workload."
canonical: "https://pgbeam.com/docs/cache"
last-updated: "2026-10-29T19:01:20.000Z"
---

# Caching

> How PgBeam caches query results at the edge, when it bypasses cache, and how to turn it on safely for your workload.

URL: https://pgbeam.com/docs/cache

PgBeam caches query results at the data plane level. When a cached result is available, PgBeam returns it directly without touching the upstream database. This reduces upstream load, lowers read latency, and saves you money on database compute.

Each region keeps its own independent cache. There is no cross-region coherence layer. That is a deliberate tradeoff. Synchronizing caches across regions would add latency to every read, which defeats the purpose of caching.

> **Caching is opt-in**
>
> Caching starts **disabled** for new databases. This is the safe default. Turn
> it on after traffic is flowing and you know which reads are stable enough to
> benefit from caching.

## When caching helps

Caching is most effective when your workload has **repeated reads** that return the same data across many requests. Common examples:

* Product catalogs and category listings
* Configuration tables read on every request
* User profile lookups that change infrequently
* Leaderboards, stats, and aggregation queries that can tolerate staleness
* Reference data (countries, currencies, feature flags)

Caching is **not** a good fit for:

* Queries that must always return the latest data (e.g., account balances)
* Write-heavy workloads where data changes between reads
* Queries with highly variable parameters that produce unique results each time

## How caching works

### Query classification

PgBeam classifies each incoming query. Read statements (`SELECT`) are
candidates for caching. Writes (`INSERT`, `UPDATE`, `DELETE`), DDL
(`CREATE TABLE`, `ALTER`), session-changing commands (`SET`, `DISCARD`), and
queries with volatile functions (`NOW()`, `RANDOM()`, `pg_advisory_lock()`) are
never cached.

### Cache key generation

For cacheable queries, PgBeam generates a cache key from the **normalized SQL
text plus parameter values**, scoped to the project. Normalization means
whitespace and casing differences do not create separate cache entries.

The same query template with different parameter values produces different
cache entries:

```sql
-- These produce two separate cache entries
SELECT * FROM products WHERE id = 1;
SELECT * FROM products WHERE id = 2;
```

Session settings a result can depend on are part of the key too. A session
that runs `SET search_path`, `SET ROLE`, or sets any other session parameter
only shares entries with sessions that ran the same `SET`
statements in the same order, so a schema-per-tenant or role-per-tenant
application never reads another tenant's rows. Those entries are not
refreshed in the background. Timeouts, `application_name`,
`client_min_messages` and `pgbeam.*` do not count.

A setting changed somewhere PgBeam cannot read it turns the cache off for
the rest of the session instead. That covers `set_config()`, `DO` blocks,
and any call to a function that is not a known PostgreSQL built-in, such
as your own `set_tenant()`.

### Cache lookup

PgBeam checks the local cache and then the regional shared cache for a matching entry:

* **Hit**: A fresh cached result is returned immediately. The upstream
  database is never contacted.
* **Stale hit**: The cached result has passed its TTL but is within the
  stale-while-revalidate (SWR) window. PgBeam returns the stale result
  immediately and refreshes the cache in the background.
* **Miss**: No cached result exists. The query goes to the upstream database,
  and the result is stored in the cache for future requests.

## Cache defaults

| Setting         | Default  | What it controls                                                          |
| --------------- | -------- | ------------------------------------------------------------------------- |
| **TTL**         | `60s`    | How long a cached result is considered fresh                              |
| **SWR**         | `30s`    | Extra window after TTL where stale results can be served while refreshing |
| **Max entries** | `10,000` | Approximate upper bound of cached entries per project                     |
| **Enabled**     | `false`  | Caching is off by default for new databases                               |

### Understanding TTL and SWR

TTL and SWR work together to balance freshness and performance:

```
 ◀──── TTL (60s) ────▶◀── SWR (30s) ──▶
 │     fresh hit      │   stale hit     │  miss
 │  (instant return)  │ (instant return │ (upstream query,
 │                    │  + bg refresh)  │  cache updated)
 ┼────────────────────┼────────────────┼──────────
 cache write          TTL expires      SWR expires
```

* During the TTL window, cached results are returned without any upstream query.
* During the SWR window, the stale result is returned immediately (keeping latency low), and PgBeam refreshes the entry in the background.
* After the SWR window, the entry is treated as a miss and the upstream is queried.

## Invalidation: writes do not auto-clear the cache

> **Cached reads are cleared by TTL, not by writes**
>
> PgBeam does **not** automatically invalidate cached results when a write flows
> through the proxy. Writes are never cached, but a `SELECT` cached before the
> write keeps being served until its TTL (and SWR window) expire. Plan for a
> staleness window of up to `TTL + SWR` after any change.

If you need cached data cleared sooner than its TTL, opt in to event-based invalidation. PgBeam listens on the `pgbeam_invalidate` channel on your upstream database and evicts cached entries for a table when it receives a notification naming that table. You emit those notifications from triggers you install on the tables you cache:

```sql title="Install invalidation triggers on your upstream"
CREATE OR REPLACE FUNCTION pgbeam_notify_invalidate() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('pgbeam_invalidate', TG_TABLE_NAME);
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_invalidate
  AFTER INSERT OR UPDATE OR DELETE ON products
  FOR EACH STATEMENT
  EXECUTE FUNCTION pgbeam_notify_invalidate();
```

The `NOTIFY` payload is the table name (bare or schema-qualified). PgBeam evicts the cached entries that read from that table.

> **NOTIFY needs a direct upstream, not a pooler**
>
> `LISTEN`/`NOTIFY` requires a session-level connection. PgBeam opens one direct
> session connection to your upstream to listen on `pgbeam_invalidate`. If the
> upstream connection details you gave PgBeam point at a PgBouncer-style
> transaction pooler (PlanetScale, the Supabase pooler, RDS Proxy), the `LISTEN`
> silently receives nothing and invalidation does nothing, with no error. Point
> PgBeam's upstream at a direct, session-capable endpoint (not the pooler port)
> if you want `NOTIFY` invalidation to work.

If a table changes often and must never be served stale, do not rely on invalidation timing. Either skip the cache for those reads with `/* @pgbeam:cache noCache */`, or give them a very short TTL (for example `/* @pgbeam:cache maxAge=1 */`) so the freshness window is bounded regardless of whether a notification arrives.

## Ways to control cache behavior

PgBeam checks cache controls in priority order. The first match wins:

| Priority | Source               | Example                        | Scope           |
| -------- | -------------------- | ------------------------------ | --------------- |
| 1        | SQL annotation       | `/* @pgbeam:cache noCache */`  | Per query       |
| 2        | Session override off | `SET pgbeam.cache = off`       | Per session     |
| 3        | Cache rule           | Dashboard rule for query shape | Per query shape |
| 4        | Session override on  | `SET pgbeam.cache = on`        | Per session     |
| 5        | Database default     | Database-level cache setting   | All queries     |
| 6        | Fallback             | Off                            | n/a             |

This precedence lets you set broad defaults and override them precisely where needed.

## SQL annotations

Embed cache directives directly in your SQL as comments. PgBeam strips the annotations before forwarding to the upstream, so your database never sees them.

{/* prettier-ignore */}

**Enable caching**

```sql
/* @pgbeam:cache */ SELECT * FROM products WHERE active = true;
```

**Custom TTL and SWR**

```sql
-- Cache for 5 minutes, serve stale for 1 minute during refresh
/* @pgbeam:cache maxAge=300 swr=60 */ SELECT * FROM products;
```

**Disable caching**

```sql
-- Force this query to always hit the upstream
/* @pgbeam:cache noCache */ SELECT NOW();
```

**Route to replica**

```sql
/* @pgbeam:replica */ SELECT * FROM events ORDER BY created_at DESC LIMIT 100;
```

### Annotation reference

| Annotation                           | Effect                                  |
| ------------------------------------ | --------------------------------------- |
| `/* @pgbeam:cache */`                | Enable caching with default TTL and SWR |
| `/* @pgbeam:cache maxAge=N */`       | Cache for N seconds                     |
| `/* @pgbeam:cache swr=N */`          | Set SWR window to N seconds             |
| `/* @pgbeam:cache maxAge=N swr=M */` | Set both TTL and SWR                    |
| `/* @pgbeam:cache noCache */`        | Bypass cache for this query             |
| `/* @pgbeam:replica */`              | Route to a read replica                 |

## Session overrides

Control caching for an entire connection session. Useful for debugging or for application code that needs to temporarily force cache behavior.

```sql title="Session-level cache control"
-- Enable caching for this session
SET pgbeam.cache = on;

-- Set TTL for this session
SET pgbeam.cache_ttl = 300;

-- Enable debug output (see cache hit/miss notices)
SET pgbeam.debug = on;

-- Check current settings
SHOW pgbeam.cache;
SHOW pgbeam.cache_ttl;
SHOW pgbeam.debug;

-- Return a setting to its default, or clear all three
RESET pgbeam.cache;
RESET ALL;
```

| Setting            | Accepted values                                       |
| ------------------ | ----------------------------------------------------- |
| `pgbeam.cache`     | `on`, `off`, `true`, `false`, `1`, `0`, `default`     |
| `pgbeam.cache_ttl` | A whole number of seconds (`0` or more), or `default` |
| `pgbeam.debug`     | `on`, `off`, `true`, `false`, `1`, `0`, `default`     |

Values may be quoted (`'off'` or `"off"`) and the `SET SESSION` spelling works the same as `SET`. Any other value is rejected with SQLSTATE `22023` (`invalid value for parameter "pgbeam.cache": "yes"`) and the setting keeps its previous value. `SET LOCAL` is rejected with `0A000` because these settings apply to the whole session, not a single transaction.

When `pgbeam.debug` is on, every query returns a `NOTICE` with cache information:

```sql
SET pgbeam.debug = on;
SELECT * FROM users WHERE id = 1;
-- NOTICE: pgbeam:cache hit age=12s ttl=1m0s swr=30s
```

## Cache bypasses

PgBeam skips the cache entirely when any of these conditions are true:

| Condition                                                                                                                           | Why it bypasses                                                                             |
| ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Caching not enabled for the query                                                                                                   | No rule, annotation, or session override                                                    |
| Statement mutates data                                                                                                              | `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`                                                    |
| Statement changes session state                                                                                                     | `SET`, `DISCARD`, `RESET`                                                                   |
| Session ran `set_config()`, a `DO` block, or a function that is not a known built-in, or sent a `SET` through the extended protocol | The session may have changed a setting the cache key cannot see, so the rest of it bypasses |
| Query uses volatile functions or reads session state                                                                                | `NOW()`, `RANDOM()`, `pg_advisory_lock()`, `current_setting()`, etc.                        |
| Query is inside an explicit transaction                                                                                             | `BEGIN` ... `COMMIT` blocks                                                                 |
| SQL annotation says `noCache`                                                                                                       | Explicit opt-out                                                                            |
| Session override is `pgbeam.cache = off`                                                                                            | Session-level opt-out                                                                       |

### Caching with row-level security

Cached results stay correct for RLS policies that read the role or a session setting, when the application sets it in one of these ways:

* `SET ROLE` or `SET app.tenant_id = '...'` sent as a plain statement becomes part of the cache key, so tenants never share an entry and sessions of the same tenant still do.
* A tenant set from inside a function (`SELECT set_tenant('acme')`), through `set_config()`, or in a `DO` block is invisible to PgBeam, so the session stops using the cache from that statement on. Its queries still run, just uncached.

A session that calls only built-in functions (`count()`, `lower()`, `now()`, `jsonb_build_object()` and the like) keeps full caching. A call to a user-defined function, or to most extension functions, turns caching off for that session even when the function changes no settings. Settings changed by a trigger, a column default, a domain check, a function a view calls, a user-defined operator or cast, or a function called with attribute notation (`SELECT o.set_tenant FROM orgs o`) are not seen at all, so do not rely on the cache for RLS that depends on those.

## Cache layers

PgBeam uses a two-layer cache for speed and resilience:

| Layer  | Scope             | Latency         | Failure behavior                         |
| ------ | ----------------- | --------------- | ---------------------------------------- |
| **L1** | Per-process       | Microseconds    | Process-local; failure is extremely rare |
| **L2** | Shared per region | Sub-millisecond | Fail-open: queries go to upstream        |

L1 is checked first. On an L1 miss, L2 is checked. On an L2 miss, the query goes to the upstream database. Results from the upstream are written back to both layers.

If the shared cache is unavailable, PgBeam falls through to the origin database. Your application continues working. It just loses the caching benefit until the cache recovers.

## Cache rules

Cache Rules give you a dashboard-based way to enable caching for specific query shapes without changing application code. PgBeam automatically tracks the distinct query shapes flowing through your project, so you can see what your traffic looks like before you start caching anything.

### View cache rules

**Dashboard**

Open your database in the dashboard and go to **Cache Rules**. You will see a
list of detected query shapes with frequency and timing data.

**API**

```bash
curl -H "Authorization: Bearer <api-key>" \
  https://api.pgbeam.com/v1/projects/{projectId}/databases/{dbId}/cache-rules
```

**CLI**

```bash
pgbeam projects cache-rules list --database-id <id>
```

### Enable caching for a query shape

Once you identify a high-frequency read query that would benefit from caching, enable it from the dashboard or API:

**Dashboard**

Find the query shape in **Cache Rules** and toggle caching on. You can
optionally set a custom TTL and SWR for that shape.

**API**

```bash
curl -X PUT \
  https://api.pgbeam.com/v1/projects/{projectId}/databases/{dbId}/cache-rules/{hash} \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -d '{"cache_enabled": true}'
```

### Recommended approach

1. **Let traffic flow for a while**: PgBeam needs to see your query patterns before you can make informed caching decisions.
2. **Start with high-frequency, stable reads**: Look for queries that run hundreds or thousands of times per day and return data that changes infrequently.
3. **Avoid caching queries with volatile data**: If a query returns different results every time, caching wastes memory without saving upstream load.
4. **Monitor cache hit rates**: Use the dashboard or [Query Insights](/docs/api) to see whether caching is actually helping.

## Further reading

* [Read Replicas](/docs/replicas): Route reads to replicas for load distribution
* [Plans & Limits](/docs/plans): Cache entry limits and defaults per plan tier
* [Troubleshooting](/docs/troubleshooting): Debugging "cache is not doing anything"