skip to content

Why do rows hard-deleted at the source never disappear from a watermark-based incremental extract?

level: seniorimportance: should knowfreq 50%

answer

  1. a filter can only match rows that exist
  2. nothing is left behind by a delete
  3. the target quietly keeps the ghost
  4. ask for keys, diff against yours
  5. soft delete turns it into an update

basics

~20 s

Because a deleted row is gone from the table, so it can never satisfy a filter on a change column. The extract only ever sees rows that still exist, and the target keeps the stale copy forever with no error raised.

solid answer

~50 s

A watermark filter asks the source for rows whose change column is above a value. A hard-deleted row has no change column left to compare, so it is structurally unobservable — nothing about a `DELETE` produces a row for the extract to find. The target therefore keeps the row indefinitely, inflating counts and sums, and no test on the incremental path can catch it because the path is behaving exactly as written. Four remedies exist, in rough order of preference: have the source **soft delete** (a flag plus a bumped change column, so the deletion arrives as an ordinary update); run a **periodic key reconcile** that pulls only primary keys from the source and marks target keys that no longer appear; read a **source-side audit or outbox table** that records deletions as rows; or move that table off polling entirely to log-based capture, which is the sibling CDC branch's territory. Soft delete is a source-team negotiation, so most pipelines end up running the reconcile.

code

sql · 9 lines
sql
-- narrow key snapshot from the source, taken as of one moment
SELECT order_id FROM orders;   -- landed into stg_order_keys with a snapshot_ts

-- flag target rows whose key is gone, guarded by the snapshot time
UPDATE orders t
SET    is_deleted = true, deleted_at = :snapshot_ts
WHERE  t.is_deleted = false
  AND  t.loaded_at <= :snapshot_ts
  AND  NOT EXISTS (SELECT 1 FROM stg_order_keys s WHERE s.order_id = t.order_id);

go deeper

for a junior

Recall that a deleted row leaves nothing behind for a query to find, so an incremental extract can never report it and the target keeps a stale copy.

for a middle

Explain the mechanism and name the remedies — soft-delete flags, a periodic key reconcile, a source-side deletions log — with what each one actually costs.

for a senior

Show the operational judgment: reconcile cadence versus how long a phantom row may live, a consistent key snapshot, a blast-radius guard on the diff, and a count-drift alert.

for a principal

Own the policy question: which tables are allowed to be loaded by polling at all given deletion semantics and retention obligations, and what the organisation commits to for erasure propagation.

