Fetching OSM Changeset Metadata from the API Jump to heading
Get the comment, editor string, bounding box and account age that the diff stream does not carry — in batches, cached, and without hammering a shared community server.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A diff tells you who edited what, when. It does not tell you why, and it says nothing about the account. The changeset comment, the created_by editor string, the bounding box and the account creation date all live on a separate API — the split described in Extracting Changeset Metadata from History Files.
The single-changeset endpoint is the one people reach for and the one that makes this step expensive. The batch endpoint takes up to a hundred identifiers per call, which is the difference between an enrichment stage that keeps up with a minutely stream and one that does not.
Almost everything here is immutable, which makes caching unusually effective. A closed changeset can never change; an account creation date never changes at all. The single exception is a changeset that is still open, and the API tells you which those are.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Batch-fetch and cache OSM changeset metadata and account creation dates."""
from __future__ import annotations
import logging
import sqlite3
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timezone
from itertools import islice
from typing import Iterable, Iterator
import httpx
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
API = "https://api.openstreetmap.org/api/0.6"
BATCH = 100 # the endpoint's own limit
USER_AGENT = "osm-quality-pipeline/1.0 (ops@example.org)"
@dataclass(frozen=True)
class ChangesetMeta:
id: int
uid: int | None
user: str | None
created_at: datetime
closed_at: datetime | None
comment: str | None
created_by: str | None
num_changes: int
bbox: tuple[float, float, float, float] | None
@property
def is_open(self) -> bool:
return self.closed_at is None
def _chunk(items: Iterable[int], size: int) -> Iterator[list[int]]:
it = iter(items)
while batch := list(islice(it, size)):
yield batch
def _parse(elem: ET.Element) -> ChangesetMeta:
tags = {t.get("k"): t.get("v") for t in elem.findall("tag")}
box = None
if elem.get("min_lon") is not None:
box = (float(elem.get("min_lon")), float(elem.get("min_lat")),
float(elem.get("max_lon")), float(elem.get("max_lat")))
closed = elem.get("closed_at")
return ChangesetMeta(
id=int(elem.get("id")),
uid=int(elem.get("uid")) if elem.get("uid") else None,
user=elem.get("user"),
created_at=datetime.fromisoformat(elem.get("created_at").replace("Z", "+00:00")),
closed_at=datetime.fromisoformat(closed.replace("Z", "+00:00")) if closed else None,
comment=tags.get("comment"),
created_by=tags.get("created_by"),
num_changes=int(elem.get("num_changes", 0)),
bbox=box,
)
class MetadataCache:
"""SQLite-backed cache. Closed changesets and account dates are immutable."""
def __init__(self, path: str = "changeset_cache.sqlite") -> None:
self.db = sqlite3.connect(path)
self.db.executescript("""
CREATE TABLE IF NOT EXISTS changeset (
id INTEGER PRIMARY KEY, uid INTEGER, user TEXT,
created_at TEXT, closed_at TEXT, comment TEXT,
created_by TEXT, num_changes INTEGER, bbox TEXT,
missing INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS account (uid INTEGER PRIMARY KEY, created_at TEXT);
""")
def known(self, ids: Iterable[int]) -> set[int]:
"""Ids we already hold, including ones we know are 404 — a miss is a fact."""
rows = self.db.execute(
f"SELECT id FROM changeset WHERE id IN ({','.join('?' * len(list(ids)))})",
list(ids)).fetchall()
return {r[0] for r in rows}
def put(self, meta: ChangesetMeta) -> None:
if meta.is_open:
return # still accumulating; ask again later
self.db.execute(
"INSERT OR REPLACE INTO changeset VALUES (?,?,?,?,?,?,?,?,?,0)",
(meta.id, meta.uid, meta.user, meta.created_at.isoformat(),
meta.closed_at.isoformat() if meta.closed_at else None,
meta.comment, meta.created_by, meta.num_changes,
",".join(map(str, meta.bbox)) if meta.bbox else None))
self.db.commit()
def put_missing(self, cid: int) -> None:
"""A 404 is permanent — redacted or never existed. Never ask again."""
self.db.execute(
"INSERT OR IGNORE INTO changeset (id, missing) VALUES (?, 1)", (cid,))
self.db.commit()
class ChangesetClient:
def __init__(self, cache: MetadataCache) -> None:
self.cache = cache
self.http = httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30.0)
def _get(self, url: str, params: dict | None = None) -> httpx.Response | None:
"""One request, with the only retries that are worth making."""
for attempt in range(4):
response = self.http.get(url, params=params)
if response.status_code == 200:
return response
if response.status_code == 404:
return None # permanent; do not retry
if response.status_code == 429:
wait = float(response.headers.get("Retry-After", 2 ** attempt))
logger.warning("rate limited, sleeping %.1fs", wait)
time.sleep(wait)
continue
if response.status_code >= 500:
time.sleep(2 ** attempt)
continue
response.raise_for_status()
raise RuntimeError(f"giving up on {url} after 4 attempts")
def fetch(self, ids: Iterable[int]) -> dict[int, ChangesetMeta]:
"""Batch-fetch, skipping anything already cached."""
wanted = [i for i in set(ids) if i not in self.cache.known([i])]
out: dict[int, ChangesetMeta] = {}
for batch in _chunk(wanted, BATCH):
response = self._get(f"{API}/changesets",
{"changesets": ",".join(map(str, batch))})
if response is None:
for cid in batch:
self.cache.put_missing(cid)
continue
returned = set()
for elem in ET.fromstring(response.text).findall("changeset"):
meta = _parse(elem)
out[meta.id] = meta
self.cache.put(meta)
returned.add(meta.id)
for cid in set(batch) - returned: # asked for, not returned → gone
self.cache.put_missing(cid)
logger.info("fetched %d/%d changeset(s)", len(returned), len(batch))
return out
Step-by-step walkthrough Jump to heading
fetch filters against the cache before making any request, which in steady state removes most of the work — the same mappers edit repeatedly, so their changesets and accounts are already known.
_get implements exactly three behaviours and no more. A 404 returns None and is recorded permanently, because a redacted or non-existent changeset will still not exist tomorrow. A 429 sleeps for the server-supplied Retry-After, which is the only correct response to being told to slow down. A 5xx retries with exponential backoff. Everything else raises, because a 400 means the request is wrong and retrying it will not fix that.
The gap between what was asked for and what came back is handled explicitly. The batch endpoint returns only the changesets that exist, silently omitting the rest, so a caller that does not diff the two sets will re-request missing identifiers on every pass forever.
put refuses to cache an open changeset. An open changeset’s num_changes and bounding box are still growing, and caching it freezes a partial record that will never be corrected.
The User-Agent is not optional politeness. The OSM API blocks requests without an identifying agent, and a contactable address in it is what lets an administrator get in touch before blocking you.
Verification Jump to heading
Check the cache is doing its job, which is the whole economics of this step:
before = client.http.request_count if hasattr(client.http, "request_count") else None
metas = client.fetch(changeset_ids) # first pass — cold
metas = client.fetch(changeset_ids) # second pass — should make zero requests
Then confirm the batch endpoint is actually being used, because falling back to per-changeset requests is silent and costs two orders of magnitude:
# Watch the request rate while enriching a minute of diffs.
python3 -c "import logging; ..." 2>&1 | grep -c 'fetched'
A minute of a country stream typically contains one to three hundred distinct changesets, which should be two to three batch calls. Anything approaching a hundred calls means the batching is not engaging.
Finally, sanity-check a known changeset against the website — openstreetmap.org/changeset/<id> shows the comment and editor, and they should match what the parser extracted.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| HTTP 403 on every request | No User-Agent, or a generic one |
Set a descriptive agent with a contact address |
| Enrichment is the slowest stage | Single-changeset endpoint in a loop | Use /changesets?changesets=… with up to 100 ids |
| Same 404s re-requested every run | Misses not cached | Record a permanent miss row |
comment always None |
Read from an attribute instead of a <tag> |
Comments are tags, not attributes |
| Cached record never fills in | Open changeset cached | Skip caching while closed_at is absent |
| Sporadic 429s in bursts | Retrying without honouring Retry-After |
Sleep for the header value |
Frequently Asked Questions Jump to heading
When is the changeset dump better than the API?
For anything historical. Calibrating a scoring model over a year of edits means millions of changesets, which is a download and a local scan rather than a polite number of API calls. The dump is published regularly, contains every changeset with its tags, and once loaded into a local table makes lookups free. Use the API only for changesets recent enough not to be in the dump yet.
Can I get the account creation date in the same batch?
No — accounts are a separate endpoint and there is no batch form. In practice this matters little, because the set of distinct users in a stream is far smaller than the set of changesets and account dates cache forever. Fetch them lazily on cache miss and the steady-state cost approaches zero.
How current is the API bounding box?
It reflects the changeset as of the last time it was updated, and for an open changeset it is still growing. This is one more reason to enrich on a delay rather than the instant a changeset first appears in a diff: waiting until it closes gives a complete record in one request instead of an incomplete one that has to be re-fetched.
Should enrichment block the detection pipeline?
No. Detection should work from the diff alone, and enrichment should decorate the queue afterwards. Coupling them means an API outage stops you noticing a mass deletion, which inverts the priority — the signal that matters most is the one that needs no API at all.
Specification reference Jump to heading
GET /api/0.6/changesets?changesets=id1,id2,…returns up to 100 changesets per call as an<osm>document of<changeset>elements. Attributes includeid,uid,user,created_at,closed_at,num_changesand, when present,min_lon/min_lat/max_lon/max_lat. Thecommentandcreated_byvalues are child<tag>elements, not attributes. An open changeset has noclosed_at.
Related Jump to heading
- Changeset Analysis & Vandalism Detection — the topic this enrichment serves.
- Scoring OSM Changesets for Suspicious Edits — the consumer of account age and editor.
- Detecting Bulk Deletions in an OSM Diff Stream — detection that deliberately needs none of this.
- Extracting Changeset Metadata from History Files — what the history file does and does not carry.
- Error Handling in Large OSM Extracts — the retry taxonomy this follows.
Up one level: Changeset Analysis & Vandalism Detection.