This guide is part of Scheduling and Incremental Spatial Loads, within the broader Orchestrating Spatial ETL Pipelines reference.
Incremental Spatial Loading with Watermark Timestamps
The Problem: A Delta Load Is Only Safe If the Watermark Moves Correctly
An incremental spatial load reads a stored high-water mark, fetches everything newer, writes it, and records a new mark. The concept is simple; the failure modes are subtle, and every one of them corrupts the dataset silently rather than crashing.
- The watermark advances past data that was never loaded. If the pipeline writes the new mark from the wall clock and then crashes mid-write, the next run starts after records that never landed — a permanent, invisible gap in the spatial record.
- Late-arriving scenes are skipped forever. Querying with
> watermarkexactly means any record the source backdated or published after the last run falls below the lower bound and is never seen again. - The overlap window duplicates geometries. The fix for late data — re-reading a recent slice — floods the target with duplicate polygons unless the write is idempotent on a stable key.
- Naive local timestamps drift. A watermark stored as naive local time and compared against UTC source timestamps quietly shifts the window by the UTC offset, dropping or double-loading an hour of data at every run.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| Python | 3.10+ | Timezone-aware datetime, `X |
| GeoPandas | >=0.14 |
to_postgis() write path used by the upsert |
| Shapely | >=2.0 |
Vectorized shapely.is_valid(arr) for pre-load validation |
| SQLAlchemy | >=2.0 |
engine.begin() transactional scope for atomic advance |
pip install "geopandas>=0.14" "shapely>=2.0" "sqlalchemy>=2.0"How the Watermark Advances Across Runs
The correctness of the whole pattern lives in the ordering of four operations and in choosing the new watermark from data, not the clock. The sequence below traces one run against the source and the state table.
The incremental_load Function
The function below is the complete pattern in one place: it reads the watermark, fetches the delta through a caller-supplied fetch function, validates geometry with the array API, upserts on a stable key, and advances the watermark — derived from the batch maximum — inside the same transaction as the load. The fetch_delta callable is injected so the same loader works against a STAC search, a portal download, or a database change feed.
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Callable
import geopandas as gpd
import shapely
from sqlalchemy import text
from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__)
# A record must expose a source timestamp column the watermark is measured against.
FetchDelta = Callable[[datetime], gpd.GeoDataFrame]
def incremental_load(
engine: Engine,
source_id: str,
fetch_delta: FetchDelta,
target_table: str,
*,
ts_col: str = "updated_at",
key: str = "scene_id",
overlap: timedelta = timedelta(hours=48),
floor: datetime = datetime(2020, 1, 1, tzinfo=timezone.utc),
) -> int:
"""Load only records newer than the stored watermark, then advance it atomically.
Returns the number of rows upserted. A run that finds no new records is a
cheap no-op that leaves the watermark untouched.
"""
# 1. Read the stored high-water mark (timezone-aware UTC), or the floor on first run.
watermark = _read_watermark(engine, source_id, floor)
lower_bound = watermark - overlap # overlap re-reads late arrivals; upsert dedupes
logger.info(
"incremental_load | source=%s | watermark=%s | lower_bound=%s",
source_id, watermark.isoformat(), lower_bound.isoformat(),
)
# 2. Fetch the delta. The caller scopes extraction to records >= lower_bound.
gdf = fetch_delta(lower_bound)
if gdf.empty:
logger.info("incremental_load | source=%s | no new records", source_id)
return 0
# Normalize the source timestamp column to UTC so comparisons never drift.
gdf[ts_col] = gpd.pd.to_datetime(gdf[ts_col], utc=True)
# 3. Validate geometry with the vectorized Shapely 2.0 API — never per-row apply.
valid = shapely.is_valid(gdf.geometry.values) & ~gdf.geometry.is_empty.values
dead = gdf.loc[~valid]
if len(dead):
logger.warning(
"incremental_load | source=%s | quarantining %d invalid geometries",
source_id, len(dead),
)
gdf = gdf.loc[valid].copy()
if gdf.empty:
logger.warning("incremental_load | source=%s | all records invalid", source_id)
return 0
# New watermark comes from the DATA, not the clock — the max timestamp observed.
new_watermark: datetime = gdf[ts_col].max().to_pydatetime()
# 4. Upsert and advance in a single transaction: a crash rolls back BOTH.
ids = gdf[key].unique().tolist()
with engine.begin() as conn:
conn.execute(
text(f"DELETE FROM {target_table} WHERE {key} = ANY(:ids)"),
{"ids": ids},
)
gdf.to_postgis(target_table, conn, if_exists="append", index=False)
conn.execute(
text(
"INSERT INTO etl_watermark (source_id, watermark_ts) "
"VALUES (:sid, :ts) "
"ON CONFLICT (source_id) DO UPDATE SET watermark_ts = EXCLUDED.watermark_ts"
),
{"sid": source_id, "ts": new_watermark},
)
logger.info(
"incremental_load | source=%s | upserted=%d | advanced_to=%s",
source_id, len(gdf), new_watermark.isoformat(),
)
return len(gdf)
def _read_watermark(engine: Engine, source_id: str, floor: datetime) -> datetime:
"""Fetch the stored watermark, normalized to timezone-aware UTC, or the floor."""
with engine.connect() as conn:
row = conn.execute(
text("SELECT watermark_ts FROM etl_watermark WHERE source_id = :sid"),
{"sid": source_id},
).fetchone()
if row is None or row[0] is None:
return floor
ts: datetime = row[0]
return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)Key Implementation Notes
- The new watermark is
gdf[ts_col].max(), neverdatetime.now(). Anchoring it to the maximum timestamp actually loaded guarantees the mark never overtakes data the source has yet to publish. If a source’s clock and yours disagree, the data-derived value stays correct; a wall-clock value silently opens a gap. - The overlap is subtracted at query time, not stored. The watermark itself always records the true high-water mark. Only the lower bound of the next fetch is
watermark - overlap, so widening or narrowing the overlap is a one-line change that never requires rewriting stored state. - Load and advance share one
engine.begin()block. This is the crash-safety guarantee: if the process dies after the upsert but before the watermark write, the transaction rolls back the upsert too, and the next run repeats the delta harmlessly. Splitting them into two transactions reintroduces the gap this pattern exists to prevent. - Idempotency comes from the key, not from luck.
DELETE ... WHERE scene_id = ANY(:ids)followed by an append makes the batch a set-replace onscene_id. Re-reading an overlapping record overwrites its own row rather than adding a second geometry. Choose a key that is stable across source revisions — a scene ID or tile ID, not a row number. - An empty delta is the common case and must be free. On a healthy schedule most runs find nothing new. Returning early on
gdf.emptybefore opening any transaction keeps those runs to a single indexed range query. - Validation splits rather than raises. Quarantining invalid geometries with the array API lets the good records advance the watermark; a single malformed feature must not freeze the whole stream.
Troubleshooting Watermark Loads
| Failure | Root Cause | Fix |
|---|---|---|
| Permanent gap after a crash | Watermark advanced in a separate step from the load | Wrap upsert and watermark write in one engine.begin() transaction |
| Recent records never appear | Fetch used > watermark with no overlap |
Query from watermark - overlap; let the upsert dedupe re-reads |
| Duplicate polygons every run | Overlap re-reads records into a plain insert | Upsert on a stable scene_id/tile_id with delete-then-insert or ON CONFLICT |
| Window shifts by a fixed offset each run | Naive-local watermark compared to UTC source timestamps | Store and compare watermarks as timezone-aware UTC; normalize the source column with utc=True |
| Watermark stops advancing | Every fetched record fails geometry validation | Inspect the dead-letter batch; repair upstream before the load, not after |
Integration Note
This loader is the reusable core of the scheduling and incremental spatial loads workflow: the scheduler decides when to call it and supplies the fetch_delta closure that scopes extraction to the source, while incremental_load owns the watermark discipline. Because the whole body is a single atomic upsert-and-advance, it slots directly into an orchestrated task without extra checkpointing — the same guarantees that make writing idempotent spatial ETL tasks in Airflow safe under retry apply here unchanged, since a retried incremental_load re-reads the same delta and converges on the same target state.
Related
- Scheduling and Incremental Spatial Loads — the surrounding workflow covering cadence, change detection, sensors, and backfills
- Writing Idempotent Spatial ETL Tasks in Airflow — the retry-safety guarantees this loader depends on inside an orchestrated task
- Orchestrating Spatial ETL Pipelines — the top-level reference for idempotency, partitioning, and load-target selection