Why does a dbt incremental model filtered on event_at > (select max(event_at) from {{ this }}) miss late-arriving rows?
answer
- the watermark only moves forward
- when it happened versus when it arrived
- nothing errors, that is the problem
- reprocess a trailing window instead
- filter on the load timestamp
basics
~20 sThat filter compares business event time against the highest event time already loaded, so any row arriving later but dated earlier falls below the watermark and is never selected. The run succeeds and the rows are silently lost forever.
solid answer
~50 sThe predicate uses the maximum **event** time in the target as a high-water mark. Once a row with today's timestamp lands, every subsequent run only considers rows newer than that, so a record that arrives tomorrow carrying yesterday's event time is filtered out permanently. Nothing errors — the run is green and the rows simply never appear. There are three standard fixes. Filter on an **ingestion** timestamp (`loaded_at`) instead of event time, so lateness in the business dimension is irrelevant. Or keep event time but subtract a **lookback window**, reprocessing the last few days, which requires `unique_key` with a merge or `insert_overwrite` strategy so the reprocessed rows update rather than duplicate. Or schedule a periodic `--full-refresh` to correct drift. Whichever you pick, add a reconciliation test comparing model counts against the source over a trailing window, because this failure is invisible by construction.
code
sql · 7 lines-- BEFORE: business-time watermark, late rows lost forever
{{ config(materialized='incremental') }}
select * from {{ ref('stg_events') }}
{% if is_incremental() %}
where event_at > (select max(event_at) from {{ this }})
{% endif %}go deeper
Understand that the filter is plain SQL you wrote and that comparing business event time against the table's maximum permanently excludes anything dated earlier.
Explain why the watermark only moves forward, and describe the two standard remedies: filter on an arrival timestamp, or reprocess a trailing window.
Show the full fix: pair a lookback with a merge or partition-overwrite strategy, size the window from the measured lateness distribution, and add reconciliation tests because the failure is silent by construction.
Own the policy — how far back models are allowed to restate, what lateness the ingestion layer must stamp on every row, and how drift between incremental output and a full rebuild is detected across the project.
## The failure This is the single most common defect in incremental dbt models, and it is invisible: the run is green, the row counts grow, and a slice of data is missing forever. The predicate at issue looks reasonable: ```sql {% if is_incremental() %} where event_at > (select max(event_at) from {{ this }}) {% endif %} ``` It says: only take rows whose business event time is newer than the newest one I already have. That works perfectly when arrival order matches event order. It fails whenever it does not — which is the normal state of real pipelines. Concretely: a mobile client buffers events offline and uploads them a day later. A partner sends a corrected transaction file on Thursday for Tuesday's trades. An upstream extraction job runs late and lands Monday's batch on Wednesday. In each case the target already contains rows with a higher `event_at`, so the late rows sit below the watermark and are never selected — not on this run, and not on any future one, because the watermark only ever moves forward. ## Why nothing warns you dbt has no expectation of what the model should contain. `is_incremental()` only decides whether to apply a filter; the predicate is yours, and dbt executes it faithfully. There is no reconciliation between the model and its source unless you write one. Row counts still rise every day, dashboards still refresh, and the gap is only discovered when someone reconciles against the source system — often months later, and usually by the finance team. ## Fix one: filter on ingestion time The cleanest change is to stop conflating "when it happened" with "when we got it": ```sql where loaded_at > (select max(loaded_at) from {{ this }}) ``` `loaded_at` (or `_ingested_at`, or the loader's own metadata column) increases monotonically with arrival, so a late row still has a fresh ingestion timestamp and is picked up on the run after it lands. Its `event_at` can be arbitrarily old and it does not matter. This requires the column to exist and to be assigned at load time by the loader, not by the source. It is one of the strongest reasons to ensure your ingestion layer stamps arrival metadata on every row. ## Fix two: a lookback window When only business time is available, widen the filter to reprocess a trailing window: ```sql where event_at >= ( select dateadd(day, -3, max(event_at)) from {{ this }} ) ``` Now anything up to three days late is caught. But this re-emits rows already in the table, so it is only safe with a strategy that overwrites: `unique_key` with `merge`, or `insert_overwrite` replacing whole days. Pair a lookback with `append` and you have traded silent data loss for silent duplication, which is a lateral move. Sizing the window is an empirical question, not a guess: measure the observed distribution of `loaded_at - event_at` in your source and pick a window that covers the tail you care about. State the residual risk out loud — anything later than the window is still lost. ## Fix three: periodic full refresh A scheduled `dbt run --full-refresh` on the model rebuilds it from all of history and corrects any accumulated drift, whatever its cause. This is a safety net rather than a design, and it only works while a full rebuild remains affordable — which is precisely the condition that made the model incremental in the first place. Still, for mid-sized tables a weekly refresh is cheap insurance. ## Newer machinery dbt 1.9 introduced a `microbatch` incremental strategy in which you declare an `event_time` column, a `batch_size` and a `lookback`, and dbt splits the run into time-bounded batches, reprocessing the lookback batches on each run. It formalises the lookback pattern rather than leaving it as hand-written Jinja, which reduces the chance of getting the predicate subtly wrong. The underlying trade-off is unchanged: you still choose how far back to reprocess, and anything outside the window is still missed. ## Detection, not just correction Because the failure is silent, the durable fix is a test, not just a better predicate. Useful checks: - A reconciliation test comparing counts (or sums of a key measure) between the model and its source over a trailing window, failing on divergence beyond a tolerance. - Monitoring the observed lateness distribution so you notice when a source's tail lengthens past your lookback window. - A `not_null` and freshness discipline on the arrival timestamp column the filter depends on, since a null `loaded_at` would quietly exclude rows from the comparison too. A related trap in the same family: `>` versus `>=` on the boundary. Using `>` on a timestamp with second granularity can drop rows that share the exact maximum timestamp but had not yet been committed when the previous run read it. Filtering on ingestion time with a small overlap plus a `unique_key` merge sidesteps both problems at once.
- If you add a three-day lookback to the filter, what else must change in the model config?The write strategy. Re-emitting three days of rows under `append` duplicates them. You need `unique_key` with `merge` or `delete+insert` so reprocessed rows update in place, or `insert_overwrite` so each reprocessed day's partition is replaced wholesale. The lookback and the idempotent write are a single design, not two options.
- How would you detect that an incremental model has been silently dropping rows?Reconcile against the source: compare row counts or a summed measure between the model and its upstream over a trailing window and fail the run when they diverge beyond tolerance. Row counts alone rising daily prove nothing. A one-off `--full-refresh` into a scratch schema and a diff against production also exposes accumulated drift.
- Why is a strict greater-than on the maximum timestamp risky even without late data?Rows sharing the exact maximum timestamp are excluded, so any row committed after the previous run read that value but stamped with the same second is lost. Using >= with an overlap window plus a unique_key merge makes the boundary safe, since re-selected rows update rather than duplicate.
It is like sorting incoming mail by the date written on the letter and refusing anything older than the newest letter you have filed. A letter posted last week but delivered today never gets filed, and nobody notices because the filing cabinet keeps getting fuller.
saying these in an interview costs you the question
- Believing dbt tracks a watermark and would have caught the gap
- Adding a lookback window while keeping an append strategy
- Treating event time and ingestion time as interchangeable
- Assuming rising row counts prove no rows are missing
- Sizing the lookback window by guesswork instead of measured lateness