How to Implement Incremental Data Loading in an ETL Pipeline
A full-load ETL that truncates the target table every run gets slower as data grows - and leaves the target empty during the reload window. Switching to incremental loading with a watermark means each run only processes changed rows, and the target stays populated throughout.
The problem with full loads
A common ETL pattern looks like this: TRUNCATE the target, then copy
everything from the source. It's simple, but it has two serious drawbacks:
- Performance degrades linearly - as the source grows, every run takes longer.
- Availability gap - between the
TRUNCATEand the finalCOMMIT, the target table returns zero rows to any query that hits it.
Incremental loading fixes both by only moving rows that are new or changed.
Watermarks: remembering where you left off
A watermark is a saved timestamp (or sequence number) marking the last row you
processed. You store it in a small pipeline_state table:
CREATE TABLE pipeline_state (
pipeline_name TEXT PRIMARY KEY,
last_watermark TIMESTAMPTZ,
last_run_at TIMESTAMPTZ
);
On each run, read the watermark, query only rows newer than it, process them, then
advance the watermark to the maximum updated_at you saw.
Python implementation
import psycopg2
from datetime import datetime
DB_URL = "postgresql://postgres:postgres@localhost:5432/app"
PIPELINE_NAME = "products_sync"
def sync_products():
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# 1. Read the last watermark (first run defaults to epoch)
cur.execute(
"SELECT last_watermark FROM pipeline_state WHERE pipeline_name = %s",
(PIPELINE_NAME,)
)
row = cur.fetchone()
watermark = row[0] if row and row[0] else datetime(1970, 1, 1)
# 2. Fetch only rows changed since the watermark
cur.execute("""
SELECT product_id, name, price, stock, updated_at
FROM products_source
WHERE updated_at > %s
ORDER BY updated_at
""", (watermark,))
rows = cur.fetchall()
# 3. Upsert each row - no TRUNCATE, target stays live
new_watermark = watermark
for pid, name, price, stock, updated_at in rows:
cur.execute("""
INSERT INTO products_target (product_id, name, price, stock, synced_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (product_id) DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
stock = EXCLUDED.stock,
synced_at = NOW()
""", (pid, name, price, stock))
if updated_at > new_watermark:
new_watermark = updated_at
# 4. Save the new watermark
cur.execute("""
INSERT INTO pipeline_state (pipeline_name, last_watermark, last_run_at)
VALUES (%s, %s, NOW())
ON CONFLICT (pipeline_name) DO UPDATE SET
last_watermark = EXCLUDED.last_watermark,
last_run_at = NOW()
""", (PIPELINE_NAME, new_watermark))
conn.commit()
cur.close()
conn.close()
print(f"Synced {len(rows)} rows; watermark now {new_watermark}")
Key design choices
INSERT ... ON CONFLICT DO UPDATE(upsert) handles both new rows and updates to existing rows in a single statement - no separateUPDATEpass.- Advance the watermark only after a successful commit - if the pipeline crashes mid-run, the next run re-processes the same window. This makes the pattern idempotent as long as upserts are idempotent.
- First-run bootstrap - defaulting to epoch (
1970-01-01) means the very first run does a full load, which is fine. After that, every run is incremental. - Use
updated_at, notcreated_at-updated_atcatches both inserts and updates. A watermark oncreated_atwould miss rows that were changed after their initial insert.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Storing and advancing a watermark in a pipeline_state table
- Querying a source table with WHERE updated_at > watermark for changed rows
- Upserting rows into the target with INSERT ... ON CONFLICT DO UPDATE
FAQ
What is a watermark in an ETL pipeline?
A watermark is a saved timestamp (or sequence number) that records the latest row already processed. On each run the pipeline reads the watermark, queries only rows newer than it, and then advances the watermark. This avoids reprocessing rows that were already loaded.
How does INSERT ... ON CONFLICT work for ETL upserts?
INSERT ... ON CONFLICT (column) DO UPDATE SET ... inserts a new row if the primary key does not exist, or updates the existing row if it does. This lets incremental ETL handle both new records and changes to existing records in one pass without a separate UPDATE query.
Why is incremental loading better than truncate-and-reload?
Truncate-and-reload empties the target table during every run, causing a gap where queries return zero rows. Incremental loading never truncates, so the target stays populated and queryable throughout. It also only moves changed rows, so runtime stays constant instead of growing with table size.
What is incremental data loading?
Incremental data loading moves only the data that changed since the last run - new and updated rows - instead of reloading the entire dataset. The pipeline tracks how far it got (usually a watermark timestamp), pulls only newer rows, and upserts them into the target.
What is the difference between full load and incremental load?
A full load reloads the entire source every run (often truncate-and-reload); it is simple but gets slower as data grows and blanks the target during reload. An incremental load moves only changed rows since the last watermark, so it stays fast and keeps the target continuously available.
What is the difference between CDC and incremental load?
Both move only changes, but the source differs. A watermark-based incremental load queries rows newer than a saved timestamp - simple, but it can miss hard deletes and out-of-order updates. Change Data Capture (CDC) reads the database's transaction log to capture every insert, update, and delete exactly. CDC is a more complete (and more complex) form of incremental loading.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →