Skip to content

Insights: how cached results work

Every SQL-backed dashboard card runs against your Databricks SQL Warehouse — Datahub caches the result so the first viewer pays the warehouse cost and everyone else reads the same payload back in milliseconds.

Default TTL24 hours
Keyed bySQL · filters · connection
ScopeOrg-shared · per-user on OAuth
StorePostgres · not Redis

As of April 2026, Datahub keeps a shared, organization-wide cache of card query results. Most teams' underlying tables refresh only once a day, but without a cache every viewer, every filter change, and every page tab re-paid the warehouse cost from scratch — wasteful for the bill, slow for the user.

Metric-backed cards are separate

This page is about cards that read SQL: cards with their own SQL, and cards From Saved Query (see below). Cards backed by a metric have their own cache — see Insights: backing a card with a metric → Cache and refresh. The settings on this page do not affect them.

Why it exists — what the cache saves

  • One warehouse read, many viewers


    The first viewer to land on a dashboard pays the Databricks cost; every other viewer reads the cached payload until it expires.

  • Millisecond loads


    A cache hit returns immediately instead of waiting on a (sometimes cold) SQL warehouse.

  • Filter & tab clicks stay free


    Re-filtering or switching page tabs reads from cache, not from Databricks — the rapid-click case the warehouse bill hates most.

  • 80–95% hit rate


    On a dashboard a team actually uses, most loads never touch the warehouse.

How a cache read works

When a card runs, Datahub builds a cache key, looks for a live row, and either serves it (a hit) or runs the query on Databricks and stores the result for the next viewer (a miss).

flowchart LR
    Q["Card runs<br/>rendered SQL + filter values"] --> KEY["Build cache key<br/>sha256"]
    KEY --> LOOK{"Live cached row?<br/>(not expired)"}
    LOOK -->|MISS| DBX["Run on Databricks<br/>SQL Warehouse"]
    DBX --> STORE[("Store payload<br/>+ expires_at TTL")]
    STORE --> SERVE(["Serve payload<br/>milliseconds on next hit"])
    LOOK -->|HIT| SERVE
    classDef accent fill:#ff8400,stroke:#e67700,color:#ffffff,font-weight:600;
    class SERVE accent

Figure 1 — A card query hits or misses the cache. On a miss, Datahub pays the warehouse once and stores the payload; every viewer after that reads the cached row until it expires.

Why teams reach for this

The expensive part of a dashboard is the Databricks query, not the render. Because the cache is shared across the organization, one colleague's morning load warms the dashboard for the whole team — their read becomes everyone's cached read, and the warehouse only wakes once per freshness window.

What makes up a cache key

Two loads share a cached row only when every ingredient below matches. That is why a different filter value or a different connection caches separately.

Ingredient Why it's in the key
Rendered SQL Captures the query text and operator shape — a > filter caches separately from an = filter.
Filter values The bound parameter values themselves — a page tab or filter selection that changes them is a different row.
Connection (integration) The same query against a different warehouse caches separately.
Viewer (per-user OAuth only) RLS-scoped results are keyed per person and never shared — see below.
Card Each card caches its own result, so refreshing or purging one dashboard never depends on another dashboard's cards.

Saved-query cards and linked alerts

A card From Saved Query — an action list's table, or a KPI summarising it — is cached like a SQL card: same key ingredients, same freshness window. Its "rendered SQL" is the saved query's active version with the card's summary and the dashboard's filters applied, run on the query's own connection.

Cards on the same saved query stay in step, so a KPI never totals a different run than the table under it. They expire together, at the shortest freshness window any of them has, and when one of them is refreshed, edited or added, the others run again too and the page shows the new rows at once.

One rule is added for an action-list dashboard an alert links to: a cached result stored before that alert's latest run is not used. An alert's message links recipients to its action list, so the list they land on is never older than the run that sent them. Views between two runs are still served from the cache as usual.

flowchart LR
    V["Viewer opens the<br/>action list"] --> K["Build the<br/>cache key"]
    K --> H{"Cached, and stored after<br/>the linked alert's last run?"}
    H -- yes --> S["Show the cached rows"]
    H -- no --> R(["Run the saved query,<br/>cache the result"])
    classDef accent fill:#ff8400,stroke:#e67700,color:#ffffff,font-weight:600;
    class R accent

Figure 2 — an action list linked to an alert is never served from before that alert's latest run.

