This page requires JavaScript to display.
I
Unpacking...
← Data Engineering
Data Engineering

Incremental Load Reference

Loading only what's new: watermarks, change data capture, file-based incremental ingestion, and merge/upsert patterns.

Contents
1. Full vs Incremental Load 2. Watermark-based Extraction 3. Incremental Extraction from APIs 4. Incremental Ingestion from Files 5. Change Data Capture 6. CDC Tooling Compared 7. Delivery Semantics & Idempotency 8. Late Data & Streaming Watermarks 9. Merge / Upsert 10. Partitioned Incremental Loads 11. Backfill & Reprocessing 12. Monitoring & Data Quality 13. Design Checklist

1. Full vs Incremental Load

Full loadIncremental load
What movesEntire source dataset, every runOnly rows/files new or changed since the last run
CostGrows with total data sizeGrows with change volume only
ComplexitySimple, self-healingNeeds a reliable way to identify "what's new"

Incremental load reduces redundant processing but trades away full-load's simplicity: every design below is really a strategy for answering one question — "how do we know, cheaply and reliably, what changed?"

2. Watermark-based Extraction

The classic database-side pattern: track a high-water-mark value (usually an updated_at timestamp or a monotonic ID) from the last successful run, and pull only rows past it next time.

-- Store the watermark in a small control table
SELECT last_loaded_at FROM etl_control WHERE source_table = 'orders';

-- Extract only rows changed since the watermark
SELECT * FROM orders
WHERE updated_at > :last_loaded_at
ORDER BY updated_at;

-- After a successful load, advance the watermark
UPDATE etl_control
SET last_loaded_at = :max_updated_at_this_run
WHERE source_table = 'orders';

Pitfalls: clock skew between the source and the ETL host, rows updated with a stale/unchanged timestamp, and hard deletes (a watermark on updated_at never sees a row that was removed). Use a slightly overlapping window and dedupe by key, or pair with CDC for deletes.

3. Incremental Extraction from APIs

SaaS and REST sources rarely expose a raw table to query — incremental pulls rely on whatever the API offers instead:

import requests, json

state = json.load(open("state.json"))  # {"cursor": "...", "updated_since": "2026-07-28T00:00:00Z"}

resp = requests.get(
    "https://api.example.com/orders",
    params={"updated_since": state["updated_since"], "cursor": state["cursor"]},
    headers={"Authorization": f"Bearer {TOKEN}"}
)
data = resp.json()
process(data["records"])

state["cursor"] = data.get("next_cursor")
state["updated_since"] = data["records"][-1]["updated_at"] if data["records"] else state["updated_since"]
json.dump(state, open("state.json", "w"))

4. Incremental Ingestion from Files

When the source is files landing in object storage rather than a live database, the question becomes "which files haven't we loaded yet?" Databricks offers two built-in mechanisms:

COPY INTOAuto Loader
InterfaceSQL command, idempotent batchStructured Streaming source
ScaleThousands of files; less efficient beyond thatMillions to billions of files, near real-time
Tracking new filesAlready-loaded files are skipped automaticallyCheckpointed file metadata — exactly-once, fault tolerant
-- COPY INTO: run repeatedly, only new files are picked up
COPY INTO my_table
FROM '/path/to/files'
FILEFORMAT = CSV
FORMAT_OPTIONS ('delimiter' = '|', 'header' = 'true')
COPY_OPTIONS ('mergeSchema' = 'true');

# Auto Loader: a streaming source that discovers new files as they land
spark.readStream.format("cloudFiles") \
    .option("cloudFiles.format", "json") \
    .option("cloudFiles.schemaLocation", "/schema/orders") \
    .load("/path/to/files") \
  .writeStream \
    .option("checkpointLocation", "/checkpoints/orders") \
    .option("mergeSchema", "true") \
    .trigger(availableNow=True) \
    .table("orders")

Rule of thumb: reach for COPY INTO on modest, occasional batches; reach for Auto Loader once file counts climb into the millions or you need near-real-time pickup. trigger(availableNow=True) runs Auto Loader as a one-shot batch over everything new, rather than continuously.

