Why your revenue doubled overnight (dedup it in SQL)

ยท does not celebrate growth miracles before checking the join๐Ÿงน with a duplicate-detector query, not a lecture

Published September 6, 2026

The 60-second version

  • Rows multiply in three places: retried ingestion, fan-out joins, and counting events as orders.
  • Deduplicate on business identity (order_id, event_id) โ€” never on full-row equality, which silently fails.
  • Aggregate before joining, always. The fan-out bug reports 10x revenue and looks completely plausible.
bottom line: one reality, one row

Revenue doubled overnight. Nobody celebrated, because nobody had doubled anything โ€” a query had. Somewhere between the events table and the dashboard, rows multiplied: a fan-out join, an untracked duplicate webhook, a UNION that overlapped. The business spent a week debating a growth miracle that never happened.

Duplicate counting is the most common data-correctness bug in marketing warehouses, and the hardest to notice, because every wrong number looks like a plausible right one. This post catalogues the three ways rows multiply, the diagnostic queries that catch each, and the dedup patterns that prevent them. if revenue jumps, check the join first

Rows multiply in three places

Where Duplicates Come From

Data Journey
Stage 1Same event twice
Ingestion duplicates

Retried webhooks, double-fired pixels, overlapping backfills insert the same order or event two or more times.

2 rows, 1 reality
Stage 2Join multiplies
Fan-out joins

Joining orders to a multi-row dimension (or spend to orders on date) multiplies rows at query time. Nothing is stored wrong; everything computed is.

1 order ร— 10 campaigns = 10 rows
Stage 3Counting at wrong level
Grain confusion

COUNT(*) on event-grain data reported as orders, or SUM over a UNION with overlapping windows. Correct rows, wrong question.

Clicks reported as purchases

The defining symptom: metrics that scale with the wrong thing. Revenue that grows when you add campaigns (fan-out), conversions that spike on backfill days (ingestion), AOV that halves when event volume doubles (grain confusion). If a metric moves with plumbing rather than business, suspect multiplication first.


Cause 1: ingestion duplicates

Webhooks retry. Pixels fire on back-button revisits. A backfill overlaps the streaming pipeline by a day. Each produces two rows describing one reality, and SUM(revenue) happily counts both.

Diagnosis: find the doubles

Duplicate Detector โ€” Run Before Every New Source Goes Live

Spikes on specific days point at backfills and retries; a constant low rate points at a double-firing tag. Either way, the fix belongs upstream (idempotent loads, MERGE on event ID) โ€” but reporting must be robust to it regardless.

Upstream ยท stop creating doubles

๐Ÿšฐ Fix the pipe

Idempotent loads and MERGE on event ID. Retried webhooks and overlapping backfills collapse to one row at write time.

  • Idempotent ingestion keys
  • MERGE on event_id, not append
  • Backfills that cannot overlap
Downstream ยท survive the doubles

๐Ÿ›ก๏ธ Defend the report

Deduped views as the only readable surface. Even a perfect pipe has bad days; reports must not depend on perfection.

  • One blessed v_orders_deduped per table
  • Raw kept immutable for forensics
  • Detector query running daily
LIVE ยท DRAG ITWhat is your duplicate rate costing you?
โ‚น42L
9%

From the duplicate detector above โ€” 0% is the only healthy reading

True revenue
โ‚น38.2L
Phantom revenue
โ‚น3.8L
MEASURABLE DRIFT

Small enough to look like noise, large enough to move decisions. Fix the pipe and defend the report.

live mathsestimates are fine โ€” this is a what-if sandbox

Prevention: deduplication as a view, not a hope

-- Canonical orders: one row per order_id, latest record wins.
-- Every revenue report reads this view, never the raw table.

CREATE VIEW v_orders_deduped AS
SELECT *
FROM (
  SELECT
    o.*,
    ROW_NUMBER() OVER (
      PARTITION BY order_id
      ORDER BY _ingested_at DESC
    ) AS _rn
  FROM raw_orders o
)
WHERE _rn = 1
  AND order_status = 'completed';

Deduplicate on business identity, not on full-row equality. Two rows for order 88412 with different _ingested_at timestamps are never byte-identical, so SELECT DISTINCT * will not save you. The dedup key must be the business key (order_id, event_id) โ€” which is why every table needs one defined at creation.


Cause 2: fan-out joins

This is the famous one, covered briefly in the ROAS-reconciliation post and worth a full treatment because it bites every team exactly once. Joining a 1,000-row orders table to a 10-row daily-spend table on date produces up to 10,000 rows: each order repeated per campaign. SUM(revenue) then reports 10x reality โ€” and the more campaigns you run, the richer you look.

The 10x revenue day

