Split by key across tablets
Each table is split into tablets by its shard key, with a count you set or one the engine picks. A row-value list that covers the shard key reads only the shards that own the listed tuples.
Give each table the layout its reads deserve, prune with partitions and indexes, and let the engine keep your heavy aggregates answered before you ask. No copy into a warehouse, no second schema to keep in step.
Storage profile
A table’s physical layout is one clause of its definition. Choose a row-oriented projection for point access, a columnar one for scans, or both at once, and the choice applies to the table’s current rows and to the history it keeps.
CREATE TABLE transfers ( chain_id UINT16 NOT NULL, token STRING NOT NULL, sender STRING NOT NULL, recipient STRING NOT NULL, amount UINT256 NOT NULL, transferred_at DATETIME64(3, 'UTC') NOT NULL, _index UINT128 NOT NULL, INDEX transfers_ts (transferred_at) USING MINMAX SCOPE HISTORY GRANULE 8192)MODE APPENDSHARD BY (token) SHARDS AUTOPARTITION BY MONTH(transferred_at)ORDER BY (chain_id, token, _index)STORAGE PROFILE COLUMNAR;Partitions and indexes
Shards, partitions and indexes decide how much data a read touches. Every candidate row still passes the full predicate, so pruning changes the work, not the result.
Each table is split into tablets by its shard key, with a count you set or one the engine picks. A row-value list that covers the shard key reads only the shards that own the listed tuples.
Partition a shard’s rows by an hour, a day, a month or a hash of a column. A read that is bounded by the bucket skips the rest.
Per region of data, the smallest and largest value. A range predicate skips every region that cannot match.
Rules out the regions that cannot contain the value you look for, so an equality lookup touches only the regions that might.
Maps each key value to the positions that hold it, for the lookups that ask which rows carry exactly this value.
An index declares which state it covers. Declare one on a table that already holds rows and the table stays readable and writable while it fills.
An index changes how much data a read touches, not what it returns. Indexes are local to each tablet and maintained with its writes.
Keys, shards and partitionsOne-shot analytics
Grouped aggregates with a group filter, ordered and paged results, and subqueries that nest wherever a relation is expected. Plain SQL over the current state of your tables, with the aggregates you expect and arithmetic over them.
Every non-aggregate output is grouped, and the group filter keeps only the groups whose aggregate passes.
SELECT token, COUNT(*)FROM transfersWHERE chain_id = 1GROUP BY tokenHAVING COUNT(*) > 4;GROUP BY · HAVING
Order by any output, with nulls first or last, and page the result with a limit and an offset.
SELECT address, amountFROM balancesWHERE chain_id = 1 AND token = 'USDC'ORDER BY amount DESCLIMIT 10 OFFSET 10;ORDER BY · LIMIT · OFFSET
Derived tables and subqueries nest anywhere a relation or a value is expected, and a named query can lead the statement.
SELECT token, totalFROM (SELECT token, SUM(amount) AS total FROM balances GROUP BY token) totalsWHERE total > 0;subquery · WITH
Maintained aggregates
Describe the aggregate once. The engine applies every committed change to it and keeps the result exact, across restarts, until you drop it. Your dashboards read a table, not a scan.
A materialized view is maintained continuously into an engine-owned table and read by its name. Inserts, updates and deletes on the source reach it, and it is exact after a restart.
CREATE MATERIALIZED VIEW
CREATE MATERIALIZED VIEW token_volume ASSELECT chain_id, token, SUM(amount) AS volume, COUNT(*) AS transfersFROM transfersGROUP BY chain_id, token;The result is a relation like any other: filter it, order it, stream it. The grouped scan you used to run on a schedule becomes an ordinary read.
read by name
SELECT *FROM token_volume;A transformation writes the fold into a table of yours, or one attribute group of it, so several definitions maintain one row together and the next transformation can read the result.
CREATE TRANSFORMATION · INTO … ATTRIBUTE GROUP
CREATE TRANSFORMATION balance_activity INTO balances ATTRIBUTE GROUP activity ASSELECT chain_id, recipient AS address, token, COUNT(*) AS transfer_count, MAX(transferred_at) AS last_transfer_at, MAX(_index) AS activity_indexFROM transfersGROUP BY chain_id, recipient, token;COUNT · SUM · MIN · MAX · AVG · COUNT(DISTINCT …), and arithmetic over aggregate calls, such as a weighted mean.
TransformationsJoins
Join in a one-shot query, which reads a consistent snapshot of each side, or in a transformation, which keeps the joined target current as any side changes. The price in force when a transfer happened, or the current price when the event is processed: each is one join.
SELECT t.token AS token, t.amount AS amount, p.price_usd AS price_usdFROM transfers AS tASOF LEFT JOIN token_prices AS p ON t.chain_id = p.chain_id AND t.token = p.token AND p.priced_at <= t.transferred_at;History
A versioned table keeps every accepted version beside the current row. Read it at an earlier commit and your query runs over the state behind a decision, an audit or a replay, under the schema you have now.
SELECT address, token, amountFROM balances AS OF COMMIT 42WHERE chain_id = 1;The questions an analytics team asks before it stops copying tables into a second system. Anything else is in the docs.
No. Choose a columnar or hybrid layout per table, declare partitions and indexes, and run grouped queries over the same tables your services write to. An aggregate you read often is maintained for you by a materialized view or a transformation.
Lay out a table for scans, declare a maintained aggregate over it and read the answer like any other table.