Why Your Revenue Doubled Overnight: Deduplicating Orders and Events in SQL
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 JourneyIngestion duplicates
Retried webhooks, double-fired pixels, overlapping backfills insert the same order or event two or more times.
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.
Grain confusion
COUNT(*) on event-grain data reported as orders, or SUM over a UNION with overlapping windows. Correct rows, wrong question.
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
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
-- 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 HierarchyEvent grain
Funnel analysis: how many checkouts followed add-to-carts. COUNT(*) is correct here.
Order grain
Revenue reporting: one row per order_id. Deduplicate first, count distinct always.
Customer grain
Retention and LTV: one row per customer per period. Join orders to customers, then aggregate.
The dedup checklist for every new table
New Table, Same Discipline
Process FlowName 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 PR 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.
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.
MER Calculator
Total revenue divided by total marketing spend — the attribution-agnostic efficiency number, plus its contribution-margin-adjusted variant.
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.