Home / Data Platform / Data Factory

Data PlatformTroubleshootingAdvanced

ADF incremental copy missed rows: why watermarks lose late commits and how to fix them

Source and target row counts drift even though every Data Factory run succeeds. Find missed rows, fix watermark gaps from late commits and time zones, and use an overlap with merge, rowversion or CDC.

Deepak Bhardwaj6 min read

Applies toAzure Data Factory, Azure Synapse pipelines, SQL Server and Azure SQL sources

Symptom

An incremental pipeline copies new and changed rows using a modified_at column. Every run succeeds, yet the target slowly falls behind the source. A full reload fixes it for a while, then the gap returns. The missing rows have timestamps that look like they're well inside a window that already ran.

Why it happens

The standard pattern reads WHERE modified_at > old_watermark AND modified_at <= new_watermark, with the new watermark taken from the source. It has one hidden assumption: every row with a timestamp below the watermark is already committed and visible. That's often false.

10:00:00  Transaction T1 starts. Sets modified_at = 10:00:00. Not committed yet.
10:05:00  Pipeline runs. Reads MAX(modified_at) = 10:04:59, copies visible rows.
          Saves watermark 10:04:59.
10:06:00  T1 commits. Its row has modified_at = 10:00:00.
10:10:00  Next run reads modified_at > 10:04:59. T1's row is never copied.

Other causes produce the same result:

Cause How it loses rows
Long or batch transactions The late commits above
Time set by the app servers Clock differences; an old timestamp arrives late
Time zones Local time versus UTC, and daylight saving shifts
Rows not touching modified_at Bulk fixes or triggers that skip it
Coarse column type datetime has low precision; a > check skips equal values
Watermark moved after a failure The copy was partial, yet the watermark advanced
Concurrent pipeline runs Two runs race and overwrite the watermark
Deleted rows A watermark can never see them

Step 1: measure the gap

Compare by day, so you can see where it starts:

-- Run on both source and target
SELECT CAST(modified_at AS date) AS d,
       COUNT(*) AS row_count,
       CHECKSUM_AGG(CHECKSUM(order_id)) AS id_checksum
FROM dbo.orders
WHERE modified_at >= DATEADD(day, -14, SYSUTCDATETIME())
GROUP BY CAST(modified_at AS date)
ORDER BY d;

Then list missing keys for a bad day. A linked server, or an export to staging, lets you compare:

SELECT s.order_id, s.modified_at
FROM source_copy.orders AS s
LEFT JOIN dbo.orders AS t ON t.order_id = s.order_id
WHERE t.order_id IS NULL
  AND s.modified_at >= '2026-11-05' AND s.modified_at < '2026-11-06';

Check the run history to see which window each row should have fallen in:

az datafactory pipeline-run query-by-factory -g rg-data --factory-name adf-ops \
  --last-updated-after 2026-11-05T00:00:00Z --last-updated-before 2026-11-06T00:00:00Z \
  --filters operand="PipelineName" operator="Equals" values="pl_incremental_orders" \
  --query "value[].{run:runId,start:runStart,status:status}" -o table

Compare a missing row's modified_at with the window. If it falls inside an earlier window, you have a late commit or a timestamp problem, not an outage. Check how long transactions run on the source:

SELECT s.session_id, t.transaction_begin_time,
       DATEDIFF(second, t.transaction_begin_time, SYSDATETIME()) AS age_s, s.program_name
FROM sys.dm_tran_active_transactions AS t
JOIN sys.dm_tran_session_transactions AS st ON st.transaction_id = t.transaction_id
JOIN sys.dm_exec_sessions AS s ON s.session_id = st.session_id
ORDER BY age_s DESC;

Fix option 1: overlap window with a merge

This is the simplest fix. Re-read a safety margin larger than your longest transaction, and let the sink remove duplicates.

Lookup activity to get the upper bound:

SELECT DATEADD(minute, -2, SYSUTCDATETIME()) AS new_wm

Copy activity source query:

@concat('SELECT * FROM dbo.orders WHERE modified_at > DATEADD(minute, -30, ''',
  activity('GetOldWatermark').output.firstRow.wm, ''') AND modified_at <= ''',
  activity('GetNewWatermark').output.firstRow.new_wm, '''')

Load into a staging table, and then merge:

MERGE dbo.orders AS t
USING stg.orders AS s ON t.order_id = s.order_id
WHEN MATCHED AND s.modified_at >= t.modified_at THEN
  UPDATE SET t.status = s.status, t.amount = s.amount, t.modified_at = s.modified_at
WHEN NOT MATCHED THEN
  INSERT (order_id, status, amount, modified_at)
  VALUES (s.order_id, s.status, s.amount, s.modified_at);

Warning

An overlap without a merge creates duplicates. Never add one to an append-only sink. Size the overlap from your longest real transaction, and remember it can't cover a batch that runs for longer than the overlap.

Fix option 2: rowversion on SQL Server

A rowversion column is set by the engine, in commit-safe order only if you bound it properly. MIN_ACTIVE_ROWVERSION() gives the lowest value still in an open transaction, so stopping below it avoids the race:

SELECT CONVERT(varchar(20), CONVERT(bigint, MIN_ACTIVE_ROWVERSION() - 1)) AS new_wm;
SELECT * FROM dbo.orders
WHERE row_ver > CONVERT(rowversion, CONVERT(bigint, @old_wm))
  AND row_ver <= CONVERT(rowversion, CONVERT(bigint, @new_wm));

Adding a rowversion column to a large table is a schema change that needs a plan; check the impact and don't assume it's instant.

Fix option 3: use change tracking or CDC

Log-based tools read committed changes and also capture deletes:

ALTER DATABASE sales SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 3 DAYS, AUTO_CLEANUP = ON);
ALTER TABLE dbo.orders ENABLE CHANGE_TRACKING;

SELECT ct.order_id, ct.SYS_CHANGE_OPERATION, ct.SYS_CHANGE_VERSION
FROM CHANGETABLE(CHANGES dbo.orders, @last_version) AS ct;

Which to use:

Requirement Choose
Quick fix for existing pipeline Overlap + merge
SQL Server source, no deletes needed rowversion
Must capture deletes Change tracking or CDC
Full history of every change CDC
Many tables, near real time Debezium or similar

Make the pipeline itself safe

  • Update the watermark as the last step, and only on success. Connect it with a success dependency on the copy and merge.
  • Set pipeline concurrency to 1, so runs can't race: "concurrency": 1 in the pipeline's properties.
  • Use UTC everywhere. Use datetime2, and be clear about time zones when comparing.
  • Capture the upper bound before copying, not after, and subtract a small margin from "now".
  • Use a stable end value, and don't call GETDATE() inside the copy query, so the window is fixed for the run.

Repair the existing gap

Rerun the merge with the old watermark moved back before the first missing row. For example, set the watermark back and let the pipeline catch up:

UPDATE etl.watermark SET wm = '2026-10-20T00:00:00'
WHERE table_name = 'dbo.orders';

Caution

Only do this when the sink merges by key. On an append-only target, rewinding the watermark creates duplicates. Take care with downstream systems too, which may recompute after receiving those rows.

Prevent it next time

  • Run a daily reconciliation on count and checksum, and alert on differences above a small tolerance.
  • Log the old and new watermark and rows copied for each run.
  • Schedule a periodic full comparison to catch deletes and anything missed.
  • Related: duplicate records after reruns and choosing an ingestion pattern.