Skip to main content
Available on request — The Autumn Lakehouse is provisioned per customer. Contact us at hey@useautumn.com to get access.
Throughout this page, replace <catalog> and <namespace> with the names Autumn assigned you.

Find your catalog and namespace

Autumn sends you both names when your Lakehouse is provisioned (see Connecting). If you need to rediscover them in ClickHouse Cloud, the catalog mounts as a database — list databases to find it:
The catalog appears as one of the listed databases. Your tables then live under `<catalog>`.`<namespace>.v2_3_<object>` (the <namespace>.v2_3_<object> part is a single literal table name — see below).

Identifier syntax

The Glue catalog mounts as a database. The whole <namespace>.v2_3_<object> is the table name — the dot is literal, so backtick both parts:
A common mistake is `<catalog>.<namespace>`.`v2_3_features` — that won’t resolve, because <namespace>.v2_3_features is a single identifier, not database.table. The single-quoted '<namespace>.v2_3_features' form does not resolve in ClickHouse Cloud either — use backticks on both parts.

Timestamps

All number (epoch ms) columns (created_at, started_at, expires_at, current_period_*, *_resets_at, …) are epoch-milliseconds. Convert before use. The only native timestamp is events.timestamp (the unversioned events table — see Schema → Events).
fromUnixTimestamp64Milli(toInt64(created_at)) also works and preserves millisecond precision. events.timestamp is already a DateTime — use it directly.

JSON columns

metadata, config, processors, properties, deductions, display, v2_3_rollovers.entities, and the discounts arrays are stored as JSON strings.

Nullable columns: don’t use IS NULL

ClickHouse only. On the Iceberg tables, WHERE col IS NULL raises NOT_FOUND_COLUMN_IN_BLOCK — the engine reaches for an unmaterialized col.null subcolumn that doesn’t exist. This bites hardest on entity_id (the customer-vs-entity scope discriminator) and on v2_3_rollovers.expires_at (where null means “never expires”, so you have to test for it). Use coalesce(col, '') = '' for “is null” and coalesce(col, '') != '' for “is not null”; for numeric columns, coalesce(col, 0) = 0. BigQuery has no such restriction — plain IS NULL works there.

Examples

Fetch one customer

Active subscriptions joined to their plan

Join on the internal ids, not the external ones (see below).

A customer’s balances for a feature

Use the customer-scoped (pooled) rows where coalesce(entity_id, '') = ''.
This returns the raw aggregated row, which sums over every entitlement — including superseded versions and past cycles — so granted/remaining/usage will not match the API for customers with history. It also excludes rollover balances, which live in v2_3_rollovers — see Balance including rollovers. To reproduce the API/dashboard figure, apply the active + current-cycle filter from Working with balances. For overage specifically, use the reference query below.

Balance including rollovers

v2_3_balances.remaining and v2_3_breakdowns.remaining do not include rollover balances — rollovers are their own table, so any customer carrying unused allowance forward reads low until you add them in. This is the single most common way to undercount.
Join v2_3_rollovers.internal_breakdown_id to v2_3_breakdowns.internal_id and sum the unexpired rows. A rollover with expires_at null never expires; one with expires_at in the past is dead weight the table still keeps, so an unfiltered sum(balance) overstates.
Aggregate the rollovers in a subquery before joining, as above. Joining the rollover rows in directly fans out v2_3_breakdowns — one entitlement can hold several rollover grants — and then sum(b.remaining) counts the same breakdown row once per rollover. This query also has the same caveat as every other raw balances query: it sums over all entitlement rows, so apply the active + current-cycle filter if you want the figure the API would return.

Current-period overage

Overage is derived, not stored, and there are two figures — pick deliberately (see Working with balances → Overage):
  • Displayed (max(0, Σusage − Σgranted) — sum first, floor once): what the balance header / dashboard shows. This query computes this one, so it reproduces the dashboard to within sync-lag drift.
  • Billable (Σ max(0, usage − granted) — floor per row, then sum): what Autumn invoices. To get it, swap the overage expression as noted in the query.
