Databases & Query Optimization

Why Your Revenue Doubled Overnight: Deduplicating Orders and Events in SQL

By Chinmay Raibagkar·September 6, 2026·11 min read·Deep dive

The 60-second version

Ingestion doubles, fan-out joins and grain confusion multiply rows three different ways. The diagnostics that catch each and the dedup views that prevent them.

  • What happened, in one line
  • What to do about it this week
  • What you can safely ignore

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.

The short version: 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 — the same event stored twice

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

Show query

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.

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 — correct tables, multiplied results

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 — counting events as orders

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 for every new table

New Table, Same Discipline

Process Flow
1

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.

2

Ship the deduped view with the table

v_<table>_deduped lands in the same PR as the table. Reports read the view; raw stays queryable for forensics.

3

Add the duplicate detector to scheduled checks

The diagnostic query above, run daily, alerting on new duplicates. Backfills and tag regressions announce themselves.

4

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.

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.
Free tool

MER Calculator

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

CR

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.