5. Change Data Capture

CDC reads database changes directly from the transaction log instead of polling with a watermark query — it captures inserts, updates, and deletes, in order, with minimal load on the source.

-- Enable Change Data Feed on a Delta table
ALTER TABLE orders SET TBLPROPERTIES (delta.enableChangeDataFeed = true);

-- Read only what changed between two versions
SELECT * FROM table_changes('orders', 10, 15)
WHERE _change_type IN ('insert', 'update_postimage', 'delete');

6. CDC Tooling Compared

ToolApproachFits when
DebeziumLog-based, publishes change events to KafkaSelf-managed, streaming-first, open-source stack
AWS DMSLog-based, managed, full-load + ongoing CDC in one taskMigrating into or replicating within AWS (RDS, S3, Redshift)
Fivetran / AirbyteManaged connectors, mostly API/cursor-basedSaaS sources without a transaction log to tail
Delta CDFTable-level, built into Delta Lake writesDownstream consumers already reading from Delta

Log-based CDC (Debezium, DMS) is preferred when available: it adds near-zero load to the source and captures deletes natively. Reach for connector-based tools when the source is a SaaS API with no accessible log.

7. Delivery Semantics & Idempotency

Practical idempotency levers: a deterministic primary key on every record (not an auto-increment assigned at write time), a MERGE/upsert instead of a blind INSERT, and checkpoints that record what was durably committed to the target — not just what was read from the source.

8. Late Data & Streaming Watermarks

In a continuous pipeline, "incremental" means handling data that arrives out of order, not just data that arrives late relative to wall-clock time. Structured Streaming's watermark tells the engine how long to keep waiting for stragglers before finalizing a window:

from pyspark.sql.functions import window

events \
  .withWatermark("event_time", "15 minutes") \
  .groupBy(window("event_time", "5 minutes"), "device_id") \
  .count()

A tighter watermark finalizes results sooner but drops more genuinely-late events; a looser one keeps more state in memory and delays output. Size it against how late your source realistically arrives, not an arbitrary default.

9. Merge / Upsert

Once changed rows are identified, they're applied to the target with an upsert — insert new keys, update existing ones, optionally delete removed ones — rather than blindly appending duplicates.

-- Delta Lake MERGE INTO
MERGE INTO target AS t
USING changes AS s
ON t.order_id = s.order_id
WHEN MATCHED AND s._change_type = 'delete' THEN DELETE
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;

-- Redshift equivalent: stage, then merge with a delete+insert
DELETE FROM target USING staging s WHERE target.order_id = s.order_id;
INSERT INTO target SELECT * FROM staging;

10. Partitioned Incremental Loads

Partitioning the target by load date (or event date) turns most incremental writes into an overwrite of just the affected partitions, instead of a row-level merge — simpler and often faster when a source naturally arrives in daily batches.

df.write \
  .mode("overwrite") \
  .option("partitionOverwriteMode", "dynamic") \
  .partitionBy("load_date") \
  .parquet("/data/orders/")

Dynamic partition overwrite replaces only the partitions present in the incoming DataFrame, leaving every other date's data untouched — a much cheaper substitute for a row-by-row merge when the grain of change is "a whole day changed," not "a few scattered rows changed."

11. Backfill & Reprocessing

Incremental pipelines still need a path back to "reload everything" — a source fixes a bug retroactively, a bad job corrupts a partition, or business logic changes and history must be recomputed.

12. Monitoring & Data Quality

13. Design Checklist

Idempotency — re-running a load for the same window should not duplicate or corrupt data.

Deletes — a watermark alone misses them; use CDC, a soft-delete flag, or periodic full reconciliation.

Late-arriving data — widen the extraction window slightly and dedupe, rather than trusting a hard cutoff.

Checkpointing — persist "what's been loaded" outside the job itself, so a crash mid-run resumes correctly instead of reprocessing or skipping.

Schema drift — decide upfront whether new columns should auto-merge or fail loudly (see the Glue schema-evolution notes in the AWS reference doc).