Skip to content
All posts

Why Every Team in Your Company Gets Different Numbers from Your Warehouse

Part 1 of 2 — Building a BigQuery base layer that survives four teams.

June 16, 2026 by Yash 11 min read #BigQuery#Architecture#StarSchema#DataMart#SemanticLayer

The CFO closes the laptop and asks why this month’s delivery GMV is $14M on the finance dashboard and $11M on the delivery ops dashboard. The head of data says they are both correct. The CFO does not love this answer.

The four-teams-four-numbers problem is a definitions issue, not a data quality bug. The fix is in the data model, not the data.

Four teams. Four definitions of what looks like the same metric. All technically correct. None of them match.


The premise

Picture a mid-market company at this scale. About sixty engineers. About fifteen people whose job title says “data” or “analytics” somewhere in it. A BigQuery footprint that has grown organically for four years.

There is no single fact table. There are three:

  • Finance has a 147M-row “transaction level CM reporting” table that is rebuilt by roughly thirteen scheduled queries running hourly and daily. It is the canonical source for every Contribution Margin calculation. It has every cost component per transaction: payment gateway cost, delivery cost, points cost, recovery, upfront, CM, CM2.
  • Engineering has a “denormalized transaction” table that is refreshed every six hours by a MERGE statement and topped up daily by a Kubernetes cron job. It joins user, merchant, and locality data into one wide row.
  • Finance again has a delivery-specific “delivery orders dashboard” table that is rebuilt four times a day by combining the engineering table, the CM table, and the raw delivery charges from another schema.

Three “base-ish” tables. Three teams that maintain them. None of them produces the same number when you query for “delivery GMV last month.”

There are also hundreds of analytics scripts (illustrative from environments at this scale, not from a single engagement) pulling from raw CDC (Change Data Capture — the database-replication mechanism that streams source-DB inserts/updates/deletes into BigQuery, typically via Airbyte, Fivetran, or Datastream) tables directly. The single most-referenced raw dataset has thousands of references across those scripts. The most commonly joined raw table appears in a hundred-plus JOIN statements.

Every analyst, in every team, is independently reverse-engineering the same business logic out of CDC tables. Each of them has a slightly different mental model. None of the models are wrong; none of them agree.

This is the moment a “base layer” or “single source of truth” project gets proposed. (Half of them then fail because the team doesn’t understand the problem they’re solving.)

What’s actually different across the teams

Here is an illustrative divergence matrix drawn from the shape of environments like this. The team names are generic; the patterns are universal.

Finance team

  • Includes all redemption statuses (success, used, inactive, refunded) — refunds are handled separately inside the formula, not by exclusion.
  • Filters is_test_order ≠ 1 — single condition.
  • Includes cross-sell transactions (roa.tags LIKE '%cross_sell%').
  • Includes all ONDC types in the GMV bucket (eight distinct types in this environment, each with its own pricing math).
  • New user definition: month-of-first-transaction, computed inline as a CASE expression.
  • Date window: month-to-date.
  • CPO (Cost Per Order, the per-order delivery cost): per-merchant conditional formula. “If merchant = Starquik then 116; if merchant ID = 6904794 then 30; else default 62.”

Growth team

  • Includes only success, used, inactive (excludes refunds upfront).
  • Filters is_test_order = 0 AND campaign_tags NOT LIKE '%test_merchant%' (two-condition test exclusion).
  • Excludes cross-sell explicitly (roa.tags NOT LIKE '%cross_sell%').
  • Excludes four ONDC types (seller_native, partner_a_buyer, partner_b_buyer, partner_a_saas) — “we want true platform-native behavior only.”
  • New user definition: pre-computed first_redemption_date from a separate analytics table, with a source_of_acquisition dimension (Organic / Referral / Paid / BTL_Local / BTL_Brand).
  • Additionally bucketed by recency since first transaction (1-7 days / 7-23 days / 23-45 days / 45+ days).
  • Date window: rolling 30 days.

Delivery ops team

  • Counts only order_status = 'DELIVERED'.
  • Works at the order grain, not the transaction grain (different source table entirely).
  • CPO: provider lookup table, updated monthly with contracted rates per city and date range (Rapido = 105, Zomato varies 27-98 depending on city and date, Shadowfax = 115-123, Ola = 99-100, Yulu = 140; with surge modifiers for holidays).
  • Cashin: gross — uses the delivery_total_cost from the raw delivery charges table; no fee deductions.
  • ONDC: treated as a single category bucket. No type-level breakdown.
  • Date window: D0/D1/D7 rolling.
  • Many delivery ops scripts have no test order exclusion at all — this inflates the order count.

Brands team

  • “Brand transaction” requires merchant_priority = 1 (excludes all local merchants).
  • Includes platform-native ONDC orders (inflow_client_order_id LIKE '%PLT%'), excludes all other ONDC partners.
  • Revenue formula: 8 variants by product_type × ONDC_partner combination.
  • B2B tracking via a hardcoded list of 700+ voucher amount IDs in a separate brand-analytics table.
  • Uses parent_merchant_id for MBO (Multi-Brand Outlet) hierarchy — not used by any other team.
  • Excludes cross-sell same as growth.

