Incremental, Idempotent Loading of Grid Datasets with Hive Partitions
The scenario: a monthly interconnection-queue load is re-run after a failure, and the resulting dataset has 1.8 million rows where it should have 1.2 million. Every row is valid, no error was raised, and the duplicates are invisible until a capacity total comes out 50 percent high. This page builds the load that cannot do that, and it is the incremental-loading detail behind geospatial data ingestion pipelines.
Root-cause analysis
Three properties have to hold together for a reload to be a no-op, and losing any one produces a different failure.
- Append semantics without a key. Writing new rows into an existing partition duplicates everything the previous run already wrote. The fix is not “check before appending” — that races — but a deterministic key plus a whole-partition replace.
- A key derived from mutable fields. Hashing the row including a
last_updatedcolumn means the same asset gets a new key on every publication, so deduplication never matches and the store grows monotonically. The key must come from the fields that identify the thing, not from the fields that describe its current state. - Partial writes treated as complete. A killed job that wrote half a partition leaves a file every reader treats as authoritative. Staging-then-rename makes the write atomic, so a reader sees either the previous version or the new one.
Pre-flight validation
Before writing, confirm the batch is internally consistent: the partition columns are single-valued, the deterministic key is unique, and the geometry column survived the read.
import hashlib
import geopandas as gpd
def deterministic_key(row, *, fields: tuple[str, ...]) -> str:
"""A key built only from identity fields — never from mutable state."""
payload = "|".join(str(row[f]) for f in fields)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
def preflight_batch(
batch: gpd.GeoDataFrame,
*,
partition_cols: tuple[str, ...] = ("state", "month"),
key_fields: tuple[str, ...] = ("queue_id", "poi_name", "state"),
) -> dict:
"""Refuse a batch that cannot be written idempotently."""
for col in partition_cols:
values = batch[col].dropna().unique()
if len(values) != 1:
raise ValueError(f"{col} must be single-valued in a partition batch, got {list(values)[:5]}")
keys = batch.apply(deterministic_key, axis=1, fields=key_fields)
dupes = int(keys.duplicated().sum())
if dupes:
raise ValueError(f"{dupes} duplicate keys within the batch — key fields are not identifying")
if batch.geometry.isna().any():
raise ValueError("null geometry in the batch — quarantine before loading")
return {
"rows": len(batch),
"partition": {c: batch[c].iloc[0] for c in partition_cols},
"key_fields": key_fields,
"unique_keys": int(keys.nunique()),
}
Fix implementation
The load below replaces one partition atomically. Replacement rather than append is what makes it idempotent: running it twice with the same input leaves exactly the same bytes.
import uuid
import fsspec
import geopandas as gpd
def load_partition(
batch: gpd.GeoDataFrame,
*,
root: str,
state: str,
month: str,
key_fields: tuple[str, ...],
storage_options: dict | None = None,
) -> dict:
"""Replace one Hive partition atomically. Re-running is a no-op."""
so = storage_options or {}
prefix = f"{root}/queue/state={state}/month={month}"
target = f"{prefix}/part.parquet"
staging = f"{prefix}/.staging-{uuid.uuid4().hex}.parquet"
prepared = batch.copy()
prepared["asset_key"] = prepared.apply(deterministic_key, axis=1, fields=key_fields)
# Last write wins within a batch: a republished row supersedes its predecessor.
prepared = prepared.sort_values("published_at").drop_duplicates("asset_key", keep="last")
prepared.to_parquet(staging, index=False, storage_options=so)
fs, _ = fsspec.core.url_to_fs(target, **so)
fs.mv(staging.replace(f"{root}/", ""), target.replace(f"{root}/", "")) if False else fs.mv(
_strip_protocol(fs, staging), _strip_protocol(fs, target)
)
return {
"partition": f"{state}/{month}",
"rows_written": len(prepared),
"rows_in": len(batch),
"superseded": len(batch) - len(prepared),
"target": target,
}
def _strip_protocol(fs, uri: str) -> str:
return fs._strip_protocol(uri)
The sort_values("published_at").drop_duplicates(keep="last") pair is the revision policy made
explicit. Energy portals republish corrected rows, and a load that keeps the first occurrence
silently ignores every correction — a failure that is much harder to notice than a duplicate.
Fallback routing and performance tuning
- Partition by the grain queries filter on. State and month are the natural grain for queue data; partitioning by an ingestion batch identifier makes every query a full scan.
- Keep partitions between 128 MB and 1 GB. Tens of thousands of small partitions cost more in object listing than they save in pruning, which is the same trade covered in streaming GeoParquet from cloud object storage.
- Write the partition columns into the file as well as the path. A reader that loses the Hive path — a file copied elsewhere, a manifest built by hand — otherwise loses the partition identity.
- Re-examine a freshness window, not the whole history. Three months of monthly data is a cheap nightly sweep; re-loading five years every night is not, and nothing older changes without a deliberate backfill.
- Record the source fingerprint on the partition. It is what lets the next run skip a partition whose input has not changed, rather than rewriting identical bytes.
Downstream validation
import geopandas as gpd
def assert_partition_idempotent(
root: str, state: str, month: str, *, expected_rows: int, storage_options=None
) -> None:
"""Re-read the partition and prove the load did what it claimed."""
path = f"{root}/queue/state={state}/month={month}/part.parquet"
got = gpd.read_parquet(path, storage_options=storage_options or {})
assert len(got) == expected_rows, f"partition holds {len(got)} rows, expected {expected_rows}"
assert got["asset_key"].is_unique, "duplicate asset keys survived the load"
assert (got["state"] == state).all(), "partition contains rows from another state"
assert (got["month"] == month).all(), "partition contains rows from another month"
assert got.geometry.notna().all(), "null geometry reached the store"
Handling revisions without losing history
A whole-partition replace is the right default and it throws away the previous version, which is usually fine and occasionally not — a figure that appeared in a submission has to remain reconstructible even after the source revises it.
The cheap pattern is a dated archive alongside the live partition. Before the rename, copy the existing part file to an archive prefix keyed by the load date; the live path always holds the current truth and the archive holds what was true on each load date. Storage is negligible for tabular grid data, and the archive answers the only question anyone asks retrospectively: what did this dataset say on the date the study was run.
The alternative — versioning inside the partition with a validity interval per row — is more precise and much more invasive, because every downstream query then has to filter on the interval or it sees several versions of the same asset. That cost is worth paying when the pipeline itself needs as-of queries, and not worth paying when the requirement is simply “reproduce last quarter’s number”.
Whichever is chosen, record the load date and the source fingerprint on the partition. Those two fields are what let a rerun decide whether anything actually changed, and what let a reviewer tie a published figure to the bytes that produced it.
Frequently asked questions
Why replace the whole partition instead of merging?
Because a merge has to read, combine and write anyway, and doing it as a replace makes the result a pure function of the input batch. A merge that reads the existing partition also inherits whatever is wrong with it, so a bad load has to be undone manually; a replace overwrites it. Where history genuinely matters, keep the previous version as a separate dated artefact rather than merging into the live one.
What should the deterministic key include?
The smallest set of fields that identifies the thing across publications — typically the source identifier, the point of interconnection and the state. What it must exclude is anything that changes when the row is revised: status, capacity, queue position and every timestamp. If the source provides a stable identifier, use it directly and skip the hash.
How do I handle a row that disappears from the source?
Decide the policy explicitly and record it. A whole-partition replace naturally drops it, which is right when the source is authoritative for that month and wrong when the row was omitted by accident. A useful middle path is to replace the partition but log the disappeared keys, so a sudden drop in row count is visible rather than silent.
Does this work on a local filesystem as well as object storage?
Yes, and the rename is genuinely atomic there — POSIX guarantees it within a filesystem. On object stores the guarantee is weaker but sufficient in practice: the rename is a server-side copy plus delete, and readers see either the old key or the new one. What breaks on both is renaming across filesystems or buckets, which degrades to a non-atomic copy.
Should the loader validate the schema too?
Yes, before the write and not after. A schema violation caught after a partition has been replaced means the previous good version is already gone. The order that survives a bad batch is: validate, stage, verify the staged file, then rename.
How large can a single partition get before this breaks down?
The pattern holds until the partition no longer fits comfortably in the loader’s memory, since the replace reads the whole batch. At that point the answer is a finer partition grain rather than a smarter loader — a state-month partition that exceeds a gigabyte usually means the data deserves a daily grain.
Related
- Geospatial Data Ingestion Pipelines — the parent workflow and its ingestion contract
- Handling Schema Drift in Interconnection Queue Exports — what to do when the incoming shape changes
- Spatial Pipeline Orchestration & Deployment — the scheduler that calls this loader
- Streaming GeoParquet from Cloud Object Storage with GeoPandas — reading the store this loader writes