The manual refresh skips the cache here too. Send test notification on the alert clears the linked list's cache before the message goes out, so its link opens a list at least as fresh as the rows in the message. On such a list the freshness chip reads refreshes after the alert's next run, or in ….

What viewers see

Every SQL-backed card shows a small freshness chip in its top-right corner.

The freshness chip

Updated 3h ago • next refresh in 21h   [refresh icon]

  • Updated <time> ago — when the card was last fetched from Databricks. Two people on the same dashboard see the same value because they read the same cached row.
  • Next refresh in <time> — when the cache will automatically expire. After that, the next viewer's load hits Databricks again and replaces the cached row.
  • The clockwise arrow icon is a manual refresh: it skips the cache for this card, fetches fresh data, and stores the new payload. Everyone else immediately benefits — your refresh becomes their cached read.

The chip does not appear on metric-backed cards (metrics have their own cache), nor during a card's very first paint — once the first response arrives, the chip shows whatever freshness the server reported.

Setting the freshness window

Cache duration is configurable at two levels, both optional. If you set neither, the platform uses 24 hours — the typical Databricks refresh cadence. The value resolved at write time follows this cascade:

Priority Source Where Range
1 — wins Per-card override Card editor → Cache section 1–1440 min
2 Per-integration default Administration → Integrations → SQL Warehouse card 1–1440 min
3 — fallback Platform default hardcoded 24 hours (1440 min)

The default for every card on that warehouse.

  1. Administration → Integrations → open the Databricks integration powering your dashboards.
  2. Scroll to the SQL Warehouse card.
  3. Set Cache duration (minutes) — 1 to 1440 (1 minute to 24 hours).
  4. Save.

Saving invalidates every cached row on every dashboard backed by this integration. The next page load re-queries Databricks once and starts the new TTL window; rows under the old TTL are dropped.

A tighter or looser SLA for one card — the canonical case is a "live order count" tile on an otherwise daily dashboard.

  1. Open the dashboard, click the pencil on the card, and find the Cache section in the right-side editor.
  2. Fill in Cache duration (minutes) — 1 to 1440.
  3. Save.

Leaving it empty makes the card inherit the integration default (or the 24h fallback). A shorter override behaves exactly as expected: that one card refreshes more often, the rest stay on the longer cadence.

Sensible starting points:

Warehouse cadence Set cache duration Effect
Daily ETL (most common) empty or 1440 Lasts a full day; the first morning viewer pays, everyone after is fast.
Hourly batch refresh 60 Viewers within the hour share one read; the next hour pays once.
Streaming / near-live 5 or 1 Helps rapid filter / tab clicks but little else.

"Shared across the organization" — and row-level security

For most Databricks integrations the cache is fully shared across your entire organization: the first viewer fills it, every other viewer in your tenant reads that same payload. This is the whole point — it is what saves the warehouse bill.

The one exception: per-user OAuth

Integrations that authenticate with per-user OAuth (the Databricks U2M OAuth flow) run every query as the real human user viewing the dashboard, so Unity Catalog row-level security scopes what each person sees. Sharing one user's cached payload with another would defeat that — Alice would see Bob's restricted rows.

For per-user OAuth integrations the cache is therefore per-user-per-card, not org-shared. Each viewer still benefits across their own filter changes and page tabs (what the chip reflects), but two people on the same dashboard each pay the warehouse cost on their first load.

Integration auth method Cache scope RLS-safe?
Service principal / PAT Org-shared (one row per query, all viewers) Runs as the integration's identity — no per-user RLS to protect
Per-user OAuth (U2M) Per-user-per-card Yes — each viewer's key includes their own identity

If you want the org-shared performance win and your data has no per-user restrictions, switch the integration's auth method to a service principal or PAT (see Databricks: per-user OAuth for the trade-offs).

When the cache is invalidated

Datahub invalidates aggressively whenever something semantically changes, so you rarely need to think about it. Any of these drops the affected rows; the next viewer re-queries Databricks once and refills.

flowchart LR
    E["Card edited<br/>SQL · config · TTL override"] --> DROP
    A["Dashboard archived<br/>or deleted"] --> DROP
    I["Integration TTL changed<br/>or warehouse swapped"] --> DROP
    R["Viewer clicks Refresh<br/>(bypass cache)"] --> DROP
    T["TTL expires<br/>hourly reaper sweep"] --> DROP
    DROP(["Cached rows dropped"]) --> NEXT["Next viewer re-queries<br/>Databricks once → refills"]
    classDef accent fill:#ff8400,stroke:#e67700,color:#ffffff,font-weight:600;
    class DROP accent

