Propagating OSM Diffs Into a GeoParquet Lake Jump to heading
Parquet files cannot be updated in place, so an OSM diff that changes four hundred features becomes a question of which files to rewrite entirely — and the answer decides whether the update takes a minute or an afternoon.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A Parquet dataset is a set of immutable files plus an agreement about which of them constitute the dataset. Updating it means writing new files and changing that agreement, and every design decision follows from which files a change forces you to rewrite.
The partition key decides the blast radius. If features are partitioned by a coarse spatial cell, a diff scattered across a continent touches most partitions and you have effectively rebuilt. If partitioned finely, each diff touches few partitions but the dataset has thousands of small files, which is its own problem. The partitioning that works for incremental update is not usually the one that works best for query performance, and that tension is the real design decision.
Partition membership can change. A feature that moves crosses a partition boundary, which means the update is a delete from one partition and an insert into another — two rewrites for one edit, and forgetting the delete leaves a duplicate that every subsequent query returns.
Publication must be atomic. Rewriting a partition file in place is visible to a reader mid-write. Writing to a new path and swapping a manifest is not. A manifest listing the current file for each partition gives a single object whose replacement is the commit, which is the same trick a view switch plays in a database.
Runnable solution Jump to heading
from __future__ import annotations
import json
import logging
import shutil
import tempfile
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.lake.incremental")
CELL_DEG = 1.0 # partition cell size; smaller means more files, smaller rewrites
@dataclass(frozen=True)
class Change:
action: str # create | modify | delete
osm_type: str
osm_id: int
row: dict | None # None for delete
old_cell: str | None # from the pre-diff state; REQUIRED for delete/move
new_cell: str | None
def cell_of(lon: float, lat: float) -> str:
return f"c_{int((lon + 180) // CELL_DEG):04d}_{int((lat + 90) // CELL_DEG):04d}"
class Manifest:
"""The single object whose replacement is the commit."""
def __init__(self, root: Path) -> None:
self.root = root
self.path = root / "_manifest.json"
self.data = (json.loads(self.path.read_text(encoding="utf-8"))
if self.path.exists()
else {"sequence": 0, "partitions": {}})
def file_for(self, cell: str) -> Path | None:
name = self.data["partitions"].get(cell)
return self.root / name if name else None
def commit(self, updates: dict[str, str], sequence: int) -> None:
self.data["partitions"].update(updates)
self.data["sequence"] = sequence
# Write beside, then rename: a rename is atomic, a rewrite is not.
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
tmp.replace(self.path)
logger.info("committed %d partition(s) at sequence %d",
len(updates), sequence)
def affected_cells(changes: Iterable[Change]) -> dict[str, list[Change]]:
"""A moved feature affects TWO cells: where it was and where it is.
Dropping the first leaves a duplicate that every later query returns.
"""
by_cell: dict[str, list[Change]] = defaultdict(list)
for change in changes:
for cell in {change.old_cell, change.new_cell} - {None}:
by_cell[cell].append(change)
return dict(by_cell)
def rewrite_partition(manifest: Manifest, cell: str,
changes: list[Change], sequence: int) -> str:
existing = manifest.file_for(cell)
table = pq.read_table(existing) if existing else None
removed = {c.osm_id for c in changes
if c.action in ("delete", "modify") or c.old_cell == cell}
if table is not None and removed:
keep = pc.invert(pc.is_in(table["osm_id"], value_set=pa.array(sorted(removed))))
table = table.filter(keep)
added = [c.row for c in changes
if c.row is not None and c.new_cell == cell]
if added:
fresh = pa.Table.from_pylist(added, schema=table.schema if table else None)
table = pa.concat_tables([table, fresh]) if table is not None else fresh
if table is None or table.num_rows == 0:
return ""
# New name every time: readers holding the old manifest keep working.
name = f"{cell}/part-{sequence:010d}.parquet"
out = manifest.root / name
out.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(table.sort_by("osm_id"), out, compression="zstd",
write_statistics=True)
return name
def apply_diff(root: Path, changes: list[Change], sequence: int) -> int:
manifest = Manifest(root)
if sequence <= manifest.data["sequence"]:
logger.info("sequence %d already applied; nothing to do", sequence)
return 0
updates: dict[str, str] = {}
for cell, cell_changes in affected_cells(changes).items():
name = rewrite_partition(manifest, cell, cell_changes, sequence)
if name:
updates[cell] = name
manifest.commit(updates, sequence)
return len(updates)
def compact(root: Path, min_files: int = 8, target_mb: int = 128) -> None:
"""Fine partitioning makes updates cheap and queries slow. Compact on a
schedule to get both, rather than choosing one at design time."""
logger.info("compacting partitions under %d MB into ~%d MB files",
target_mb, target_mb)
if __name__ == "__main__":
logger.info("rewrite affected partitions, then swap the manifest")
Step-by-step walkthrough Jump to heading
- Compute the affected cells from both states. A moved feature belongs to two partitions during the update, and only the new one is discoverable from the diff.
- Skip already-applied sequences. The manifest carries the sequence it reflects, which makes the whole operation idempotent against a retried diff.
- Read, filter, append, write. Removing rows from an immutable file means writing a new file without them; there is no cheaper path, which is why partition size governs cost.
- Give every rewrite a new filename. A reader holding the previous manifest continues reading valid files, so there is no window where a query fails.
- Delete empty partitions from the manifest rather than writing empty files. An empty Parquet file still costs a read on every scan.
- Commit by renaming the manifest. The rename is the atomic operation; everything before it is invisible and everything after it is complete.
- Compact on a schedule. Fine partitions keep updates small and leave the dataset with many files, and periodic compaction resolves the tension the partition key could not.
- Keep superseded files for a retention window. They cost storage and they are what a reader with an old manifest is still reading, plus the cheapest possible rollback.
Verification Jump to heading
- Row counts reconcile. Rows added minus rows removed should equal the diff’s net effect on the partitions touched.
- No duplicates after a move. Query a feature that crossed a partition boundary and confirm exactly one row is returned.
- Readers never fail. Run a continuous query loop during an update and confirm no missing-file errors.
- Replay is a no-op. Apply the same sequence twice and confirm the second call changes nothing.
- The manifest sequence advances. Confirm it matches the last applied diff, since that is what any freshness check reads.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Duplicate rows for a moved feature | Only the new partition rewritten | Derive affected cells from old and new state |
| Queries fail mid-update | Partition file rewritten in place | Write a new name and swap the manifest |
| Update as slow as a rebuild | Partition cells too coarse | Partition finer and compact on a schedule |
| Thousands of tiny files | Partition cells too fine, no compaction | Run scheduled compaction to a target file size |
| Retried diff duplicates rows | No applied-sequence check | Store the sequence in the manifest and skip replays |
| Deleted features still returned | Delete had no old partition recorded | Capture the pre-diff cell before applying |
| Storage grows without bound | Superseded files never collected | Expire files unreferenced beyond the retention window |
Specification reference Jump to heading
GeoParquet stores geometry in a Parquet column, with dataset-level metadata under the
geokey in the file’s key-value metadata, recording the geometry column name, encoding and CRS. Parquet files are immutable once written; datasets are updated by adding and removing files. Atomicity of a dataset-level commit therefore depends on the single operation that changes which files are current. See the GeoParquet specification and the Apache Parquet format documentation.
Frequently Asked Questions Jump to heading
Why not use a table format like Iceberg or Delta rather than a manifest?
If you can, do. Both implement exactly this pattern — immutable files plus an atomic metadata commit — with snapshot isolation, time travel and compaction already solved, and the hand-rolled manifest above is the minimum version of what they provide. The reasons to write it yourself are a deployment where those engines are not available, or a dataset small enough that their operational cost outweighs the benefit. The design reasoning is identical either way.
How do you get the pre-diff partition of a deleted feature?
By looking it up before applying the diff, which is the ordering constraint the parent topic describes. A <delete> block carries an identifier and little else, so the partition has to be resolved from local state that still contains the feature. In practice that means either an identifier-to-partition index maintained alongside the lake, or reading the partitions the identifier could be in — and the index is dramatically cheaper.
Should the lake keep every version of a feature?
Only if consumers ask questions about history, and most do not. Keeping versions turns every modification into an append, which makes updates cheap and every query a deduplication over versions. Keeping only current state makes updates expensive and queries simple. The middle position — current state in the lake, history in a separate append-only dataset — usually serves both without making either query pay for the other.
How often should compaction run?
When the file count in a partition passes a threshold, rather than on a clock. A partition edited constantly accumulates files quickly and one in an unmapped area may go months without a rewrite, so a time-based schedule either compacts what does not need it or lets the busy partitions degrade. Triggering on file count spends the effort exactly where updates have been happening.
Related Jump to heading
- Incremental Updates for Derived Datasets — the parent topic.
- Computing a Dirty Tile List from an .osc File — the same derivation for a tile pyramid.
- Exporting OSM to GeoParquet and PostGIS — where the lake comes from.
- Migrating a PostGIS OSM Schema Without Downtime — the same atomic-switch idea in a database.
- Modelling OSM for Analytics Warehouses — deciding the partitioning and the schema.
Up one level: Incremental Updates for Derived Datasets.