← Back to list

Eliminating Fan-out Double-counting in Multi-tenant Financial Views using BigQuery ROW_NUMBER()…

Author: Lalithesh Role: Backend Engineer Published: June 2026 Tags: BigQuery SQL Window Functions Backend Engineering Financial Data Data…

Lalithesh Akula · 2026-06-12 19:12 · 0 claps · 5.4 min read
#bigquery #fanout
Open on Medium ↗
Wiki topics: ECO · Economy · General LIT · Literature & Writing 🌐 · Web Development 🔧 · Data Engineering

Eliminating Fan-out Double-counting in Multi-tenant Financial Views using BigQuery ROW_NUMBER() Window Functions

Author: Lalithesh Role: Backend Engineer Published: June 2026 Tags: BigQuery SQL Window Functions Backend Engineering Financial Data Data Quality

The Problem: Silent Revenue Inflation You Never See Coming

As a backend engineer building financial reporting pipelines, one of the most dangerous bugs I’ve encountered is fan-out double-counting — a silent aggregation error caused by JOIN operations that produce more rows than the intended grain of your query.

Unlike null pointer exceptions or schema mismatches, fan-out does not throw an error. Your query runs successfully. Your numbers look reasonable. But your revenue totals, transaction counts, or any other aggregated metric are quietly inflated — sometimes by 2x, 3x, or more — depending on how many duplicate rows exist on the join side.

This post walks through what fan-out is, how to detect it, and how to permanently fix it using BigQuery’s ROW_NUMBER() window function — a pattern I've applied in production financial reporting pipelines handling data across multiple tenants and brands.

What Is Fan-out?

Fan-out occurs when a JOIN between two tables results in more rows than the left table’s grain because the right-side table has multiple rows matching a single join key.

Minimal Example

sql

-- Table A: transactions (1 row per transaction)
-- Table B: config_table (should be 1 row per entity, but has duplicates)
SELECT
  a.entity_id,
  SUM(a.amount) AS total_amount   -- ⚠️ INFLATED due to fan-out
FROM transactions a
JOIN config_table b ON a.entity_id = b.entity_id
GROUP BY a.entity_id

If config_table has 3 rows for entity_id = 'E001', every transaction for E001 joins to all 3 rows — tripling the revenue for that entity without any warning.

Why It’s Especially Dangerous in Financial Systems

  • Aggregates pass basic sanity checks — values are positive, non-null, and plausible
  • It only surfaces when the right-side join table has duplicate keys
  • Different entities may fan out at different multipliers, making cross-entity comparisons meaningless
  • It can exist undetected in production for months until an external reconciliation catches the discrepancy

Step 1: Diagnose Fan-out in Your Join Table

Before writing any fix, confirm the root cause. Run this diagnostic against the right-side join table:

sql

-- Detect duplicate join keys in the dimension/config table
SELECT
  entity_id,
  COUNT(*) AS row_count
FROM config_table
GROUP BY entity_id
HAVING COUNT(*) > 1
ORDER BY row_count DESC

If this returns rows, you have a fan-out source. Note which entity_id values have the highest duplicate counts — those are the entities with the most inflated aggregates.

Step 2: Fix with ROW_NUMBER() Deduplication

The standard backend engineering fix is to assign a row rank within each duplicate group using ROW_NUMBER(), then filter to retain only one canonical row per join key before performing the JOIN.

sql

WITH deduped_config AS (
  SELECT
    *,
    ROW_NUMBER() OVER (
      PARTITION BY entity_id
      ORDER BY updated_at DESC    -- Retain the most recently updated row
    ) AS rn
  FROM config_table
),
canonical_config AS (
  SELECT * EXCEPT(rn)
  FROM deduped_config
  WHERE rn = 1                    -- ✅ One row per entity_id
),
base_transactions AS (
  SELECT
    entity_id,
    period,
    amount
  FROM transactions
)
SELECT
  t.entity_id,
  t.period,
  SUM(t.amount) AS total_amount   -- ✅ Accurate, fan-out eliminated
FROM base_transactions t
JOIN canonical_config c ON t.entity_id = c.entity_id
GROUP BY t.entity_id, t.period

Key Design Decisions

**PARTITION BY entity_id** — defines deduplication granularity. Every row in the same partition competes for rank 1. Adjust this to match the actual join key of your query.

**ORDER BY updated_at DESC** — deterministic tie-breaking. This is critical. Without a deterministic ORDER BY, BigQuery may return different rows across query executions, producing non-reproducible results. Always tie-break on a reliable timestamp, version number, or surrogate key.

**SELECT * EXCEPT(rn)** — cleanly removes the helper column before the JOIN, keeping the downstream CTE schema clean.

**WHERE rn = 1** — the deduplication filter. Only the top-ranked row per partition survives into the join.

Step 3: Handle Sign Convention for Financial Amounts

A related issue in financial database pipelines is sign convention for credit/debit accounts. In standard double-entry accounting, revenue accounts carry credit balances (stored as negative values) in the general ledger. If consumed directly in reporting queries, revenue surfaces as a negative number in dashboards.

The fix is a CASE-based sign-flip applied before aggregation:

sql

SELECT
  entity_id,
  period,
  account_type,
  CASE
    WHEN account_type = 'REVENUE' THEN -1 * amount
    WHEN account_type = 'EXPENSE' THEN amount
    ELSE amount
  END AS normalized_amount