Figure 3 — What drops a cached entry. All five triggers converge on the same outcome: the stale rows are gone, and the next load pays the warehouse once to refill.

Trigger What's dropped Who pays next
A card is edited (its SQL, filters, query config, or cache override) All cached rows for that card Next viewer, once
A dashboard is archived or deleted All cached rows for the dashboard (automatic) —
An integration's cache duration changes, or its warehouse is swapped All cached rows for that integration Next viewer, once
A viewer clicks Refresh on a card Just that card, for everyone The clicker — becomes everyone's cached read
Hourly background sweep (reaper task) Expired rows, so the table doesn't grow unbounded —

Databricks changes don't auto-invalidate

A fresh ETL run or a schema patch inside Databricks does not notify Datahub, so it does not drop cached rows on its own. The cache refreshes when its TTL expires. To surface fresh data sooner, click Refresh on the affected card, or use the admin purge (below).

Admin: cache stats & purge-all

Workspace administrators with the Insights manage role get a page at Administration → Insights cache.

Stat What it tells you
Cached queries Total cached rows across the organization right now.
Dashboards with cache How many distinct dashboards have at least one cached card.
Hit rate (24h) How often viewers hit the cache instead of Databricks in the last 24 hours. Higher is better — expect 80–95% on a team dashboard.
Total bytes (approx.) How much storage the cache table currently uses.
Oldest / Newest entry Bookends of the current cache window.

The Purge all cached queries button clears every cached row across the organization. A confirmation dialog warns that the next page load on every dashboard will re-query Databricks. Reach for it only when something organization-wide changes:

Use purge-all after… Why
A platform-wide warehouse migration (you swapped the workspace URL on every integration at once). The per-integration hooks fire one integration at a time; purge-all clears everything in one action.
An emergency where every viewer must see fresh data on their next load, regardless of configured TTLs. Nothing else forces an immediate org-wide refresh.
A schema change you know affects many dashboards. Force everyone to re-query rather than wait for per-card invalidation.

Use the smallest hammer

Use the in-card Refresh when a single card is stale, and reach for purge-all only when something organization-wide changes.

Troubleshooting

Symptom Likely cause Fix
Clicked Refresh but the chip still says "Updated 3h ago". The chip updates only after the new response returns; a slow warehouse can take several seconds. Watch the spinner inside the refresh button. If it stops and the chip hasn't moved, the request failed silently — check the browser network panel for the /cards/{card_id}/execute call and report the error.
Set integration cache to 60 min but a card still says "next refresh in 23h". The integration setting applies to new cached rows; existing rows live out their original TTL. Hit the in-card Refresh — that one click resets the card to the new 60-minute window.
You're an admin but don't see the Insights cache page in the sidebar. You're missing the Insights manage role. Ask a workspace owner to grant it (Administration → Roles). Owners and stewards have it by default.
Two of you on the same dashboard see different "Updated X ago" values. You're on a per-user OAuth integration — the cache is per-user-per-card by design. Expected. Switch the integration's auth method if you want a shared cache.
Sensitive numbers appeared after a broader-access viewer loaded the dashboard. Cross-user sharing is on because the integration uses a service principal / PAT, not per-user OAuth. For per-user RLS data, the integration must be per-user OAuth. Switch the auth method and clear the cache once.
Where this lives — for engineers and reviewers
  • The cache lives in Postgres (table insights.insight_query_cache). It is not Redis — see ADR-013 for the decision.
  • The implementation mirrors the metric-cache pattern in MetricCacheService — new engineers learn one pattern, applied twice.
  • The dynamic TTL cascade is display_config.cache_ttl_minutes_override → integrations.config.cache_ttl_minutes → 1440 minutes hardcoded fallback.
  • The Refresh button sends bypass_cache: true on the existing POST /cards/{id}/execute endpoint — no separate refresh endpoint, just a flag.
  • Tenant scoping is via the dashboard_id → tenant_id bridge, not a denormalised tenant_id column on the cache table.

See also

  • Insights


    The dashboards these cards live on — build, filter, and share them.

  • Metric-backed cards


    Cards backed by a governed metric — they have their own cache, separate from this page.

  • Matrix & multi-metric cards


    Pivot / heatmap and one-query-many-KPI card types that ride the same SQL cache.

  • Databricks: per-user OAuth


    The auth method that makes the cache per-user — and the RLS trade-offs of turning it off.