Extracting Changeset Metadata from History Files Jump to heading
From a full-history .osh.pbf, collect the provenance of every element version — the changeset that made it, the editor’s uid and name, and the timestamp — into a table you can audit or aggregate by contributor.
Prerequisites Jump to heading
Confirm each item; the most common surprise is a file that parses cleanly but reports zeros for every metadata field because its metadata was stripped upstream.
Conceptual minimum Jump to heading
Every version of every OSM element carries a fixed metadata block independent of its tags and geometry: a version counter, the changeset that committed it, the editor’s numeric uid and display user, and a UTC timestamp. In a current-state extract you see this block once per object; in a full-history file you see it once per version, which is exactly what makes the file usable for auditing — you can reconstruct who changed what, when, and under which changeset across an object’s whole life. This is the same per-version stream used to reconstruct features at a past date, read for provenance rather than for state. Extraction is therefore a flat projection: for each version, emit one row of (type, id, version, changeset, uid, user, timestamp), and leave the tags behind.
The one caveat that governs whether this works at all is metadata availability. The OSM PBF and XML schemas treat object metadata as optional, so a producer can strip it to shrink a file — and when it is stripped, pyosmium returns version = 0, changeset = 0, uid = 0, an empty user, and an invalid timestamp rather than raising. History files distributed as .osh.pbf always retain metadata, because the metadata is the history; the risk arises with self-made extracts. Whenever you cut a region from a history planet you must keep history explicitly, and if a downstream tool re-encoded the file with metadata disabled, provenance is simply gone and no code can recover it. The reader below detects that condition instead of silently emitting a table of zeros.
Runnable solution Jump to heading
The handler below collects one provenance row per version into a list, guarding against stripped metadata, and then aggregates the rows into a per-contributor summary. The aggregation uses pandas when it is available and falls back to the standard library otherwise.
from __future__ import annotations
import logging
from collections import Counter
import osmium
logger = logging.getLogger("osm.provenance")
class ProvenanceHandler(osmium.SimpleHandler):
"""Collect (type, id, version, changeset, uid, user, timestamp) per version."""
def __init__(self) -> None:
super().__init__()
self.rows: list[dict[str, object]] = []
self.stripped = 0 # versions whose metadata looks absent
def _row(self, kind: str, obj: osmium.osm.OSMObject) -> None:
# Stripped metadata surfaces as version 0 and changeset 0, not an error.
if obj.version == 0 and obj.changeset == 0:
self.stripped += 1
return
self.rows.append(
{
"type": kind,
"id": obj.id,
"version": obj.version,
"changeset": obj.changeset,
"uid": obj.uid,
"user": obj.user, # may be "" if redacted
"timestamp": obj.timestamp.isoformat() if obj.timestamp else None,
"visible": obj.visible,
}
)
def node(self, n: osmium.osm.Node) -> None:
self._row("node", n)
def way(self, w: osmium.osm.Way) -> None:
self._row("way", w)
def relation(self, r: osmium.osm.Relation) -> None:
self._row("relation", r)
def extract_provenance(path: str) -> list[dict[str, object]]:
"""Return one provenance row per element version in *path*."""
reader = osmium.io.Reader(path)
if not reader.header().has_multiple_object_versions():
logger.warning("%s is not a history file; only head versions will appear", path)
reader.close()
handler = ProvenanceHandler()
handler.apply_file(path)
if handler.stripped:
logger.warning(
"%d versions had no metadata (version=0); source may be stripped",
handler.stripped,
)
logger.info("collected %d provenance rows", len(handler.rows))
return handler.rows
def contributor_summary(rows: list[dict[str, object]]) -> list[tuple[int, str, int]]:
"""Aggregate rows into (uid, user, edit_count), busiest first."""
try:
import pandas as pd
frame = pd.DataFrame(rows)
grouped = (
frame.groupby("uid")
.agg(user=("user", "last"), edits=("version", "size"))
.sort_values("edits", ascending=False)
.reset_index()
)
return list(grouped.itertuples(index=False, name=None))
except ImportError:
counts: Counter[int] = Counter(r["uid"] for r in rows)
names = {r["uid"]: r["user"] for r in rows}
return [(uid, names[uid], n) for uid, n in counts.most_common()]
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
provenance = extract_provenance("history.osh.pbf")
for uid, user, edits in contributor_summary(provenance)[:10]:
logger.info("uid=%s user=%r edits=%d", uid, user, edits)
Step-by-step walkthrough Jump to heading
- Check the header first.
extract_provenanceopens anosmium.io.Readerand inspectshas_multiple_object_versions()before parsing. On a current-state file it warns that only head versions exist, so a caller never mistakes a one-row-per-object result for a full history. - Detect stripped metadata, don’t crash on it. A version with both
version == 0andchangeset == 0had its metadata removed upstream; the handler counts these separately rather than writing rows full of zeros that would corrupt any contributor tally. - Project, don’t reconstruct.
_rowcopies only the provenance fields and ignores tags and node references, so the handler stays cheap even on a large file — there is no geometry assembly. - Keep
useras best-effort. The display name can be blank when an account was deleted or a version redacted, whileuidremains stable. Rows retain both, and the summary groups onuidso redacted names never split one contributor into several buckets. - Aggregate flexibly.
contributor_summaryuses pandas for a fastgroupbywhen it is installed and otherwise falls back tocollections.Counter, so the extraction has no hard dependency on the data-frame stack. - Timestamps serialize as ISO-8601.
obj.timestamp.isoformat()produces a sortable, timezone-aware string that loads cleanly into a database or a data frame later.
Verification Jump to heading
- Row count matches version count. For a small region,
len(rows)plus thestrippedcount should equal the total version count reported byosmium fileinfo -e history.osh.pbf. - No zero uids among real edits.
uid = 0in the output means anonymous or stripped edits leaked past the guard; inspect those rows before trusting the tally. - Changesets are monotone per object. Within one
(type, id),changesetvalues should generally increase withversion; a decrease signals rows sorted or merged incorrectly. - Spot-check against the API. Pick a changeset id from the table and confirm its editor and timestamp on the live OSM changeset view; they must match the extracted row.
- The stripped-metadata warning fires appropriately. Running against a deliberately metadata-free file should log the
versions had no metadatawarning and return no rows.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
Every row shows changeset=0, uid=0 |
Source written with add_metadata=false |
Re-source a file that retains metadata; provenance cannot be recovered. |
| Only one row per object | Current-state file, not a history file | Use a .osh.pbf; check has_multiple_object_versions(). |
| Contributor split across blank names | Grouped on user not uid |
Aggregate on the numeric uid; treat user as a label. |
AttributeError on timestamp.isoformat |
Timestamp invalid on a stripped version | Guard with if obj.timestamp else None, as shown. |
History lost after osmium extract |
--with-history omitted on the cut |
Re-run the extract with --with-history. |
| Memory climbs on a planet history | All rows buffered in one list | Stream rows to CSV/Parquet instead of appending in RAM. |
Specification reference Jump to heading
Each OSM element carries optional metadata —
version,changeset,timestamp,uid, anduser— that records the edit provenance; see the OSM Wiki Elements page for the field definitions and Changeset for how edits are grouped. Because the metadata is optional in the PBF and XML schemas, a file can be written without it, in which case these fields are absent; the pyosmium documentation describes the attributes exposed on each object.
Related Jump to heading
- Full-History .osh.pbf Processing — the format and the version stream this extraction reads.
- Reconstructing OSM Features at a Past Date — the state-oriented sibling that reads the same versions to rebuild geometry at T.
- How to Decode OSM PBF Headers in Python — confirming a file is historical and metadata-bearing before extraction.
- Node-Way-Relation Data Model — the elements whose per-version metadata this table projects.
- Applying .osc Change Files with osmium — the diffs that create the changeset-stamped versions you are auditing.
Up one level: Full-History .osh.pbf Processing.
Frequently Asked Questions Jump to heading
Why are all my changeset and uid values zero?
The source file was written without object metadata, which the PBF and XML schemas permit. When metadata is stripped, pyosmium returns version and changeset as zero, uid as zero, an empty user, and an invalid timestamp rather than raising an error. Provenance cannot be reconstructed from such a file; you need a source that retained its metadata, which every distributed .osh.pbf does.
Should I group contributor stats on uid or user?
Group on the numeric uid. The display name can change over time, and it can be blank when an account was deleted or a version redacted, so grouping on the name splits one contributor into several buckets or merges anonymous rows. The uid is stable across a contributor’s history, so it is the correct aggregation key; carry the name along only as a label.
Do I need the whole history file to get changeset metadata?
Only if you want provenance for past versions. A current-state extract still carries the metadata of each object’s latest version, so you can attribute the head state from it. To audit an object’s full edit trail — every changeset that ever touched it — you need the full-history file, because a current-state file keeps just the newest version per object.
How do I keep provenance when cutting a regional extract?
Pass --with-history to osmium extract so the cut preserves version chains and their metadata. A plain extract reduces the file to current state and discards older versions, and any re-encoding step that disables metadata output removes the changeset, uid, and timestamp fields entirely. Verify the result with osmium fileinfo before relying on it.