Core concepts
Datasets and the four modes
cube, lattice, hybrid, rows - what each one ships to the browser, what it is correct for, and what it costs you.
A dashboard carries up to eight named datasets. Each one is a SQL query plus its declared dimensions and measures, and each independently chooses a mode: how its results are materialized into the file and how the browser answers a question about them.
This is the most consequential decision in a spec, because it is the one that decides whether a number stays correct when a viewer touches a filter.
The four modes are a choice only on data Dashies provides
A dataset that reads a warehouse you connected does not choose between them. A
warehouse-backed dashboard keeps its data with Dashies and is answered when a
reader opens the page, so a publish that asks for cube, lattice, hybrid or
an in-file rows on your own warehouse connection is refused, and the refusal says so.
One exception, and it turns on a plan rather than on a connection: the shared Dashies sample data connection is a warehouse too, and on a free plan a dashboard built on it is prepared the in-file way instead, so the ladder below is the one that applies to it. On a trial or a paid plan it keeps its data with Dashies like any other warehouse dashboard.
Read the mode ladder below as the sample source's. For the shape a warehouse dashboard takes, and which engines can carry one today, see Connect a warehouse.
The four modes
They form a ladder, in ascending power and cost:
cube < lattice < hybrid < rows
cube | lattice | hybrid | rows | |
|---|---|---|---|---|
| What ships | pre-aggregated rows, one per combination of dimension values | one precomputed cell per possible filter state | a lattice plus a row-level slice | the raw rows |
| How the browser answers | re-sums the rows in JavaScript | looks up the one matching cell | looks up, or recomputes from the rows when it has to | runs real SQL in DuckDB-WASM |
| Correct for | additive measures only: sum, count, min, max, and ratios of them | any exact aggregate, under single-select filters | any exact aggregate, including under multi-select and range filters | anything, including ad-hoc slicing you cannot enumerate |
| Filters | single-select, multi-select and range, all exact | single-select; multi-select and range only for a composable measure | single-select, plus multi-select and range on any measure | multi-select, ranges, anything |
| Engine in the browser | none | none | DuckDB, only when a filter needs it | DuckDB, always |
| Size | smallest | small, bounded by a cell ceiling | lattice plus rows | largest; the only mode that can offload |
| Costs you | nothing, but it refuses non-additive measures | every dimension must declare a bounded value set | the same bound, plus shipping rows as well | every viewer gets the row-level bytes, and DuckDB loads on every view |
A one-line mnemonic that survives most conversations: a cube recomputes, a lattice remembers, rows re-query.
How the choice is actually made
It is mechanical, not a matter of taste. Note that the first question is about your measures, never about your filters:
- Every measure additive?
cube. Smallest, fastest, simplest. Filters do not enter into it: a cube answers multi-select and range filters exactly, because re-summing an additive measure over any subset of rows is still that measure. See below. - Any measure non-additive (a distinct count, an average, a median, a
percentile), but every dimension has a small enumerable set of values and
every filter is single-select?
lattice. You get exact answers with no query engine in the browser and the file stays small. - Those same bounded dimensions, but a filter has to be multi-select or a range
over a measure that cannot be composed from parts?
hybrid. - A dimension you cannot bound, or a genuine need for row-level detail?
rows.
You do not have to declare the mode. Leave it out and the server picks, then
reports what it picked and why. But it will only ever pick cube or lattice:
rows and hybrid are always an explicit opt-in, because both ship
row-level bytes that every viewer of the dashboard can read, and nothing should
route your raw rows into a shared file on your behalf.
If your dataset needs one of those two and you did not ask for it, the publish stops and tells you so, naming which of the two situations you are in and what to change.
Why cube refuses non-additive measures
A cube ships pre-aggregated rows and the browser re-sums whatever survives a
filter. That works for sums, counts, minimums and maximums, because those
compose: the total over two groups is genuinely derivable from the total of each.
A distinct count does not compose. Ship one row per region with a
count(distinct customer_id) on it, select "all regions", and the browser adds
them up, double-counting every customer who appears in two regions. A live
measurement of exactly this shape reported 2,503 customers against a true
1,200.
So Dashies inspects a cube dataset's SQL and refuses to publish if it
computes count(distinct ...), avg, a median, a percentile, stddev,
variance, or mode(...). The refusal names the construct it found. That refusal
is the feature; the other three modes are how you get the measure anyway.
Measure correctness is the page on which measures are which, and on the ones no static check can catch.
A cube answers multi-select and range filters exactly
This surprises people, and getting it wrong is expensive in one specific direction, so it is worth stating on its own.
A multi-select or range filter is not a reason to leave cube. The runtime
re-sums the additive cube over whatever subset of rows the filter leaves, and a
sum over a subset is still that sum. Every measure in a cube is additive by
construction, which is exactly the property that makes it composable, so there is
nothing for a multi-value filter to break.
The server agrees, and will tell you so if you let it: leave mode off and the
auto-selector checks your measures first. If they are all additive it returns
cube immediately, without ever looking at your filter tiles. The multi-select
question only arises further down the ladder, when deciding whether a
non-additive measure can live in a lattice.
The reason to be careful here is the cost of the wrong answer. Reaching for
hybrid or rows to get a multi-select filter you already had means shipping
row-level bytes to every viewer and loading a query engine in every viewer's
browser, buying nothing. And nothing will tell you: the dashboard works, so the
waste is invisible. If you have an additive measure and you are reading this
because of a filter, stay on cube.
How a lattice stays exact
Instead of storing one row per region and asking the browser to combine them, a lattice stores one row per possible filter state, with the answer already computed exactly by the warehouse.
The SQL is a GROUP BY CUBE over exactly the declared dimensions, projecting a
GROUPING(<dim>) AS __g_<dim> flag column for each one:
select region, plan,
grouping(region) as __g_region,
grouping(plan) as __g_plan,
count(distinct customer_id) as customers
from orders
group by cube(region, plan)
GROUP BY CUBE(a, b) asks the warehouse for every combination of "group by this"
and "roll this up": four groupings for two dimensions. A 1 flag means that
dimension is rolled up in that row.
| region | plan | customers | __g_region | __g_plan |
|---|---|---|---|---|
| us | pro | 120 | 0 | 0 |
| us | (all) | 500 | 0 | 1 |
| (all) | pro | 350 | 1 | 0 |
| (all) | (all) | 1000 | 1 | 1 |
The last row is the point. A cube dataset holding one row per region would have
answered "all regions" by adding 500 and 400 and 300 to get 1,200. The lattice
holds the warehouse's own answer for that state: 1,000.
When a viewer picks a filter, the browser does not aggregate. It looks up the one precomputed row for that filter state, synchronously, with no query engine and no network call. A median stays a real median; a distinct count stays a real distinct count.
The flag columns are why it keys on GROUPING() and never on a NULL: without
them, a rolled-up blank is indistinguishable from a genuine null value in your
data.
Two constraints follow from the shape, and both are enforced at publish:
- Multi-select and range filters are limited, and a cube has no such limit.
The lattice precomputes "region is us" and "region is anything". It does not
precompute "region in (us, eu)", because that is a different population and not
a cell. Where the measure is composable the runtime combines the cells it does
have; where it is not, the answer is unavailable and you need
hybrid. This is the constraint that makes the ladder counter-intuitive: acubehandles those same filters without complaint, because a sum over a subset is still a sum. - Approximate aggregates are rejected. A lattice promises each cell is exact,
which is true of a real median and false of an approximate quantile. On one
real BigQuery population, two cells of the same lattice disagreed about the
same rows (22,518 and 22,164, against a true 22,785) precisely because the
aggregate was approximate. BigQuery has no exact percentile function at all, so
an author reaching for a median there lands on the approximate form because it
is the only thing that runs. On BigQuery, percentiles need
rows.
The lattice cell budget
Cell count is the product of each dimension's number of values plus one: for
cardinalities c1 to cN it is (c1 + 1) * (c2 + 1) * ... * (cN + 1), where
each plus-one is that dimension's rolled-up state.
Multiply, not add. That is why low cardinality is a hard requirement rather than a style preference:
| Dimensions | Cardinalities | Cells |
|---|---|---|
| region | 4 | 5 |
| region, plan | 4, 3 | 20 |
| region, plan, month | 4, 3, 12 | 260 |
| region, plan, month, channel | 4, 3, 12, 6 | 1,820 |
| region, plan, month, channel, device | 4, 3, 12, 6, 4 | 9,100 |
| the same plus one 50-value dimension | ..., 50 | 464,100, refused |
The ceiling is 50,000 cells per lattice dataset, and it is checked at publish from your declared value sets, before any SQL runs. So you find out while authoring rather than on the first refresh.
The rule of thumb: adding a dimension multiplies; adding one more value to an
existing dimension adds. Past roughly 20 values, bucket the dimension (top 10
plus "other") or move to rows.
Sizes and ceilings
Every cube, lattice and hybrid dataset rides inside the file, and they
share one budget across the whole dashboard. Those three modes are the sample
source's, so the Data island and Lattice cells rows cannot bind a warehouse
dashboard: none of its datasets ships inside the file, and none of them is a
lattice.
| Ceiling | Value | Applies to |
|---|---|---|
| Data island | 8 MiB, and 100,000 rows | all inline datasets, summed |
| Compiled publish body | 5 MiB | the whole file; usually the one that binds first |
| Lattice cells | 50,000 | one lattice dataset |
| Datasets | 8 | one dashboard |
| Parquet-backed datasets | 3 | one dashboard |
Two engine-specific facts change these:
- SQL Server is much tighter. Its executor caps a single result at 5,000 rows and 2,000,000 bytes, twenty times fewer rows than every other engine. A dataset that is comfortable on Postgres or Snowflake can simply be refused there, and the remedy is a coarser grain, a narrower window, or a lower-cardinality dimension. Both figures are the in-database executor's, so they bound an inline dataset and the authoring tools rather than a served SQL Server dashboard's own data.
- BigQuery, Snowflake, Redshift and Databricks have no byte cap at execution time. They refuse on row count before fetching. The island and publish ceilings above still bind, and they usually bind first because they measure real bytes. So do not coarsen a BigQuery grain to fit a number that does not apply to it.
Offloading to Parquet
On a warehouse connection this is no longer something a dataset declares. It is the one shape a warehouse dashboard has: its data lives in a Parquet object that Dashies holds and answers from. What follows is what that buys and what it costs, not a mode you pick.
This is the single most misread feature in Dashies, so the precise statement:
Parquet does not raise what you may declare. It raises what the dataset may grow into.
At publish, the SQL you write is still seeded through the same executor, so it must still return no more than 100,000 rows. That is the same row cap as an inline dataset, and declaring Parquet buys you nothing there. What it buys is real, and is usually the reason to reach for it:
- nothing takes a share of the 8 MiB island budget, because nothing ships inside the file
- between publishes it may grow to 256 MiB and 18,000,000 rows
- the page asks Dashies for what it needs, rather than a viewer's browser loading the object
The trade-offs: refresh for that dashboard becomes asynchronous and completes in minutes rather than immediately, and the dataset publishes empty and reads "Updating" until the first refresh lands.
SQL Server had no Parquet path at all until 2026-09-11, and this page said so. It has one now: a SQL Server dashboard is served like the other warehouse engines, so its data is held by Dashies rather than shipped inside the file. The 5,000-row and 2,000,000-byte figures above are its in-database executor's, and they bound an inline dataset and the authoring tools rather than that dashboard's own data. See Connect SQL Server.
The composite model
This is the sample source's shape too, because it pairs a lattice with a
Parquet-backed rows dataset and a warehouse connection cannot carry a lattice.
The shape a large report should take is not one heavy dataset. It is two:
- a
latticecarrying the aggregates, exact under every single-select filter, megabyte-scale, with no engine to load - a Parquet-backed
rowsdataset carrying the drill-through detail behind it
That is why the limit is three Parquet datasets per dashboard: it is sized for exactly this. If you have used Power BI's composite models, it is the same idea, compiled into a static file.
Next
Measure correctness is what the mode ladder is protecting you from. Designing the cube is how to write SQL that stays inside these budgets.