modellerUpdated 2026-04-22

Configure Pocket Tables

Model Builder — Pocket Drawer in create mode, Query tab.

What this covers

A pocket table is a cached, filtered slice of a model — not free SQL over the model's tables. The Query Router redirects matching reads to the pocket instead of running them against the source. This article covers the model-subset contract, how to author the defining SQL, validate it, measure it with a dry-run, schedule refreshes, handle drift when the model changes, and understand when the Router will and will not use a pocket.

Pocket matching and identity — how the Query Router picks a pocket and when it skips.

When to use a pocket table

Use a pocket table when a narrow filter on a large source table is scanned repeatedly, and the result set is small enough to cache. Aggregates cover rolled-up summaries; pocket tables cover row-level slices. Both live in the same query target and are selected independently by the Router — when both can answer a query, the pocket wins.

Pocket identity

A pocket is uniquely identified by three values:

PartMeaning
model_idThe model the pocket belongs to.
query_fingerprintA stable hash of the defining SQL's shape (projection, measures, grain).
predicate_set_hashA stable hash of the sorted canonical form of the pocket's predicates (column, operator, value).

Two pockets on the same model with the same query shape but different filter values are distinct rows. An attempt to create a second pocket with the same identity returns HTTP 409 and no row is inserted — the existing pocket is left alone. The automated optimiser follows the same rule silently, so duplicate candidates do not pile up errors.

The pocket's source table list is not a stored field. It is parsed from the defining SQL each time it is needed, so the SQL is the single source of truth.

Pocket table properties

PropertyDescription
TargetThe data target where the pocket is stored. A target is required.
Defining SQLA model-subset SELECT of the form SELECT * FROM <model_slug> [WHERE ...] [ORDER BY ...] [LIMIT ...]. No trailing semicolon.
ScheduleCron expression controlling when the Scheduler rebuilds the pocket.
Schedule enabledWhen off, no automatic refreshes run. Manual refresh still works.

The default refresh strategy is full refresh — the pocket is rebuilt from scratch on each run. Optional incremental mode kicks in when incremental_column is set on the pocket.

The model-subset contract

A pocket is a horizontal slice of its model, expanded to physical SQL at refresh time by the same join pipeline the gateway uses for ad-hoc model queries. The defining SQL must therefore stay inside a small, predictable shape:

SELECT * FROM <model_slug> [WHERE ...] [ORDER BY ...] [LIMIT ...]

The following are rejected at create / validate / refresh time and reported as a structured violation list (each with a code, a message and a one-line suggestion):

CodeWhat it means
FROM_NOT_MODELFROM references a physical table or unknown name instead of the model slug.
MULTIPLE_FROM_TABLESMore than one table in FROM.
JOIN_NOT_ALLOWEDA JOIN was used. Pockets cache the model's join output, not their own.
GROUP_BY_NOT_ALLOWED / HAVING_NOT_ALLOWED / DISTINCT_NOT_ALLOWEDAggregation keywords. Use an aggregate, not a pocket.
AGGREGATE_NOT_ALLOWED / WINDOW_NOT_ALLOWEDSUM/COUNT/AVG/etc. or window functions.
SUBQUERY_NOT_ALLOWED / SET_OP_NOT_ALLOWED / CTE_NOT_ALLOWEDSubqueries, UNION/INTERSECT/EXCEPT, or WITH clauses.
SELECT_MUST_BE_STARAn explicit projection list. Pockets must SELECT * so the matcher can serve any sub-projection.
WHERE_UNKNOWN_COLUMN / ORDER_BY_UNKNOWN_COLUMNA column reference that does not resolve to a model dimension or measure.
EMPTY_SQL / PARSE_ERROR / NOT_SELECT / MODEL_MISSINGTrivial shape failures.

Authoring workflow

  1. Open the model in Model Builder.
  2. Click Pocket Tables in the Toolbelt.
  3. Click New Pocket. The drawer opens on the Query tab.
  4. Pick a Target and type the Defining SQL into the editor. No separate source-table input is needed — the source is read from the SQL.
  5. Click Validate. The service parses the SQL and checks the model-subset contract. The result banner reports the stage (parse or subset) and, on failure, lists the violation codes and suggestions.
  6. Click Dry run to execute COUNT(*) against the defining SQL. The banner shows row count and elapsed milliseconds. Dry run requires a target connection; the statement timeout is capped by the system setting gateway.router_client_timeout_xlong.
  7. Switch to the Schedule tab. Turn the Schedule enabled switch on, pick a cron expression with the picker, and review the recent run history.
  8. Click Save. Create-mode saves the pocket and its refresh policy in one step. The Scheduler queues the first build as soon as the policy is enabled. If a pocket with the same identity already exists, the drawer surfaces an error banner and no duplicate is created.

Closing the drawer with the X, Cancel, or a click outside after you have edited the target, SQL, cron, or enabled switch prompts for confirmation before discarding your changes.

Edit mode

Editing an existing pocket is scoped to scheduling. The target and defining SQL are read-only after creation — delete and recreate the pocket if the SQL needs to change. The drawer exposes a Refresh now button in the header to trigger an immediate full rebuild without waiting for the scheduled window.

Drift handling

When the underlying model changes — a referenced dimension is removed, the slug is renamed, a JOIN is taken out — the next refresh re-runs the model-subset validator before touching the target. If the pocket no longer fits the model, it is flagged status='invalid' with a failure_reason carrying the structured violation list. Invalid pockets are no longer routed to. Once the model is fixed, the next refresh clears the flag back to stale and rebuilds the materialised table. No manual intervention is required.

The Pocket Tables list shows the invalid status alongside stale so you can see drifted pockets at a glance and either fix the model or delete the pocket.

Schedule semantics

The Scheduler evaluates enabled pockets on each tick. A pocket is due when the previous cron-scheduled fire time is newer than the pocket's last_refresh_at. Manual refreshes (via Refresh now or the API) do not interfere with the cron cadence — they rebuild immediately and update last_refresh_at.

PresetCronWhen it runs
Every hour0 * * * *At the top of every hour
Every 6 hours0 */6 * * *00:00, 06:00, 12:00, 18:00 UTC
Daily at 02:000 2 * * *Once per day at 02:00 UTC
Weekly (Sunday 03:00)0 3 * * 0Sundays at 03:00 UTC

Pickers accept any valid five-field cron expression. All times are UTC.

How matching works

The Router prefers a pocket when one fits before it considers an aggregate. A pocket fits a query when:

When the Router passes over a pocket, the reason is written to the route log and shown in the Diagnostics panel. The reasons are:

ReasonMeaning
flag_disabledpocket.enabled is off at the system level.
model_disabledpocket.model_enabled is off for this model.
no_tenant_filterpocket.require_tenant_filter is on and the query has no tenant-scope column. Set pocket.tenant_scope_from_context to accept the authenticated session's tenant in place of an explicit filter.
no_candidatesNo fresh pocket exists for this model.
fingerprint_or_predicate_mismatchA pocket exists but its shape or slice does not fit this query.
rewrite_unsafeA pocket matched but rewriting did not change the query, so the source was used instead.
complex_sql / unresolvable_whereThe query could not be bound into an intermediate representation safely.
from_outside_modelA defensive guard caught a pocket whose defining SQL points outside the model. With the model-subset contract enforced at create time this should not occur in practice.

Advanced tab

The Advanced tab (edit mode only) is read-only metadata: physical table name, status, created timestamp, last refresh, row count, storage bytes, and hit count. Use it to confirm the pocket is being consumed by the Router and to diagnose storage pressure.

Related