---
title: Verify your numbers
description: Cross-check every published measure against an independent aggregate. A dashboard that validates and publishes cleanly can still ship a wrong number.
updated: 2026-08-04
tier: free
---

Run this before you share a dashboard link with anyone. It takes two queries per
measure.

:::danger{title="validate_cube_sql proves the SQL runs, not that it is correct"}
Dashies checks a great deal at publish time, and none of it is a correctness
proof. Two failures pass every automatic check and then refresh to a plausible
wrong number forever:

- **A non-additive measure hidden in a CTE or subquery.** A ratio or a
  `count(distinct ...)` computed inside a derived table and then selected as a
  plain column reads as additive from the outside.
- **A fan-out join.** Joining to a one-to-many table multiplies every summed
  measure on the parent row.

You wrote the SQL, so you have the context to catch both. Nothing downstream
will. For why no automatic check can close this gap, see
[what no check can do for you](/concepts/measure-correctness#what-no-check-can-do-for-you).
:::

## The check

For every measure declared as additive, compute its total two ways and compare.

**Leg A: total the cube.** Wrap the cube SQL and sum the measure out of it, so
the answer comes back as one row.

```sql
select sum(revenue) as total
from (
  select date_trunc('month', o.ordered_at)::date as month,
         c.region                                as region,
         sum(o.amount)                           as revenue,
         count(*)                                as orders
  from orders o
  join order_items i on i.order_id = o.id
  join customers c on c.id = o.customer_id
  where o.ordered_at >= now() - interval '12 months'
  group by 1, 2
) c
```

**Leg B: total the base table, independently.** Aggregate the single un-joined
source a different way.

```sql
select sum(amount) as total
from orders
where ordered_at >= now() - interval '12 months'
```

Run both through your AI's `validate_cube_sql` tool against the same data source.
Neither needs the row echo, since both return one row.

**The two numbers must match.**

In the example above they do not. `order_items` is one-to-many against `orders`,
so the join multiplies each order's `amount` by its item count, and leg A comes
back several times leg B.

:::warning{title="Leg B must not repeat the cube's joins"}
The whole point of leg B is that it is independent. Aggregating over the cube's
own `FROM` clause reproduces exactly the double-count you are checking for, and
the two legs then agree while both being wrong.

Aggregate the base table on its own. Keep the same `WHERE` clause, so both legs
cover the same rows, and change nothing else.
:::

## Reading the result

| What you see | What it means | What to do |
|---|---|---|
| The two totals match | The re-summing a cube does is sound for this measure. Repeat on one or two filter slices for stronger evidence. | Nothing. |
| Leg A is a clean multiple of leg B | A join is fanning rows out. | Pre-aggregate the fanning side to the grain first, or dedupe, so each base row counts once. |
| Leg A differs in some other way | The measure is not [additive](/concepts/measure-correctness#additive-and-non-additive). There is a ratio, distinct count, or average hidden inside it. | Declare it as [a ratio measure](/concepts/measure-correctness#ratios-not-stored-averages) with a numerator and denominator, or move the dataset to a mode that recomputes it exactly. See [Choose a dataset mode](/guides/choose-a-dataset-mode). |

## When the publish report tells you to run this

The dry-run report carries an `obligations` entry whenever a cube is built over
**more than one row source**: a join, a CTE, a comma join, or a derived table.
That is the format prompting you to run the check above, because no static
analysis can judge it.

An obligation is a prompt, never a substitute. And read the converse carefully:

:::danger{title="Empty obligations does not mean your numbers are right"}
An empty `obligations` list means only that the cube reads one row source, so it
cannot fan out. A non-additive aggregate or a mis-declared measure on a
single-source cube is still entirely yours to catch.
:::

## Non-additive measures need this more, not less

A median, a percentile, a distinct count, or a true average is not re-derivable
from anything downstream. See
[additive and non-additive](/concepts/measure-correctness#additive-and-non-additive)
for which aggregates fall on which side, and
[flow and stock](/concepts/measure-correctness#flow-and-stock) for the measure
shape that produced the worst incident this product has had. Nobody will ever notice a wrong one, so prove it before
you publish.

Compute the same value a second, independent way and compare. A rank-based query
using `row_number()` works on every engine:

```sql
select max(x) as median_x
from (
  select x, row_number() over (order by x) as rn, count(*) over () as n
  from measurements
  where x is not null
) t
where rn <= (n + 1) / 2
```

:::danger{title="Compare like with like, or this check will flag a correct cube"}
That query returns the **discrete lower median**: on an even-sized population it
gives you the lower of the two middle values. It agrees exactly with
`percentile_disc(0.5)`.

It does **not** agree with `percentile_cont(0.5)`, which interpolates and returns
the average of those two values. So if your cube uses `percentile_cont` and the
column has an even number of non-null rows, the two legs differ **by design** and
the cube is fine.

Compare `percentile_disc` against the query above, or `percentile_cont` against
an interpolating second leg. A verification procedure that reports false
positives is worse than none, because the next real discrepancy gets waved off as
noise.
:::

Two engine-specific traps are worth knowing before you run it:

- **On BigQuery there is no aggregate percentile function**, and the obvious
  substitute is wrong twice over. See the
  [BigQuery page](/guides/connect-warehouse/bigquery).
- **A `count(*)` where you meant `count(x)` skews a median low** whenever the
  column has nulls, and agrees exactly when it does not. On a column with 8,917
  nulls in 296,104 rows, that mistake returned 21,409 against a true median of
  22,767, about 6 percent low, and it validated and published clean. This is
  precisely the shape that survives casual testing and reaches production.

### Which median form to use, and which are verified

| Engine | Exact median | Verified against a live connection |
|---|---|---|
| PostgreSQL | `percentile_cont(0.5) within group (order by x)` | yes |
| BigQuery | `array_agg(x ignore nulls order by x)[safe_offset(div(count(x), 2))]` | yes |
| Snowflake | `percentile_cont(0.5) within group (order by x)` | **no** |
| Amazon Redshift | `percentile_cont(0.5) within group (order by x)` | **no** |
| Databricks | `percentile(x, 0.5)` | **no** |
| SQL Server | no aggregate form confirmed | **no** |

The four marked **no** are the documented syntax rather than something we have
run. On those, prove the median against the independent query above before you
publish, rather than trusting the syntax.

## Check it worked

You are done when, for each measure a tile actually displays:

1. Leg A and leg B agree, ungrouped.
2. They agree again over at least one filter slice, for example a single region
   or a single month.
3. Any measure that is a median, percentile, distinct count, or average has been
   confirmed against an independent computation.

Then open the published dashboard and confirm the headline figure on the page
matches leg B. If it does not, the number the dashboard displays is not the
number you just proved, and the difference is a binding or a scope problem rather
than a SQL one.

:::note{title="Do this again after any SQL change"}
The result is a claim about the SQL you ran it on. A later edit that adds a join
or moves an aggregate into a CTE re-opens both failure modes, and the next
scheduled refresh will publish the new number without asking anyone.
:::