FROM ledger_entries

This pattern ensures all downstream aggregations work with economically correct signs regardless of how the source system stores account balances.

Step 4: Validate the Fix — Reconciliation Query

After applying the deduplication fix, validate that your corrected totals match the source system of record:

sql

-- Post-fix aggregation
SELECT
  entity_id,
  period,
  SUM(total_amount) AS corrected_revenue
FROM corrected_financial_view
GROUP BY entity_id, period
-- Compare against source system totals
-- Expected: delta = 0 for all entity_id + period combinations

A zero delta across all entities confirms the fan-out has been fully eliminated. Any residual delta indicates additional fan-out sources that need investigation.

Step 5: Applying the Pattern in View Definitions

In production, this deduplication logic should live inside the database view definition — not in application-layer queries or ORM filters. Centralizing it at the view layer ensures:

  • Every downstream consumer of the view gets deduplicated data automatically
  • Application-layer code does not need to replicate the fix
  • Changes to the deduplication logic propagate to all consumers in a single deployment
  • Query plans are optimized once at the view layer rather than re-executed per consumer

sql

-- Production view definition (BigQuery)
CREATE OR REPLACE VIEW `your_project.your_dataset.financial_summary_view` AS
WITH deduped_config AS (
  SELECT
    *,
    ROW_NUMBER() OVER (
      PARTITION BY entity_id
      ORDER BY updated_at DESC
    ) AS rn
  FROM `your_project.your_dataset.config_table`
),
canonical_config AS (
  SELECT * EXCEPT(rn)
  FROM deduped_config
  WHERE rn = 1
)
SELECT
  t.entity_id,
  t.period,
  c.entity_name,
  c.region,
  SUM(
    CASE
      WHEN t.account_type = 'REVENUE' THEN -1 * t.amount
      ELSE t.amount
    END
  ) AS net_amount
FROM `your_project.your_dataset.transactions` t
JOIN canonical_config c ON t.entity_id = c.entity_id
GROUP BY t.entity_id, t.period, c.entity_name, c.region

Performance Considerations in BigQuery

ROW_NUMBER() is a window function and incurs a sort + partition operation. For large tables, keep these optimizations in mind:

Consideration

Recommendation

Partition pruning

Always filter on partition columns (e.g., WHERE date >= ...) before the window function CTE

Clustering

If the config/dimension table is clustered on entity_id, the PARTITION BY entity_id scan is cheaper

Materialization

For very large config tables with frequent joins, consider materializing the deduplicated CTE as a separate table or scheduled query result

Slot usage

Window functions consume more slots than simple scans — profile with BigQuery’s Query Plan Explanation for high-frequency pipelines

Common Mistakes to Avoid

1. Non-deterministic ORDER BY in ROW_NUMBER()

sql

-- ❌ Wrong: no ORDER BY means arbitrary row selection
ROW_NUMBER() OVER (PARTITION BY entity_id) AS rn
-- ✅ Correct: deterministic tie-break
ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY updated_at DESC) AS rn

2. Fixing fan-out in the SELECT clause instead of upstream

sql

-- ❌ Wrong: dividing by COUNT(*) to "undo" fan-out
SUM(amount) / COUNT(DISTINCT config_id) AS patched_amount
-- ✅ Correct: eliminate the duplicate rows before aggregation

3. Assuming DISTINCT fixes fan-out

sql

-- ❌ Wrong: DISTINCT on the outer query doesn't help if rows differ in any column
SELECT DISTINCT entity_id, SUM(amount) ...
-- ✅ Correct: deduplicate the join table specifically on the join key

Key Takeaways

Concept

Detail

Root cause

Right-side JOIN table has multiple rows per join key

Detection

GROUP BY join_key HAVING COUNT(*) > 1

Fix

ROW_NUMBER() OVER (PARTITION BY join_key ORDER BY ...) + WHERE rn = 1

Tie-breaking

Always deterministic — use timestamp, version, or surrogate key

Sign convention

Credit/Revenue accounts require -1 * negation for correct financial reporting

Validation

Reconcile post-fix aggregates against source system totals

Architecture

Centralize fix in the view definition, not the application layer

Conclusion

Fan-out double-counting is a silent, high-impact correctness bug that is particularly costly in financial reporting systems where accuracy is non-negotiable. As a backend engineer, the right instinct is to treat it as a data integrity issue at the database layer — not something to patch in application code.

The ROW_NUMBER() deduplication pattern is deterministic, performant at scale in BigQuery, and composable — it fits cleanly into CTE-based view definitions without affecting the readability of downstream query logic.

If you’re building financial data pipelines with multi-tenant or multi-entity JOIN patterns, make this deduplication step a standard part of your view design checklist.


메타데이터
post_id
26b0a1bbd4e0
slug
eliminating-fan-out-double-counting-in-multi-tenant-financial-views-using-bigquery-row-number-26b0a1bbd4e0
url
https://medium.com/@lalithesh.11/eliminating-fan-out-double-counting-in-multi-tenant-financial-views-using-bigquery-row-number-26b0a1bbd4e0
canonical_url
https://medium.com/@lalithesh.11/eliminating-fan-out-double-counting-in-multi-tenant-financial-views-using-bigquery-row-number-26b0a1bbd4e0
author_url
https://medium.com/@lalithesh.11
status
ok
fetched_at
2026-07-10 01:40:30