A deterministic, auditable pattern to find, flag, and quarantine duplicates — while row-level DQ “breadcrumbs” flow all the way to your data products.
Summary
ROW_NUMBER()must have anORDER BY. Without it your “first row” is non-deterministic.- Don’t hide evidence with
DISTINCT/UNION/GROUP BY. Instead: Detect → Flag → Route. - Add row-level DQ status/labels and reasons; carry them downstream.
- Publish two outputs: a golden dataset (winners) and a quarantine (losers for analysis).
- Track metrics and alert on spikes — keep pipelines running while making issues visible.

The classic mistake (and the fix)
– ❌ Non-deterministic (may pick different rows per run)
SELECT user_id, email, created_at
FROM users
QUALIFY ROW_NUMBER() OVER (PARTITION BY email) = 1;
This can return different winners because there’s no sort rule.
– ✅ Deterministic: stable order + tie-breaker(s)
SELECT user_id, email, created_at
FROM users
QUALIFY ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, ingested_at DESC, user_id
) = 1;
Guideline for **ORDER BY** (adapt as needed):
- Business time (
effective_at/created_at) - Ingest time (
ingested_at) - Source priority (e.g., CRM > CSV upload)
- Stable surrogate (
id/row_hash)
If your engine lacks QUALIFY (e.g., some Spark/Databricks), wrap in a subquery and WHERE rn = 1.
Why governed deduplication?
Real sources often contain duplicates. We don’t want critical loads to halt for a few bad rows, and we don’t want to silently erase them either. We want a deterministic winner, we want to see and analyze the losers, and we want DQ breadcrumbs that travel with the data so downstream consumers understand provenance and quality.
The pattern: Detect → Flag → Route
1) Detect & rank
WITH ranked AS (
SELECT
u.,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, ingested_at DESC, user_id
) AS rn,
COUNT() OVER (PARTITION BY email) AS dup_ct
FROM raw.users u
)
2) Flag with row-level DQ labels
, flagged AS (
SELECT
*,
CASE
WHEN dup_ct = 1 THEN ‘OK’
WHEN rn = 1 THEN ‘OK_PRIMARY’ – winner kept for downstream use
ELSE ‘DQ_DUPLICATE’ – loser quarantined
END AS dq_status,
CASE
WHEN dup_ct = 1 THEN NULL
WHEN rn = 1 THEN NULL
ELSE ‘Duplicate key; non-winner by created_at/ingested_at/id order’
END AS dq_reason
FROM ranked
)
3) Route into two clear outputs
- Golden dataset (use in downstream transforms):
SELECT *
FROM flagged
WHERE dq_status IN (‘OK’,’OK_PRIMARY’);
- Quarantine table/view (for ops & BI DQ dashboards):
CREATE TABLE IF NOT EXISTS dq.duplicates_users AS
SELECT *
FROM flagged
WHERE dq_status = ‘DQ_DUPLICATE’;
The same logic can drive alerts, dashboards, and root-cause investigations.
Why not DISTINCT, UNION, or GROUP BY?
They hide which row won, throw away columns or force arbitrary aggregates, and make audits difficult. Windowed ranking + flags preserves the evidence and keeps your pipeline moving.
Engine-specific snippets
Snowflake / BigQuery
Use QUALIFY as shown above. Make NULL handling explicit if needed:
ORDER BY
(effective_at IS NULL), – put NULLs last
effective_at DESC,
ingested_at DESC,
row_hash
Databricks / Spark SQL
No QUALIFY? Use a subquery:
SELECT
FROM (
SELECT u.,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, ingested_at DESC, user_id
) AS rn,
COUNT(*) OVER (PARTITION BY email) AS dup_ct
FROM raw.users u
) t
WHERE rn = 1; – golden
– losers: WHERE rn > 1
Postgres
Same subquery pattern. DISTINCT ON can be deterministic only if paired with a complete ORDER BY, but you still need to persist losers elsewhere—so the ranking pattern is clearer and more auditable.
dbt implementation (with breadcrumbs)
Model: **stg_users_dedup.sql**
(Produces the golden dataset but keeps DQ columns.)
WITH ranked AS (
SELECT
,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, ingested_at DESC, user_id
) AS rn,
COUNT() OVER (PARTITION BY email) AS dup_ct
FROM {{ ref(‘stg_users_raw’) }}
),
flagged AS (
SELECT
*,
CASE WHEN dup_ct=1 THEN ‘OK’
WHEN rn=1 THEN ‘OK_PRIMARY’
ELSE ‘DQ_DUPLICATE’ END AS dq_status,
CASE WHEN dup_ct=1 THEN NULL
WHEN rn=1 THEN NULL
ELSE ‘Duplicate key; non-winner’ END AS dq_reason
FROM ranked
)
SELECT * FROM flagged WHERE dq_status IN (‘OK’,’OK_PRIMARY’);
Companion quarantine model: **dq_users_duplicates.sql**
SELECT * FROM {{ ref(‘stg_users_dedup__flagged’) }}
WHERE dq_status = ‘DQ_DUPLICATE’;
Tests (YAML)
models:
– name: stg_users_dedup
columns:
– name: email
tests:
– dbt_utils.unique_combination_of_columns:
combination_of_columns: [‘email’]
where: “dq_status in (‘OK’,’OK_PRIMARY’)”
– name: dq_status
description: “Row-level DQ label that propagates downstream”
Observability playbook
Metrics to store per run
total_rows,duplicate_rows,%_duplicates- Top duplicate keys (e.g.,
email) - Source/system breakdown, batch/file identifiers
INSERT INTO dq.run_metrics
(run_id, table_name, total_rows, duplicate_rows, pct_duplicates, run_ts)
SELECT
{{ var(‘run_id’) }}, ‘users’,
COUNT(),
SUM(CASE WHEN dq_status=’DQ_DUPLICATE’ THEN 1 ELSE 0 END),
SAFE_DIVIDE(SUM(CASE WHEN dq_status=’DQ_DUPLICATE’ THEN 1 ELSE 0 END), COUNT()),
CURRENT_TIMESTAMP();
Dashboard quick spec (Power BI / Looker)
- KPIs: duplicate count, % duplicates, winners vs. losers, 30-day trend
- Slices: by
source_system,file_batch_id, ingestion path - Drill: show duplicate groups (winner + losers with order columns &
dq_reason) - Alerts: trigger on
% duplicatesthreshold or sudden spikes vs. rolling mean
Edge cases & tips
- Late arrivals: ordering on both business and ingest time re-resolves winners deterministically on reprocess.
- Composite keys: partition by all parts of the business key (e.g.,
account_id, email). - Source reliability: introduce
source_priorityinto the order to prefer trusted systems. - NULL semantics: decide explicitly (NULLs first/last) and encode in the
ORDER BY. - Provenance: store
dq_source_keys(file id, row nr) and arow_hashto trace lineage. - Downstream propagation: keep
dq_statusanddq_reasonin your semantic model; consumers should see that a record is derived from rows with DQ issues.
Ready-to-use mini-template
– GOLDEN OUTPUT
WITH ranked AS (
SELECT ,
ROW_NUMBER() OVER (
PARTITION BY business_key
ORDER BY effective_at DESC,
ingested_at DESC,
source_priority DESC,
row_hash
) AS rn,
COUNT() OVER (PARTITION BY business_key) AS dup_ct
FROM src.table
),
flagged AS (
SELECT *,
CASE WHEN dup_ct=1 THEN ‘OK’
WHEN rn=1 THEN ‘OK_PRIMARY’
ELSE ‘DQ_DUPLICATE’ END AS dq_status,
CASE WHEN dup_ct>1 AND rn>1 THEN ‘Duplicate; non-winner’ END AS dq_reason
FROM ranked
)
SELECT * FROM flagged WHERE dq_status IN (‘OK’,’OK_PRIMARY’);
Key takeaways
- Pair
ROW_NUMBER()with a deterministicORDER BYand a clear tie-breaker chain. - Treat deduplication as governance, not deletion.
- Flag and quarantine duplicates; propagate DQ breadcrumbs to the end of your pipelines.
- Add metrics, dashboards, and alerts to make quality visible without blocking delivery.
- Keep the process idempotent: same inputs → same winners, every time.
If this pattern fits your stack, you can lift the snippets directly for Snowflake, BigQuery, Databricks/Spark, or Postgres. Keep the DQ columns visible to analysts, wire up a small BI page for monitoring, and your pipelines keep flowing — even when the real world sends you duplicates.