Fan-out in action
Real ordersโ‚น4.2L from 210 ordersNothing wrong in either source table
Campaigns that day10 activeEach spend row matched every order on date
Reported revenueโ‚น42L10x โ€” SUM counted each order ten times
FixAggregate, then joinOne row per day per side before the join
The rule is absolute: never join tables at different grains and aggregate afterwards. Aggregate each side to the join grain first, then join. No exceptions, no matter how innocent the query looks.
-- THE PATTERN: aggregate each side to the join grain FIRST.
WITH daily_spend AS (
  SELECT spend_date, SUM(cost) AS spend
  FROM raw_ad_spend GROUP BY 1
),
daily_revenue AS (
  SELECT DATE(order_created_at, 'Asia/Kolkata') AS day,
         SUM(net_total) AS revenue
  FROM v_orders_deduped GROUP BY 1
)
SELECT day, spend, revenue
FROM daily_revenue FULL OUTER JOIN daily_spend
  ON daily_revenue.day = daily_spend.spend_date;

Cause 3: grain confusion

An events table has one row per event; a purchase funnel has many events per order (view, add-to-cart, checkout, purchase). COUNT(*) where you meant COUNT(DISTINCT order_id), or summing a value column that repeats on every event of the same order, inflates by the events-per-order ratio โ€” typically 3โ€“8x, varying by funnel length, which makes it look like a trend rather than a bug.

Counting at the Right Grain

Reporting Hierarchy
Tier 1
Event grain

Funnel analysis: how many checkouts followed add-to-carts. COUNT(*) is correct here.

COUNT(*) WHERE event_name = 'purchase'
Tier 2
Order grain

Revenue reporting: one row per order_id. Deduplicate first, count distinct always.

COUNT(DISTINCT order_id), SUM over deduped view
Tier 3
Customer grain

Retention and LTV: one row per customer per period. Join orders to customers, then aggregate.

GROUP BY customer_id over order-grain data

The dedup checklist

New table, same discipline ship all four with the table โ†’

  • Name the business key on day one โ€” order_id, event_id, click_id: the column whose duplication defines duplicate. No key, no dedup possible later.
  • Ship the deduped view with the table โ€” v_table_deduped lands in the same change as the table. Reports read the view; raw stays queryable for forensics.
  • Add the duplicate detector to scheduled checks โ€” the diagnostic query above, run daily, alerting on new duplicates. Backfills and tag regressions announce themselves.
  • Audit every join for grain mismatch โ€” different grains on two sides of a join is a fan-out until proven otherwise. Aggregate first, join second.

Frequently Asked Questions

Our revenue is only off by 2โ€“3%. Is that still duplicates?

Possibly โ€” but at that scale also suspect refunds timing, timezone edges and FX rounding. Run the duplicate detector: it costs one query and converts suspicion into a measured duplicate rate. Fix what you can measure; the residual 1โ€“2% is usually definitional (gross vs net) rather than duplication. measure first, argue later

Revenue doubled overnight โ€” on the same day you launched five new campaigns. What do you check first?

Should dedup happen in dbt/models or in views?

Wherever your team actually maintains it. The principle is location-independent: raw stays immutable, exactly one blessed deduped object exists per table, and every report reads the blessed object. A beautiful dbt model nobody runs and a view everybody reads โ€” the view wins.

How does this relate to bytes scanned and cost?

Dedup views with ROW_NUMBER() OVER (PARTITION BY ...) scan the full table on every read. On large event tables, materialise the deduped result on a schedule (the incremental pattern from the no-dbt post) instead of recomputing it per query โ€” correctness and cost optimised together.


Summary & Next Steps

Rows multiply at ingestion (same event twice), at join time (fan-out), and at counting time (wrong grain). Diagnose with the duplicate-rate query, prevent with per-table dedup views keyed on business identity, and make aggregate-before-join an absolute rule.

  • Use deduplication views as the only readable surface for revenue tables.
  • Use schema mapping to record each table's grain and business key where analysts will find it.
  • Use partitioning plus scheduled materialisation so correctness does not become a cost problem.

Takeaways for your next report

  • Rows multiply at ingestion (same event twice), at join time (fan-out), and at counting time (wrong grain) โ€” and every wrong number looks plausible.
  • Diagnose with the duplicate-rate query; deduplicate on business identity in blessed views that every report reads.
  • Aggregate each side to the join grain before joining โ€” no exceptions, no matter how innocent the query looks.
  • Count at the right grain: events for funnels, distinct order_ids for revenue, customers for retention.
stick this on your Monday report
Free tool

MER Calculator

Total revenue divided by total marketing spend โ€” the attribution-agnostic efficiency number, plus its contribution-margin-adjusted variant.

Chinmay Raibagkar

Chinmay Raibagkar

About author โ†’

Founder of DataLens AI. He helps non-technical teams read their ad and database numbers with confidence โ€” which number to trust, what to do next, and what to ignore.