Both reconstruct from v2_3_breakdowns over the same row set: rows whose customer plan is an active subscription (plus always-live one_off rows), restricted to each entitlement’s current cycle (earliest upcoming reset).
Swap in your <catalog> / <namespace> and the feature id. Known simplifications: it omits rollover balances (join v2_3_rollovers as in Balance including rollovers if your features roll over — unspent rollover reduces overage) and it is customer-scope-only (coalesce(entity_id,'') = ''), so entity-scoped overage under config.disable_pooled_balance is not counted. For those customers, sum the per-entity rows instead.

Invoice totals by status

To break down by plan, expand the plan_ids array with ARRAY JOIN:

Event volume per day by subtype

Reconstruct a full customer (API shape)

This rebuilds the entire GET /customers/:id response — scalars, subscriptions, purchases, balances (with per-plan breakdowns), flags, and invoices — from the warehouse in a single query, returned as one JSON object. It’s the most useful query if you want the API’s customer view without calling the API. ClickHouse-specific (uses groupArray, Tuple/Map casts, and the JSON type — ClickHouse 24.8+). Customer-level (pooled) balances and flags use entity_id = ''; per-entity rows are excluded from the customer envelope. FORMAT PrettyJSONEachRow emits one pretty-printed JSON object.
This mirrors the shape of the API response, but the balances / breakdown values are the raw aggregated rows — they sum over superseded versions and past cycles, and they exclude rollover balances — so they will not match GET /customers/:id for customers with billing history or rollovers. To match the API, restrict the balances/breakdowns subqueries to active, current-cycle rows per Working with balances → The active + current-cycle filter, and fold in v2_3_rollovers as in Balance including rollovers.

Recipes

Short, runnable answers to the questions people ask most often once they start joining the balances lane to everything else. They’re written for BigQuery; to run them on ClickHouse, swap the addressing form to `<catalog>`.`<namespace>.<table>`, replace TIMESTAMP_MILLIS(x) with fromUnixTimestamp64Milli(toInt64(x)), UNIX_MILLIS(CURRENT_TIMESTAMP()) with toUnixTimestamp(now()) * 1000, and every IS NULL / IS NOT NULL with the coalesce form (why).
All of these read raw breakdown rows, which span superseded plan versions and past cycles and exclude rollovers, so apply the active + current-cycle filter before quoting any of these numbers as the customer’s real position.

Join a breakdown to its subscription

internal_customer_product_id is the subscription’s internal_id, so this is one equality — no detour through customer + plan version.

Join a breakdown to its purchase

Identical shape against v2_3_purchases — one-off purchases have no status, so their liveness is expires_at.

The status of the plan a breakdown belongs to

v2_3_breakdowns has no status column — status is a property of the customer’s plan, so you fetch it through internal_customer_product_id. Because that key resolves to a subscription or a purchase, join both sides and coalesce. This also shows how to recover the external plan_id, which no longer lives on breakdowns: join internal_product_idv2_3_plans.

The status of the plan behind a flag

v2_3_flags works the same way, and for the same reason: it carries keys, not copies. It has no customer_plan_status, customer_plan_starts_at, or customer_plan_ended_at — those were removed along with plan_id — so a flag’s plan status comes from the same double join on internal_customer_product_id.
Only v2_3_subscriptions has a status column — a one-off purchase has no lifecycle states, so its liveness is expires_at. That’s why the COALESCE falls back to a literal rather than to pu.status. To keep only flags granted by a live plan, filter s.status = 'active' or an unexpired purchase, rather than reaching for a single status column.

Use internal_customer_product_id as the join key

A customer plan is a customer’s own instance of a plan, and it is either a recurring subscription or a one-off purchase — never both, never neither-but-something-else. So internal_customer_product_id on v2_3_breakdowns, v2_3_rollovers, and v2_3_flags points at v2_3_subscriptions.internal_id or v2_3_purchases.internal_id, and exactly one of the two joins matches. Left-join both and let the null tell you which kind it is:
Don’t reach for internal_product_id to do this job. It identifies the shared plan version, so matching on it gives you every customer who ever held that plan; you’d have to add the customer id and still couldn’t tell two spells of the same plan apart. internal_customer_product_id is the customer’s own row, and it is unique.

