Incremental Loading: Intermediate
What you will be able to do
What a full refresh buys you
A full refresh truncates the target and reloads every source row. Its correctness is complete by construction: a row removed at the source is absent from the reload, a row updated 3 years after it was created arrives with its new values, and a row whose update timestamp was never set arrives anyway because nothing filtered on it. The job keeps no state between runs, so a backfill is the same job run again. The price is that the work scales with the size of the table rather than the size of the change. Take an orders table of 40 million rows at around 400 bytes each. Reloading it means pulling the whole thing across the network every night and writing it back out, even when 99 percent of it hasn't moved since yesterday.
What incremental costs
- ▸Delete visibility: a row removed at the source has no change left to filter on, so the load never sees it (section 1).
- ▸A trustworthy change column: the filter is exactly as good as the timestamp or sequence it reads (section 2).
- ▸Reprocessability: a watermark records where you are, and a backfill must rewrite the past without moving it (section 3).
- ▸History: once you load changes instead of snapshots, you decide whether old values are kept or overwritten (section 4).
The 3 numbers that decide it
| Table | Rows | Changed per day | Reliable change column | Full refresh fits window | Choice |
|---|---|---|---|---|---|
| `dim_country` | 250 | 0 to 2 | no | yes, under 1 second | Full refresh |
| `dim_product` | 48,000 | 300 (0.6%) | yes | yes, 40 seconds | Full refresh |
| `orders` | 40,000,000 | 310,000 (0.8%) | updated_at, trigger-set | no, 4 hours against a 3 hour window | Incremental |
| `clickstream_events` | 9,000,000,000 | 120,000,000 (1.3%) | event_time, append-only | no | Incremental, append-only |
| `inventory_levels` | 2,000,000 | 1,600,000 (80%) | yes | yes, 6 minutes | Full refresh |
Row counts and change rates in this table are the running example for the whole lesson; every later figure derives from the orders row.
Read the table from right to left. dim_product has a reliable change column and a small change ratio, the textbook incremental profile, and the right answer is still a full refresh, because 40 seconds of reload costs less than any amount of watermark logic. inventory_levels changes 80 percent of its rows daily, so an incremental load would move nearly as many bytes as a refresh while taking on all 4 properties above. orders goes incremental for 1 reason: the refresh no longer fits the window. The change ratio tells you the size of the saving, the window tells you whether you need it, and the change column tells you whether it is achievable at all.
The first crack
- The table reloads inside the window with room to spare
- Most rows change every day, so the change filter saves little
- The source has no change column you would trust
- Correctness matters more than the compute bill, as with a 250 row reference table
- Under a few percent of rows change per day
- The full reload no longer fits the load window
- A trigger or the database itself sets the change column
- Consumers need the data fresher than a nightly rebuild allows
Worked example: 2,000 orders a day vanish
Suppose a cleanup job at 03:10 hard-deletes roughly 2,000 orders a day from the source, most of them test orders or duplicate submissions. Over a quarter that is 180,000 phantom rows in orders_fact, and every one of them carries an amount that gets summed into a daily revenue figure. Here is a 5 row slice on the morning after 2 of them were removed.
| order_id | status | amount | updated_at |
|---|---|---|---|
| 5001 | shipped | 42.00 | 2026-10-20 09:12 |
| 5002 | paid | 18.50 | 2026-10-21 14:40 |
| 5004 | shipped | 77.25 | 2026-10-19 16:03 |
| order_id | status | amount | updated_at |
|---|---|---|---|
| 5001 | shipped | 42.00 | 2026-10-20 09:12 |
| 5002 | paid | 18.50 | 2026-10-21 14:40 |
| 5003 | test | 0.01 | 2026-10-18 11:00 |
| 5004 | shipped | 77.25 | 2026-10-19 16:03 |
| 5005 | paid | 135.00 | 2026-10-20 20:45 |
Orders 5003 and 5005 were deleted at 03:10. The 04:00 run filtered on updated_at above yesterday's watermark, found 5002, whose status changed from pending to paid, and loaded it. Rows 5003 and 5005 matched nothing and stayed in the target, which leaves the daily revenue figure 135.01 too high.
Option 1: soft deletes at the source
If you can change the source, the cheapest fix is to stop deleting. The cleanup job sets is_deleted = true, and the same statement bumps updated_at. The row now passes the incremental filter like any other update, arrives downstream carrying its flag, and either the load applies the delete to the target or every consumer adds WHERE NOT is_deleted. This breaks when a code path sets the flag and forgets the timestamp: the row is logically gone and invisible to the load, which combines the worst of both options. Make the timestamp bump a database trigger rather than an application convention, so no code path can skip it.
Option 2: reconcile the keys
Option 3: read the change log
| Option | Source change needed | Delete latency | Extra daily cost | Fails when |
|---|---|---|---|---|
| Soft delete flag | yes, cleanup job and a trigger | next incremental run | none | a code path sets the flag without the timestamp |
| Key reconciliation | no | up to 24 hours | 320 MB key pull and an anti-join | the key snapshot is incomplete |
| Change log | database access, no schema change | seconds | a log reader running continuously | the log is truncated before it is read |
- Bump updated_at in the same statement that sets is_deleted, by trigger
- Pull keys only for reconciliation, never full rows
- Count the key snapshot before any delete runs against it
- Keep deleted keys as tombstones in the raw layer for backfills
- Trust a soft-delete flag that application code sets by convention
- Run a reconciliation delete that reports success on a partial snapshot
- Assume a hard-deleted row ever reaches a timestamp filter
- Measure delete latency by the incremental run interval when keys arrive daily
| Filter column | Inserts | Updates | Deletes | Breaks when |
|---|---|---|---|---|
| auto-increment id | yes | no | no | ids are assigned out of commit order |
| created_at | yes | no | no | 2 app servers disagree on the time |
| updated_at set by the application | yes | mostly | no | 1 code path forgets to set it |
| updated_at set by a trigger | yes | yes | no | a long transaction commits after the watermark |
| commit sequence number | yes | yes | with tombstones | the database does not expose one |
Lie 1: the row that committed after the watermark
The timestamp in updated_at is written when the statement runs, inside a transaction. The row becomes visible to your extract when the transaction commits, which can be seconds or minutes later. Between those 2 moments another run can read the table, see a higher timestamp on some other row, and record it as the watermark. Here is the sequence with a 45 second transaction and a run every 15 minutes.
| Clock | What happens |
|---|---|
| 10:00:00 | Transaction A opens, updates order 7001, sets updated_at to 10:00:00, does not commit |
| 10:00:29 | Transaction B updates order 7002, sets updated_at to 10:00:29, commits |
| 10:00:30 | Run 1 filters updated_at above 09:45:14, sees 7002 only, loads it |
| 10:00:31 | Run 1 stores the new watermark: 10:00:29 |
| 10:00:45 | Transaction A commits; 7001 is now visible with updated_at 10:00:00 |
| 10:15:30 | Run 2 filters updated_at above 10:00:29; 7001 fails the filter, and will on every later run |
Lies 2 to 4
The fix: overlap, then dedupe
You can't make the column honest, so make the filter forgiving. Overlap absorbs late commits and clock skew, the lies that leave a row a few seconds behind the watermark, and nothing else. A source correction that never touches updated_at needs a targeted re-pull or a partition backfill, a forgotten code path needs the trigger-set column from the table above, and a local-time column needs storing in UTC, because a 15 minute window can't reach back across a repeated hour. For the 2 it does absorb, subtract a lookback window from the watermark that is longer than your longest transaction, 15 minutes here against a 45 second worst case, and accept that each run reloads a slice it has already seen. Then collapse duplicates on the key, keeping the newest version. The overlap turns a permanent miss into a 1 run delay, and the dedupe turns the double-count from section 0 into a single current row. Note the comparison is now >=, because many rows share a timestamp to the second and an exclusive compare drops the ones equal to the boundary.
The stage then replaces matching rows in orders_fact: delete where order_id is in the stage, insert the stage. Reloading a row you already hold produces the row you already hold, which is why a 15 minute overlap costs some scan and no correctness. That delete-and-insert pair is the same write section 3 uses for backfills.
Choosing the window
Size the lookback from the source, never from habit. Ask for the longest transaction the application holds open, add the clock skew between its servers, and double the sum. For the orders service that is 45 seconds plus 3 seconds, doubled to about 2 minutes, so 15 minutes is generous and reloads roughly 3,000 rows a run, roughly 1 percent of the daily change. A source with 20 minute batch jobs that update rows inside a single transaction needs a 40 minute window or a different column entirely.
updated_at the run actually loaded, read back from the staged rows, never as the wall clock when the run began. The 2 values drift apart under load, and the wall clock version makes the overlap window shorter than you think.Property 1: the write replaces a whole partition
Partition orders_fact by order_date and make every write a replacement of 1 day: delete the day, insert the day. Running the pair twice yields the rows that running it once yields. Running it for 2026-09-14 touches nothing outside September 14. The same 2 statements serve the nightly load and the backfill of any date in history, which removes the need for a separate backfill code path that drifts from the one in production.
Property 2: the date is a parameter
The literal 2026-09-14 must arrive from the orchestrator, never from current_date inside the query. A task that computes its own date processes today every time it runs, so a backfill of the last quarter re-runs October 23 for all 90 dates and declares victory. With the date injected, the scheduler supplies 1 value per task instance, and 90 backfill instances run in parallel against 90 disjoint partitions, which is why the diagram below carries a parallelism of 90.
Nightly, rows move left to right; a backfill re-runs replace_partition from orders_raw for each date and never reads orders_src
Property 3: backfill from the raw layer you kept
By the time you need March again, the source may have archived March, and it has certainly hard-deleted 2,000 rows a day since. The raw landing layer holds every row ever extracted, partitioned by the day of extraction, together with the daily key snapshots from section 1 kept as tombstones. A backfill replays that history: for a target partition, take every raw row with that order_date, keep the latest version per key, drop any key a tombstone covers, replace the partition. The source is read only on the first extraction. Keeping each raw day of rows costs roughly 124 MB, which over 2 years is under 100 GB before the key snapshots.
Property 4: the watermark stays where it is
The nightly load advances the watermark; the backfill must never touch it. Keep them as 2 separate state entries, or run the backfill as a task that has no write access to the watermark at all. A backfill of 2026-09-14 that stores the max(updated_at) of the rows it processed into the shared entry drags the next nightly run back by 5 weeks and reloads 11 million rows through the overlap and dedupe path, at roughly 1 hour of extra work. The opposite accident is a backfill of recent days storing a watermark higher than the nightly run has reached. The nightly run then skips rows without any error, and section 2 covered how long a silent skip lasts.
The entire mechanism is a watermark table with a nightly row and a backfill row; the discipline is that each task may write only its own row.
Late data lands in old partitions
An order placed on 2026-09-14 and refunded on 2026-10-20 carries order_date September 14 and updated_at October 20. The nightly load catches it by updated_at and stages it. The replace step then rewrites the September 14 partition with the refund applied, 36 days after the order. A nightly run that only replaces the last 7 partitions is therefore wrong: it must replace every partition that appears in the stage, however old. In the example table a status change can arrive up to 90 days after the order, so a typical night touches about 40 distinct partitions, and any report that cached September is 1 day stale the next morning.
- ▸Partition-aligned write: delete the day and insert it again, so a second run changes nothing
- ▸Date as a parameter from the scheduler;
current_dateappears nowhere in the task - ▸Raw layer is the source of truth for history, with tombstones for deleted keys
- ▸Watermark untouched by any backfill task
- ▸Row count of the new partition compared to the old before the delete is allowed to commit
Worked example: 1 customer, 1 move
| customer_sk | customer_id | city | tier | valid_from | valid_to | is_current |
|---|---|---|---|---|---|---|
| 88 | 1042 | Austin | gold | 2025-03-02 08:00 | 9999-12-31 00:00 | true |
| customer_sk | customer_id | city | tier | valid_from | valid_to | is_current |
|---|---|---|---|---|---|---|
| 88 | 1042 | Austin | gold | 2025-03-02 08:00 | 2026-10-15 09:30 | false |
| 2311 | 1042 | Denver | gold | 2026-10-15 09:30 | 9999-12-31 00:00 | true |
The old row closes at the instant the new one opens, so the 2 intervals meet without gap or overlap and any order timestamp falls into exactly 1 of them. valid_to on the current row is a far-future sentinel rather than NULL, so range comparisons need no COALESCE at query time. The surrogate key customer_sk is what the fact table stores: a March order carries 88 and an October order carries 2311 forever, so reports join on 1 integer and never touch the dates.
Step 1: detect the changes
Compare each incoming row from the overlap and dedupe stage of section 2 against the customer's current version. A row counts as a change in 2 cases: the customer is new and has no current version, or a tracked attribute differs. Write the comparison as a hash over the tracked columns so that tracking a new column later is a 1 line edit, and COALESCE any nullable tracked column to a marker before concatenating, because a NULL inside the hash input makes the whole comparison NULL and the change goes undetected. New versions are appended to customer_versions with a surrogate key continuing from the current maximum.
Step 2: derive the intervals
customer_versions is append-only, 1 row per detected change, and the dimension is derived from it instead of edited in place. Each version's valid_to is the next version's valid_from for the same customer, which a window function produces without a self join. Rebuilding the dimension is a full refresh of a small table, delete all rows then insert, and section 0 already made the case that a full refresh is right there: 2 million customers at 3 versions each is 6 million rows and under a minute.
Step 3: resolve the key at fact load
When an order loads, look up the version whose interval contains the order timestamp and store that key. The range condition runs once per order at load time, and every report after that joins on customer_sk alone.
Where it breaks, and why the version log saves it
A change that arrives late, with a valid_from earlier than the current version, is the SCD failure most candidates miss. A design that edits the dimension in place closes the current row at the late timestamp and opens a new one, producing 2 current rows or an interval running backwards. The version log design orders by valid_from when it derives intervals, so a late version slots between its neighbours and LEAD recomputes both of them on the next rebuild. Deletes fit the same log with 1 extra flag column: a tombstone version closes the last real version and carries no attributes forward. And because the dedupe stage guarantees 1 row per customer per load, 2 versions never share a valid_from, so the order within a customer is total.
- Keep an append-only version log and derive the dimension from it
- Use a far-future sentinel for valid_to on the current row
- Hash the tracked columns after coalescing nullable ones
- Resolve customer_sk at fact load with a range join, once
- Edit valid_to in place when a change may arrive out of order
- Use NULL for an open interval and COALESCE in every report
- Rebuild surrogate keys on each refresh; facts already hold the old ones
- Compare attributes column by column across 30 columns
> A subscription meal-kit company loads its 40 million row orders table and 2 million row customers table into the warehouse every night. Finance reports revenue by month; marketing reports churn by the city the customer lived in when they ordered. The nightly full refresh has grown from 90 minutes to 4 hours against a 3 hour window, cancelled orders are hard-deleted at the source, refund transactions hold rows open for up to 45 seconds, and finance has asked to reprocess March after a tax rule change. The team must go incremental without changing any report's numbers.
Incremental loads that survive deletes, lying timestamps, backfills and history, and when a full refresh wins.
- Category
- Pipeline Architecture
- Difficulty
- intermediate
- Duration
- 35 minutes
- Challenges
- 2 hands-on challenges
Topics covered: Full Refresh or Incremental?, How Do You Catch Deletes?, Which Column Do You Filter On?, How Do You Backfill?, How Do You Track History?
Lesson Sections
- Full Refresh or Incremental?
Every incremental load is a bet that the rows which changed are a small fraction of the rows that exist. When the bet holds, you move megabytes instead of gigabytes. When it loses, you have taken on 4 kinds of state and failure that a full refresh never carries, and saved nothing. The interview question behind this section is which side of that line a given table sits on, and the answer comes from 3 numbers you can ask for before you decide. What a full refresh buys you What incremental costs Th
- How Do You Catch Deletes?
An incremental filter asks 2 things of a source row: that it exist, and that its change column pass the watermark. A row deleted at the source fails the first test before the second is ever evaluated. The target keeps the row forever, the revenue report counts a cancelled order, and nothing logs an error, because from the pipeline's point of view nothing happened. Deletes are the first thing an interviewer probes after you say incremental. Worked example: 2,000 orders a day vanish Option 1: soft
- Which Column Do You Filter On?
The incremental filter has 3 candidate columns. An auto-increment id and a creation timestamp both catch inserts and nothing else, because neither moves when a row is edited. The update timestamp does move on edits, so it catches updates as well, and it lies in 4 specific ways an interviewer will expect you to name and defend against. Lie 1: the row that committed after the watermark Order 7001 is now permanently wrong downstream. The run logs no error and the row counts match, and a reconciliat
- How Do You Backfill?
A backfill reprocesses a range of history: a bug in the transform, a correction at the source, a new column that needs 2 years of values. For a full refresh the backfill is the job itself. For an incremental load it is the moment the design is tested, because the watermark asserts that everything before it is done, and a backfill has to contradict that assertion without breaking it. The design needs 4 properties to do that. Property 1: the write replaces a whole partition Property 2: the date is
- How Do You Track History?
Everything so far loads the current state of a row and overwrites the previous one. A dimension that facts join to often needs the previous state kept: an order from March should join to the customer's March address, not the one she moved to in September. A Type 2 slowly changing dimension keeps every version as its own row with a validity interval, and an incremental load is the natural way to build one, because each version is a change the load already detected. Worked example: 1 customer, 1 m