## The mechanism, stated plainly An incremental extract is a query: `SELECT ... FROM orders WHERE updated_at >= :watermark`. Rows are returned because they exist and satisfy a predicate. A `DELETE` removes the row from the table; there is no remaining tuple with a timestamp to compare, no marker left behind, and no side effect the query can observe. The deletion is not "late" or "missed" — it is invisible by construction, and no amount of lookback, wider windows or more frequent runs will surface it. The consequence is asymmetric and quiet. Inserts and updates flow; deletions accumulate as phantom rows in the target. Counts drift upward, sums overstate, joins fan out against records the source no longer has, and any downstream user counting "active" anything gets a number that only ever goes up. Because the pipeline is doing precisely what its code says, monitoring on freshness, row counts loaded, or job success will all stay green. There is a compliance edge too: if a deletion at the source was an erasure request, the copy sitting in the warehouse is the thing that matters, and "our loader does not carry deletes" is not an answer. ## Remedy one: soft delete at the source The cleanest fix is to stop hard-deleting. The source marks the row — `is_deleted = true`, `deleted_at = now()` — and bumps the change column. The deletion now arrives through the ordinary incremental path as an update, and the load applies it like any other change. This is a source-schema and source-team decision, not something the pipeline can impose, and it has its own costs: the source table grows, application queries must all filter the flag, and eventually someone runs a purge job — which hard-deletes, reintroducing the original problem for the purged rows unless the purge is coordinated with the pipeline. When it is available, take it. It is the only remedy that is both timely and cheap. ## Remedy two: the periodic key reconcile The pragmatic default when the source will not change. Periodically read *only* the primary keys from the source — a narrow, index-friendly scan that is far cheaper than a full-row refresh — and diff them against the keys in the target. Keys present in the target and absent from the source are deletions; mark them (`is_deleted = true`, with a timestamp) rather than removing them, so downstream models can decide whether to filter or to keep the history. The details that matter: - **Consistency of the key snapshot.** Read the keys as of a single point in time and only reconcile against target rows that were loaded before that point, or a row inserted at the source between the key read and the diff will be flagged as deleted. - **Scope.** Reconciling a whole 500-million-row table nightly may be unaffordable. Scope it to a partition or a recent time range if deletions are only plausible there — but be explicit that anything outside the scope is unmonitored. - **Blast radius.** A reconcile that flags too much because of a bad snapshot can mark most of a table deleted in one statement. Add a guard: refuse to proceed if the diff exceeds a plausible fraction of the table, and alert instead. - **Cadence versus staleness.** The reconcile interval is exactly how long a deletion can survive in the warehouse. Choose it against what the consumers and any retention policy require, and write it down. ## Remedy three: a source-side record of deletions If the source application maintains an audit table, an event outbox, or a trigger-populated deletions log, deletions become rows again and the ordinary incremental machinery works on them — you extract the deletions table with its own watermark and apply the tombstones. The caveats are real: a trigger costs write throughput on the source and can be bypassed by bulk operations or by a `TRUNCATE`, and an application-maintained outbox only covers deletions the application performs. Anything that deletes out of band is still invisible, so this remedy narrows the gap rather than closing it. ## Remedy four: stop polling that table If timely deletions genuinely matter and the source is a database whose transaction log you can read, the answer is a different capture strategy entirely: log-based capture emits a delete event because the deletion was written to the log. That is a decision to leave the batch-extract world, with its own operational cost, and the detail belongs to the change-data-capture branch — the point for this leaf is knowing that no arrangement of watermarks will get you there. ## How to detect the drift you already have Whatever you choose, add a check that would notice the problem: compare the source row count against the target's non-deleted count on a schedule, and alert on a widening gap. It is a single cheap query per table, it catches missed deletions and several other classes of drift at once, and it converts a silent, permanent error into a ticket. ## What to say when asked Name the mechanism first (a deleted row cannot match a predicate), then the consequence (silent permanent drift, inflated aggregates, possible retention exposure), then the remedies with their tradeoffs, and finish with the detection check. Candidates who only say "use CDC" have skipped the part interviewers are testing: what you do when the source team will not soft-delete and you cannot get on the log this quarter.

  • How do you make a key-only reconcile safe against flagging rows that were merely inserted mid-run?
    Snapshot the source keys as of one point in time and compare only against target rows loaded before that point. Then guard the write: if the diff would mark more than a plausible fraction of the table deleted, abort and alert rather than applying it. A bad snapshot otherwise deletes most of a table in one statement.
  • Why mark rows deleted in the target instead of removing them?
    Because downstream models may need the history, and because a flag is reversible if the reconcile was wrong. A tombstone with a deleted_at timestamp lets consumers filter current state while preserving what was there, and it makes an erroneous reconcile a one-statement correction rather than a restore from backup.
  • What does soft delete at the source cost the source team?
    The table keeps growing, every application query must filter the flag or risk showing removed records, and unique constraints have to account for deleted rows. Eventually a purge job runs, which hard-deletes and reintroduces the invisibility problem for purged rows unless the purge is coordinated with the pipeline.
  • How would you detect that your target already carries phantom rows?
    Schedule a count comparison: source row count against the target's non-deleted count, per table, with an alert on a widening gap. It is one cheap query per table and it catches missed deletions along with several other drift classes, turning a silent error into something someone is paged about.

A watermark filter is asking who has moved in recently. Nobody who moved out is ever on that list, so your register of residents only grows.

saying these in an interview costs you the question

  • Believing a wider lookback window can surface deletions
  • Assuming the target row count drifting is a warehouse bug
  • Reconciling keys without a consistent point-in-time snapshot
  • Physically deleting target rows on the first diff, unguarded
  • Answering only use CDC with no batch-world remedy

context