What balances did this customer have on their last plan

“Last plan” is the most recently started customer plan. Take it from v2_3_subscriptions by started_at, then pull every breakdown that hangs off it.
Two adjustments depending on what you mean. If the customer’s most recent plan might be a one-off, build last_plan as a UNION ALL over v2_3_subscriptions and v2_3_purchases before the ORDER BY started_at DESC LIMIT 1. And this still sums every reset cycle that plan lived through — add AND b.reset_resets_at = (the current cycle) per the active + current-cycle filter, and fold in rollovers, if you want the position as of now rather than the plan’s whole history.

Rows whose next reset is in the past

Useful as a health check. On v2_3_balances, next_reset_at is the earliest reset across every entitlement in the group, so a past value usually means the group still contains stale entitlement rows from cycles that have already rolled — not that a reset is overdue.
To find the individual entitlements behind it, run the same predicate on v2_3_breakdowns.reset_resets_at — that column is per-entitlement, so a past value there identifies exactly which rows the current-cycle filter would drop.

Cross-database joins

You can join your Lakehouse tables against your own data living elsewhere in the same engine.
Qualify each side fully — the Iceberg catalog table and your own ClickHouse table:
The catalog connection is best for ad-hoc and bounded queries. For very large scans, filter early (by env, time range, or id) or materialize a subset into a native table first.

Important notes

Use internal ids, not external ids

This is the single most important rule for reliable queries.
  • Each plan version is its own row in v2_3_plans, with its own internal_id. The only identifier shared across versions is the external plan_id.
  • External ids (plan_id, customer_id, feature_id, entity_id, …) are mutable — you can rename them in Autumn at any time — and a single external id can map to multiple versioned rows.
So:
  • Join and filter on internal_id and the internal_* foreign keys (internal_customer_id, internal_feature_id, internal_product_id, internal_entity_id). These are immutable and globally unique.
  • The balances lane (v2_3_breakdowns, v2_3_balances, v2_3_rollovers, v2_3_flags) uses the same keys as every other table: internal_product_id (→ v2_3_plans), internal_plan_item_id (→ v2_3_plan_items), and internal_customer_product_id (→ v2_3_subscriptions or v2_3_purchases). Reach for internal_customer_product_id whenever you need the customer’s own plan row — status, period, cancellation — rather than the shared plan version.
  • Treat external ids as display-only — great for human-readable output, unreliable as join or lookup keys.
Filtering by an external plan_id can silently match multiple plan versions (and breaks entirely if the id was renamed). Reach for internal_id whenever you need a stable, exact reference.

Pooled vs per-entity rows

v2_3_balances, v2_3_breakdowns, and v2_3_flags contain two kinds of row:
  • Pooled (customer-scoped) — entity_id is null.
  • Per-entityentity_id is set.
In v2_3_balances and v2_3_breakdowns the two are disjoint: an entitlement produces exactly one row, under whichever scope it holds. So coalesce(entity_id, '') = '' gives you the customer-scoped rows and excludes entity-scoped ones — it isn’t a dedup. If you want a customer’s true total across both, sum them all and don’t filter on scope at all. To analyze one entity, filter on its entity_id (or internal_entity_id). v2_3_flags is different: an entity-scoped flag is written twice, once at its own entity scope and once into the customer pool, so there coalesce(entity_id, '') = '' genuinely is the dedup.
Write coalesce(entity_id, '') = '', not entity_id IS NULLIS NULL throws on these Iceberg columns (see Nullable columns).
Customers with config.disable_pooled_balance track per-entity rather than pooled — for them, sum the per-entity rows instead of reading the pooled row. See Working with balances for what the aggregated values mean and the active + current-cycle filter you need before trusting them.

Freshness

State tables sync within ~5 minutes under normal load; events have a variable lead time and backfill on first connection. See Overview → Data freshness.