Same month, same warehouse, same source tables. Four numbers.

Why this is not a data quality issue

The reflex when a CFO asks why two dashboards disagree is to assume someone has a bug. Investigate the higher number, find no bug, investigate the lower number, find no bug, and end the quarter no closer to alignment.

The four-team matrix above contains zero bugs. Every team’s formula is internally consistent, business-defensible, and audited. They produce different numbers because they are measuring different things — and each team has a legitimate reason to measure the thing they measure.

  • Finance reports to the board on realized CM including refunds, so they need refunds in the formula.
  • Growth reports on new-user behavior for paid acquisition ROI, so they need cross-sell out and the ONDC-partner-funded buyers out (those aren’t paid-acquisition users).
  • Delivery ops reports on operational SLA, so they only care about orders that actually delivered.
  • Brands report on brand-merchant performance, so they filter out the long tail of local merchants entirely.

If you forced all four teams to use the same definition, you would break three of the four reports. The CFO’s question is the wrong question. The right question is: which of these four is the right number for the decision in front of us? That’s a definitional question, not a data engineering question.

(This is also why every “single source of truth” project that starts with “let’s get all the teams to agree on one number” fails by month four. It is asking the org to renegotiate a politically settled boundary, which the org has no appetite for, in exchange for a technical convenience the org doesn’t value.)

Why semantic layers usually don’t solve this

The reflex in 2024-2026 is to reach for a semantic layer — dbt’s Semantic Layer, Cube.dev, Looker’s LookML, Tableau’s metrics framework. The promise is that you define metrics in one place and every tool reads them consistently.

For some companies this works beautifully. For most mid-market data teams, semantic layers fail the four-team test for three reasons:

  1. Adoption cost. Every team has to migrate their existing reports onto the semantic layer. The migration is itself a definitional negotiation — you don’t think your ONDC handling is the canonical one anymore? — and that negotiation is what killed the original alignment project. Semantic layers do not avoid the political problem; they re-introduce it as a tooling project.

  2. Mart-layer specificity. Semantic layers are good at defining one GMV. They are less good at defining four GMVs that all need to coexist. Each team ends up defining their own metric (gmv_finance, gmv_growth, gmv_delivery, gmv_brands), at which point the semantic layer is doing little more than what a well-designed mart layer in your warehouse would do anyway — and you’ve added a vendor relationship and a query-layer dependency on top.

  3. Performance and freshness. Semantic-layer query engines add latency. For interactive dashboards this is often acceptable; for the underlying batch processes that compute the metric daily, semantic layers introduce a runtime dependency that the team has to operate. Mid-market teams that don’t want to operate that dependency end up bypassing the semantic layer for batch and using it only for ad-hoc — at which point it’s solving a smaller problem than the architecture diagram suggested.

There is a simpler, lower-cost pattern that solves the four-team problem without renegotiating any definitions.

base.fact_transactionsraw measures + flagsFinanceall redemption statuses(refunds in formula)includes cross-sellall ONDC typesmonth-to-date windowGrowthexcludes refunds upfrontexcludes cross-sellexcludes 4 ONDC typesrolling 30 dayssource_of_acquisition dimDelivery Opsorder_status = DELIVERED onlyorder grain, not txn grainprovider CPO lookup tableD0/D1/D7 rolling windowsome scripts: no test exclusionBrandsmerchant_priority = 1 onlyPLT-ONDC includedother ONDC excluded8 revenue formula variantsB2B voucher list (700+ IDs)Finance ∩ Growthis_test_txn = FALSE(diverge on refund + ONDC)Finance ∩ Opsdelivery_total_costdifferent CPO formulaGrowth ∩ Brandsis_cross_sell = FALSEis_test_txn = FALSEOps ∩ Brandsis_delivered = TRUE(grain still differs)All four teams share: same warehouse · same source tables · same monthNearly universal: is_test_txn = FALSE — but even this diverges (Finance 1-condition; Growth 2-condition; Delivery ops: some scripts skip it)
Same metric, four definitions. Two of the teams diverge on test-order exclusion; three diverge on ONDC partner inclusion.

The star-schema-with-flags pattern

The pattern: a single fact table with all the raw measures, joined to a small set of dimension tables for current-state attributes, with pre-computed boolean flags for every classification decision the teams care about. Each team writes a WHERE clause on the flags appropriate to their definition.

The fact table doesn’t have an opinion. It has columns. The flags don’t have opinions. They have values. The opinion lives in each team’s WHERE clause — and crucially, the opinion is now visible. Anyone can read finance’s query and see that it doesn’t exclude cross-sell. Anyone can read growth’s query and see exactly which ONDC types are excluded.

Four teams, four queries, four numbers — all still different, all still legitimate, but now the difference is a WHERE clause anyone can read. The four-team matrix moves out of hundreds of analytics scripts and into the SQL itself. When finance and growth argue about a number, they can git diff each other’s WHERE clauses and resolve it in a Slack thread. Teams that ship a base layer typically watch this conversation collapse from a quarter to about ten minutes.

The next post is the build — DDL, fact-vs-dim decision rules, four-phase rollout.

Related posts