sqlintermediate45 minDuckDB SQL
Idempotent Event Deduplication with Window Functions
Eliminate duplicate webhook events and resolve status conflicts deterministically using ROW_NUMBER() in SQL.
The incident
- Company
- CashLink Payments
- Engineer
- Vikram Malhotra, Lead Data Platform Engineer
- Trigger
- Upstream webhook delivery retries created duplicate payment records, triggering double-settlement payouts to merchants.
- Stakes
- Over 22 lakh rupees in disputed merchant double-settlements during peak festive sale transactions.
On a Tuesday morning, Vikram Malhotra was alerted to an urgent escalation from merchant settlement operations at CashLink Payments, a high-volume payment processing gateway. During a major festive clearance sale, network timeouts between banking partners and CashLink webhook ingestion endpoints caused automated delivery retries. Multiple webhook payloads with identical transaction reference codes flooded into the raw_payments ledger. Network retries created duplicate events across multiple payment channels, threatening the accuracy of daily settlement reconciliation files.
Because the legacy settlement pipeline executed naive batch summations, duplicate transaction records were treated as independent merchant sales. Several prominent e-commerce merchants received duplicate settlement credits, creating over 22 lakh rupees in disputed overpayments. The accounting risk committee demanded an immediate, idempotent deduplication model to guarantee exactly-once payout semantics. Merchant partners were disputing ledger entries, and accounting teams spent hours manually identifying duplicate records across settlement batches.
Vikram connected to DuckDB to inspect the raw_payments table. Each row captured payment_id, transaction_ref, merchant_id, amount, status ('PENDING', 'SUCCESS', 'FAILED'), and received_at. Crucially, the retry payloads were not identical clones: an initial attempt might arrive with status 'PENDING', followed seconds later by a successful retry with status 'SUCCESS'. Simple distinct filtering was incapable of resolving these status conflicts. Vikram recognized that naive deduplication would corrupt the audit trail by discarding critical status progression history.
To build a reusable deduplication pipeline, Vikram needed to define deterministic tie-breaking rules. For any given transaction reference, the model had to prioritize successful completion status over transient pending states, and prioritize the latest received timestamp when statuses were identical. He structured a query utilizing the ROW_NUMBER() window function partitioned by transaction_ref. By applying deterministic multi-column ordering within each partition, Vikram guaranteed that the query would select the single authoritative transaction record every time.
Within three hours, Vikram validated the deduplication logic against the holiday transaction backlog. The query evaluated 6 raw payment events across three distinct transactions (TXN_9001, TXN_9002, TXN_9003) and cleanly collapsed them into exactly 3 authoritative settlement rows, successfully isolating the confirmed SUCCESS events while discarding transient PENDING and FAILED noise. The resulting settlement records accurately reflected final transaction outcomes.
The engineering leadership deployed Vikram query into the core settlement ingestion layer as a mandatory dbt materialization. Automated reconciliation verified zero double-payouts for the remainder of the festival sale, safeguarding 22 lakh rupees in working capital and establishing idempotent deduplication as an enterprise architectural standard. The deduplication logic was integrated into automated data quality gates. With automated checks verifying zero duplicate records across daily batch runs, financial operations restored complete confidence in high-velocity settlement processing, cementing data platform reliability as a strategic operational differentiator.
What you will learn
- Partition incoming event streams by business reference keys using ROW_NUMBER()
- Implement multi-column deterministic tie-breaking logic incorporating business status priorities
- Filter duplicate records within Common Table Expressions using row number qualification
- Guarantee idempotent settlement processing across distributed, at-least-once message pipelines
What you need first
ROW_NUMBER PartitioningSQL window functions
ROW_NUMBER() assigns consecutive integers starting at 1 within each partition.
SELECT payment_id, transaction_ref, ROW_NUMBER() OVER (PARTITION BY transaction_ref ORDER BY received_at DESC) AS rn FROM raw_payments LIMIT 3;payment_id | transaction_ref | rn -----------+-----------------+--- 2 | TXN_9001 | 1 1 | TXN_9001 | 2 3 | TXN_9002 | 1
Using RANK() instead of ROW_NUMBER(), which allows duplicate rank 1 values when order expressions tie.
Deterministic Tie-BreakingSQL case statements
Ordering by CASE WHEN status = 'SUCCESS' THEN 1 ELSE 2 END guarantees that finalized successful payments always rank ahead of pending retries.
SELECT 'SUCCESS' AS status, CASE WHEN 'SUCCESS' = 'SUCCESS' THEN 1 ELSE 2 END AS priority;status | priority --------+--------- SUCCESS | 1
Sorting solely by received_at, which can select a late-arriving FAILED notification over an earlier confirmed SUCCESS.
Architecture and data flow
The data
Raw payment event logs containing retry duplicates and variable completion statuses.
raw_payments: Raw payment webhook ingestion stream
| payment_id | transaction_ref | merchant_id | amount | status | received_at |
|---|---|---|---|---|---|
| 1 | TXN_9001 | 501 | 1200 | PENDING | 2024-03-01 10:00:00 |
| 2 | TXN_9001 | 501 | 1200 | SUCCESS | 2024-03-01 10:00:05 |
| 3 | TXN_9002 | 502 | 3400 | SUCCESS | 2024-03-01 10:05:00 |
| 4 | TXN_9003 | 503 | 850 | PENDING | 2024-03-01 10:10:00 |
| 5 | TXN_9003 | 503 | 850 | FAILED | 2024-03-01 10:10:02 |
CREATE OR REPLACE TABLE raw_payments (
payment_id INT,
transaction_ref VARCHAR,
merchant_id INT,
amount DOUBLE,
status VARCHAR,
received_at TIMESTAMP
);
INSERT INTO raw_payments VALUES
(1, 'TXN_9001', 501, 1200.0, 'PENDING', TIMESTAMP '2024-03-01 10:00:00'),
(2, 'TXN_9001', 501, 1200.0, 'SUCCESS', TIMESTAMP '2024-03-01 10:00:05'),
(3, 'TXN_9002', 502, 3400.0, 'SUCCESS', TIMESTAMP '2024-03-01 10:05:00'),
(4, 'TXN_9003', 503, 850.0, 'PENDING', TIMESTAMP '2024-03-01 10:10:00'),
(5, 'TXN_9003', 503, 850.0, 'FAILED', TIMESTAMP '2024-03-01 10:10:02'),
(6, 'TXN_9003', 503, 850.0, 'SUCCESS', TIMESTAMP '2024-03-01 10:10:10');Step by step
Read each step, run its code, then check your output against the one shown.
Query raw webhook payments ledger
We start by querying the raw_payments table ordered by transaction_ref and received_at. Inspecting the raw data reveals duplicate records: TXN_9001 appears twice (PENDING, SUCCESS), while TXN_9003 appears three times across PENDING, FAILED, and SUCCESS. In payment engineering, verifying raw payload characteristics ensures our deduplication logic handles variable status transitions and timestamp intervals without dropping legitimate distinct transactions. We inspect the raw ingestion stream to confirm that duplicate records share consistent business reference keys before applying deterministic ranking rules. In high-throughput payment architectures, understanding the arrival order and state transitions of webhook retries prevents faulty deduplication logic from dropping valid subsequent updates.
SELECT payment_id, transaction_ref, merchant_id, amount, status, received_at FROM raw_payments ORDER BY transaction_ref, received_at;payment_id | transaction_ref | merchant_id | amount | status | received_at -----------+-----------------+-------------+--------+---------+-------------------- 1 | TXN_9001 | 501 | 1200 | PENDING | 2024-03-01 10:00:00 2 | TXN_9001 | 501 | 1200 | SUCCESS | 2024-03-01 10:00:05 3 | TXN_9002 | 502 | 3400 | SUCCESS | 2024-03-01 10:05:00 4 | TXN_9003 | 503 | 850 | PENDING | 2024-03-01 10:10:00 5 | TXN_9003 | 503 | 850 | FAILED | 2024-03-01 10:10:02 6 | TXN_9003 | 503 | 850 | SUCCESS | 2024-03-01 10:10:10
Common mistakeAssuming webhook duplicates have identical payloads and attempting to deduplicate using SELECT DISTINCT across all columns.
How you will knowYou will see 6 raw payment records with multiple entries for TXN_9001 and TXN_9003.
Attempt naive DISTINCT on business keys
Wrong turn first: this step shows the mistake before the fix
Here is the naive approach: writing SELECT DISTINCT transaction_ref, merchant_id, amount from raw_payments. While this collapses the rows into 3 distinct transaction references, it strips out the status and received_at columns entirely. If the application needs to know whether the payment succeeded or when it finalized, this query fails to deliver the necessary operational state. Distinct filtering collapses rows indiscriminately, stripping out crucial metadata needed for financial auditing and merchant reporting.
SELECT DISTINCT transaction_ref, merchant_id, amount FROM raw_payments ORDER BY transaction_ref;transaction_ref | merchant_id | amount ----------------+-------------+------- TXN_9001 | 501 | 1200 TXN_9002 | 502 | 3400 TXN_9003 | 503 | 850
Common mistakeUsing DISTINCT on a subset of columns, which discards critical transactional metadata like status and settlement timestamps.
How you will knowNotice that while transaction references are deduplicated, status and received_at attributes are completely lost.
Assign row numbers with deterministic tie-breaking
To preserve full row metadata while resolving duplicates, we apply ROW_NUMBER() partitioned by transaction_ref. We construct a multi-column ORDER BY clause: first prioritizing confirmed SUCCESS status using a CASE statement (status = 'SUCCESS' gets priority 1, others get 2), and second sorting by received_at DESC to pick the latest timestamp in case of ties. This guarantees row number 1 always identifies the authoritative transaction record. Combining business status prioritization with reverse chronological ordering ensures that finalized successful payments take precedence over transient pending retries, even when network delays cause webhooks to arrive out of order.
SELECT payment_id, transaction_ref, merchant_id, amount, status, received_at, ROW_NUMBER() OVER ( PARTITION BY transaction_ref ORDER BY CASE WHEN status = 'SUCCESS' THEN 1 ELSE 2 END, received_at DESC ) AS row_num FROM raw_payments ORDER BY transaction_ref, row_num;payment_id | transaction_ref | merchant_id | amount | status | received_at | row_num -----------+-----------------+-------------+--------+---------+---------------------+-------- 2 | TXN_9001 | 501 | 1200 | SUCCESS | 2024-03-01 10:00:05 | 1 1 | TXN_9001 | 501 | 1200 | PENDING | 2024-03-01 10:00:00 | 2 3 | TXN_9002 | 502 | 3400 | SUCCESS | 2024-03-01 10:05:00 | 1 6 | TXN_9003 | 503 | 850 | SUCCESS | 2024-03-01 10:10:10 | 1 5 | TXN_9003 | 503 | 850 | FAILED | 2024-03-01 10:10:02 | 2 4 | TXN_9003 | 503 | 850 | PENDING | 2024-03-01 10:10:00 | 3
Common mistakeSorting only by received_at, which fails if an upstream service delivers an out-of-order FAILED webhook after a successful authorization.
How you will knowEvery payment row receives a row_num, with rank 1 assigned to the successful event for each transaction reference.
Compare timestamp sorting versus status prioritization
To understand why status prioritization is essential, we run a query comparing rank derived purely from received_at against rank derived with status weighting. If a gateway retried a payment and recorded a trailing timeout error after the banking debit succeeded, timestamp-only sorting would select the error row. Our deterministic rule ensures that settled financial transactions are never overwritten by late-arriving transient network error logs. Explicitly weighting business status above timestamp ordering prevents edge-case errors where delayed error notifications arrive after successful payment confirmations.
SELECT payment_id, transaction_ref, status, received_at, ROW_NUMBER() OVER (PARTITION BY transaction_ref ORDER BY received_at DESC) AS time_only_rank, ROW_NUMBER() OVER ( PARTITION BY transaction_ref ORDER BY CASE WHEN status = 'SUCCESS' THEN 1 ELSE 2 END, received_at DESC ) AS status_priority_rank FROM raw_payments ORDER BY transaction_ref, status_priority_rank;payment_id | transaction_ref | status | received_at | time_only_rank | status_priority_rank -----------+-----------------+---------+---------------------+----------------+--------------------- 2 | TXN_9001 | SUCCESS | 2024-03-01 10:00:05 | 1 | 1 1 | TXN_9001 | PENDING | 2024-03-01 10:00:00 | 2 | 2 3 | TXN_9002 | SUCCESS | 2024-03-01 10:05:00 | 1 | 1 6 | TXN_9003 | SUCCESS | 2024-03-01 10:10:10 | 1 | 1 5 | TXN_9003 | FAILED | 2024-03-01 10:10:02 | 2 | 2 4 | TXN_9003 | PENDING | 2024-03-01 10:10:00 | 3 | 3
Common mistakeRelying on database insertion order or primary key order rather than explicit business priority rules.
How you will knowThe output compares time_only_rank against status_priority_rank across all 6 payment records.
Filter authoritative deduplicated settlement records
In the final query, we encapsulate the ROW_NUMBER calculation within a Common Table Expression named deduped and filter WHERE row_num = 1. This outputs the definitive settlement dataset containing exactly one row per transaction reference. All three transactions (TXN_9001, TXN_9002, TXN_9003) resolve to status SUCCESS with accurate merchant IDs and transaction amounts. Filtering the ranked CTE on row number 1 delivers exactly-once payout semantics, protecting settlement pipelines from double-disbursements. This deterministic filtering pattern scales effortlessly across millions of financial transactions while maintaining strict idempotency across distributed message queues.
WITH deduped AS ( SELECT payment_id, transaction_ref, merchant_id, amount, status, received_at, ROW_NUMBER() OVER ( PARTITION BY transaction_ref ORDER BY CASE WHEN status = 'SUCCESS' THEN 1 ELSE 2 END, received_at DESC ) AS row_num FROM raw_payments ) SELECT transaction_ref, merchant_id, amount, status, received_at FROM deduped WHERE row_num = 1 ORDER BY transaction_ref;transaction_ref | merchant_id | amount | status | received_at ----------------+-------------+--------+---------+-------------------- TXN_9001 | 501 | 1200 | SUCCESS | 2024-03-01 10:00:05 TXN_9002 | 502 | 3400 | SUCCESS | 2024-03-01 10:05:00 TXN_9003 | 503 | 850 | SUCCESS | 2024-03-01 10:10:10
Common mistakeWriting WHERE ROW_NUMBER() OVER (...) = 1 directly in the outer query, which triggers a syntax error.
How you will knowThe result contains exactly 3 clean records, all with status SUCCESS and no duplicate transaction references.
Build it yourself
Write your solution below, run it in your browser, and pass every check.
What the result means
Interpretation
The deduplication query successfully isolates the authoritative financial records from noisy webhook retry logs. For TXN_9001, the pipeline discarded the initial PENDING event and retained the finalized SUCCESS confirmation. For TXN_9003, three events arrived across PENDING, FAILED, and SUCCESS states; our deterministic status priority logic correctly selected the SUCCESS event. This guarantees merchant settlement systems disburse exact funds without double-counting.
The message you would send
Hi Merchant Settlements Team, the SQL deduplication query is deployed and verified. It successfully eliminates duplicate webhook retries across all transactions while deterministically selecting confirmed SUCCESS states. Merchant settlement payouts are now protected from double-disbursement risks.
Put it on your CV
Resume or LinkedIn bullet
Built an idempotent webhook deduplication pipeline in SQL using ROW_NUMBER() window functions and multi-attribute tie-breaking in DuckDB.README for GitHub
# Idempotent Event Deduplication with Window Functions
A production SQL deduplication pipeline isolating authoritative payment records from distributed webhook retry streams.
## Features
- Partitioned ranking using ROW_NUMBER() over business reference keys
- Multi-column deterministic tie-breaking prioritizing finalized status over timestamps
- Common Table Expression filtering guaranteeing exactly-once payout semantics
- Seamless resilience against out-of-order message delivery in distributed architecturesImplement an audit tracking column that records the total count of duplicate retry attempts received per transaction reference.