Tracking an OSM Feature Across Versions Jump to heading
A version number says an object changed. The history says what changed, and that is the difference between knowing a stored reference needs review and knowing whether it needs anything at all.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Every OSM object retains all its versions, and the API serves them individually or as a list. Each version carries its tags, its geometry or member references, the changeset that produced it, and a timestamp.
Comparing consecutive versions classifies each edit into one of a small number of kinds, and the classification is what makes a history readable.
A tag change alters the tag dictionary and nothing else. Usually benign for a stored reference, occasionally meaning-changing.
A geometry change alters a node’s coordinate or a way’s node list. For a way, a shortened node list is the signature of a split, which is the case a version number cannot reveal.
A membership change alters a relation’s member list, which affects anything that resolved the relation into geometry.
A deletion marks the version as not visible, which the API represents by an absent object in the current state and a final version flagged accordingly.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from enum import Enum
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.identity.history")
API = "https://api.openstreetmap.org/api/0.6"
HEADERS = {"User-Agent": "osm-pipeline-example/1.0 (contact@example.org)"}
SPLIT_RATIO = 0.75 # a node list losing a quarter looks like a split
class ChangeKind(str, Enum):
TAGS = "tags"
GEOMETRY = "geometry"
MEMBERS = "members"
LIKELY_SPLIT = "likely_split"
DELETED = "deleted"
NONE = "none"
@dataclass(frozen=True)
class Version:
version: int
visible: bool
timestamp: str
changeset: int
user: str | None
tags: dict[str, str]
nodes: tuple[int, ...] # ways only
members: tuple[tuple[str, int, str], ...] # relations only
@property
def node_count(self) -> int:
return len(self.nodes)
def parse_version(element: ET.Element) -> Version:
return Version(
version=int(element.get("version")),
visible=element.get("visible", "true") != "false",
timestamp=element.get("timestamp", ""),
changeset=int(element.get("changeset", 0)),
user=element.get("user"),
tags={t.get("k"): t.get("v") for t in element.findall("tag")},
nodes=tuple(int(n.get("ref")) for n in element.findall("nd")),
members=tuple((m.get("type"), int(m.get("ref")), m.get("role", ""))
for m in element.findall("member")),
)
def history(osm_type: str, osm_id: int) -> list[Version]:
response = requests.get(f"{API}/{osm_type}/{osm_id}/history",
headers=HEADERS, timeout=60)
response.raise_for_status()
root = ET.fromstring(response.content)
versions = [parse_version(e) for e in root.findall(osm_type)]
versions.sort(key=lambda v: v.version)
logger.info("%s/%d has %d version(s)", osm_type, osm_id, len(versions))
return versions
def classify(before: Version, after: Version) -> set[ChangeKind]:
kinds: set[ChangeKind] = set()
if not after.visible:
return {ChangeKind.DELETED}
if before.tags != after.tags:
kinds.add(ChangeKind.TAGS)
if before.nodes != after.nodes:
kinds.add(ChangeKind.GEOMETRY)
# A node list that lost a substantial share, with the remainder still a
# prefix or suffix of the original, is what a split looks like.
if (before.node_count and
after.node_count < before.node_count * SPLIT_RATIO and
(after.nodes == before.nodes[:after.node_count]
or after.nodes == before.nodes[-after.node_count:])):
kinds.add(ChangeKind.LIKELY_SPLIT)
if before.members != after.members:
kinds.add(ChangeKind.MEMBERS)
return kinds or {ChangeKind.NONE}
def walk(osm_type: str, osm_id: int) -> list[tuple[int, set[ChangeKind]]]:
versions = history(osm_type, osm_id)
timeline: list[tuple[int, set[ChangeKind]]] = []
for before, after in zip(versions, versions[1:]):
kinds = classify(before, after)
timeline.append((after.version, kinds))
marker = " <-- SPLIT" if ChangeKind.LIKELY_SPLIT in kinds else ""
logger.info("v%-3d %-19s %-22s by %s%s", after.version,
after.timestamp, ",".join(sorted(k.value for k in kinds)),
after.user or "(anonymous)", marker)
return timeline
def changed_since(osm_type: str, osm_id: int,
stored_version: int) -> set[ChangeKind]:
"""What has happened to this object since we last looked at it?"""
versions = history(osm_type, osm_id)
relevant = [v for v in versions if v.version >= stored_version]
if len(relevant) < 2:
return {ChangeKind.NONE}
kinds: set[ChangeKind] = set()
for before, after in zip(relevant, relevant[1:]):
kinds |= classify(before, after)
kinds.discard(ChangeKind.NONE)
return kinds or {ChangeKind.NONE}
if __name__ == "__main__":
logger.info("changes since v3: %s", changed_since("way", 4305800, 3))
Step-by-step walkthrough Jump to heading
- Sort by version explicitly. The API returns versions in order in practice, and relying on that rather than asserting it is how a subtle ordering bug survives.
- Parse all three shapes in one record. Nodes have coordinates, ways have node lists, relations have members. One dataclass with empty tuples for the inapplicable fields keeps the comparison logic uniform.
- Check visibility first. A deleted version has no meaningful tag or geometry diff, and classifying it as a tag change would be nonsense.
- Detect a split structurally. A shortened node list where the remainder is a prefix or suffix of the original is the signature. Checking the prefix or suffix condition is what distinguishes a split from a way that simply had nodes removed.
- Return a set, not a single kind. One edit can change tags and geometry together, and collapsing that to one label loses information.
- Compare from the stored version forward.
changed_sinceaccumulates the kinds across every intervening version, which is the question a pipeline with a stored reference actually has. - Log the user and timestamp. When a stored reference breaks, the next question is always who changed it and when, and having it in the same output saves a second lookup.
Verification Jump to heading
- A known split is detected. Find a way you know was split and confirm the classifier flags that version.
- An insertion is not flagged. A way that gained nodes should be a plain geometry change.
- Deleted objects classify as deleted. Fetch the history of a deleted object and confirm the last transition is a deletion.
- Relations classify on members. A relation whose members changed should report a membership change, not a geometry one.
changed_sinceaccumulates. An object with three intervening edits of different kinds should report all of them.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Splits not detected | Only version numbers compared | Diff the node lists and test for a prefix or suffix |
| Insertions reported as splits | Length compared without the prefix test | Require the remainder to be contiguous from one end |
| Deleted objects misclassified | Visibility checked after the diff | Test visibility first and return early |
| Relations report geometry changes | Members compared as if they were nodes | Compare member tuples separately from node lists |
| One kind returned per edit | Classification collapsed to a single label | Return a set; edits routinely change several things |
| History requests rate limited | Per-object API calls at volume | Use a history file for anything beyond a handful |
| Ordering assumed | Versions used as returned | Sort by version number explicitly |
Specification reference Jump to heading
The API serves an object’s full version history at the
/historypath, returning every version with its tags, geometry or members, changeset, timestamp and author, and marking deleted versions as not visible. Individual versions are also retrievable by number. See the OSM API v0.6 documentation for the history endpoints and the version representation.
Frequently Asked Questions Jump to heading
How do I tell a split from a way that just lost nodes?
By checking whether the remaining nodes form a contiguous run from one end of the original list. In a split, one part keeps the original identifier and its node list becomes a prefix or a suffix of what it was. A way that had nodes removed from the middle, or simplified, leaves a list that is shorter but not contiguous in that way. The structural test is what distinguishes the two, and a length comparison alone cannot.
Should I use the API or a history file?
The API for a handful of objects, a history file for anything else. Each history request is a separate call against the same shared infrastructure everything else uses, so investigating a few hundred objects one at a time is both slow and impolite. A history extract answers the same questions locally at whatever rate your hardware allows, which is the only practical route for a scheduled re-resolution job.
Can one edit change several things at once?
Routinely. A single changeset can retag a way, adjust its geometry and alter its relation memberships, and a classifier that returns one label per edit will report whichever it checked first. Returning a set costs nothing and preserves the distinction between an edit that only fixed a spelling and one that also moved the road.
What does a version tell me that a timestamp does not?
The version is a monotonic counter, so it answers “has this changed since I looked” exactly, while timestamps can be equal for edits in the same changeset and are recorded with limited resolution. The timestamp is what you want for human-facing questions about when something happened; the version is what you want for the machine-facing question of whether anything happened at all.
Related Jump to heading
- OSM Feature Identity & ID Stability — the parent topic and why versions are stored alongside identifiers.
- Building Stable Surrogate Keys for OSM Features — decoupling your keys from this churn.
- Full History .osh.pbf Processing — the bulk alternative to per-object history requests.
- Reconstructing OSM Features at a Past Date — recovering the state a stored reference described.
- Extracting Changeset Metadata from History Files — the changeset context behind each version.
Up one level: OSM Feature Identity & ID Stability.