Rolling Back a Bad OSM Import Jump to heading
A revert is an admission, an engineering task and a communication in that order. Doing the second without the first and third is how a bad import becomes two bad edits.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A revert is not a single operation the API provides; it is a new changeset whose contents undo an earlier one. What “undo” means depends on what the original did.
An object you created is deleted — provided nothing else now references it and nobody has edited it since.
An object you modified is restored to the version immediately before your edit, which means fetching that version and uploading it as the current state.
An object you deleted is recreated, which the API supports by uploading the previous version with its original identifier.
The complication that dominates the work is intervening edits. If a mapper has touched an object since your import, reverting it blindly discards their work — and that is a second unwanted edit on top of the first. Every object must be checked, and the ones that moved on must be skipped and listed for human attention rather than forced.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import os
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.revert")
API = os.environ.get("OSM_API", "https://master.apis.dev.openstreetmap.org/api/0.6")
HEADERS = {
"Authorization": f"Bearer {os.environ['OSM_OAUTH_TOKEN']}",
"User-Agent": "osm-pipeline-example/1.0 (contact@example.org)",
}
@dataclass
class Plan:
delete: list[ET.Element] = field(default_factory=list)
restore: list[ET.Element] = field(default_factory=list)
skipped: list[tuple[str, int, str]] = field(default_factory=list)
def changeset_download(changeset_id: int) -> ET.Element:
"""The osmChange document describing what a changeset actually did."""
response = requests.get(f"{API}/changeset/{changeset_id}/download",
headers=HEADERS, timeout=120)
response.raise_for_status()
return ET.fromstring(response.content)
def current(osm_type: str, osm_id: int) -> ET.Element | None:
response = requests.get(f"{API}/{osm_type}/{osm_id}", headers=HEADERS,
timeout=30)
if response.status_code == 410:
return None # already deleted by somebody else
response.raise_for_status()
return ET.fromstring(response.content).find(osm_type)
def version_at(osm_type: str, osm_id: int, version: int) -> ET.Element:
response = requests.get(f"{API}/{osm_type}/{osm_id}/{version}",
headers=HEADERS, timeout=30)
response.raise_for_status()
return ET.fromstring(response.content).find(osm_type)
def plan_revert(changeset_id: int) -> Plan:
"""Decide, per object, whether it can be reverted without discarding work."""
document = changeset_download(changeset_id)
plan = Plan()
for block in document: # create / modify / delete
action = block.tag
for element in block:
osm_type, osm_id = element.tag, int(element.get("id"))
our_version = int(element.get("version"))
now = current(osm_type, osm_id)
if now is None:
plan.skipped.append((osm_type, osm_id, "already deleted"))
continue
if int(now.get("version")) != our_version:
# Somebody edited it after us. Reverting would discard that.
plan.skipped.append((osm_type, osm_id, "edited since the import"))
continue
if action == "create":
plan.delete.append(now)
elif action == "modify":
if our_version < 2:
plan.skipped.append((osm_type, osm_id, "no prior version"))
continue
plan.restore.append(version_at(osm_type, osm_id, our_version - 1))
elif action == "delete":
plan.restore.append(version_at(osm_type, osm_id, our_version - 1))
logger.info("changeset %d: delete %d, restore %d, skip %d",
changeset_id, len(plan.delete), len(plan.restore),
len(plan.skipped))
for osm_type, osm_id, reason in plan.skipped:
logger.warning("skipping %s/%d: %s", osm_type, osm_id, reason)
return plan
def build_revert(plan: Plan, changeset_id: int) -> bytes:
root = ET.Element("osmChange", {"version": "0.6",
"generator": "osm-revert-example"})
if plan.restore:
modify = ET.SubElement(root, "modify")
for element in plan.restore:
element.set("changeset", str(changeset_id))
modify.append(element)
if plan.delete:
# Deletions last, so a way can go in the same changeset as its nodes.
delete = ET.SubElement(root, "delete", {"if-unused": "true"})
for element in plan.delete:
element.set("changeset", str(changeset_id))
delete.append(element)
return ET.tostring(root)
if __name__ == "__main__":
plan = plan_revert(int(os.environ.get("REVERT_CHANGESET", "0")))
logger.info("review the skip list before uploading anything")
Step-by-step walkthrough Jump to heading
- Work from the changeset download, not from your own records. The server’s account of what a changeset did is authoritative; your pipeline’s log may be incomplete or wrong, which is plausible given that the import needs reverting.
- Check the current version of every object. This is the step that distinguishes a revert from vandalism. An object whose version has moved on has been looked at by somebody.
- Treat an already-deleted object as skipped. Somebody has removed it, which is a decision that should stand.
- Restore the version before yours. For a modification or a deletion, the previous version is the state to return to — fetched from the server rather than reconstructed.
- Guard the first-version case. An object whose first version was your modification has no prior state, which usually means the changeset download is being misread.
- Put deletions last and use if-unused. Ordering lets a way and its nodes go together, and the if-unused flag prevents deleting a node that something created later now references.
- Review the skip list before uploading. Skipped objects are the ones needing human attention, and reading that list is the point at which somebody notices the revert is more complicated than assumed.
- Revert changeset by changeset. One revert changeset per original, referencing it in the comment, so the revert is as reviewable as the import should have been.
Verification Jump to heading
- Skipped objects are genuinely edited. Spot-check a few and confirm somebody really did change them after the import.
- Created objects are gone. Query for a sample of identifiers the import created; they should be deleted or in the skip list.
- Modified objects match their prior version. Compare the current tags against the version before the import.
- Nothing unrelated changed. The revert changesets should touch only objects the import touched.
- The rehearsal passed. The whole procedure should have run against the development instance first, including a deliberately edited object to exercise the skip path.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Mappers’ later edits destroyed | Version not checked before reverting | Compare the current version against yours; skip on any difference |
| Deletion fails on a referenced node | Something created later references it | Set the if-unused flag and escalate the remainder |
| Revert itself gets reverted | No announcement before starting | Post what went wrong before the first edit |
| Objects missed | Worked from pipeline logs, not the server | Use the changeset download as the source of truth |
| Revert is unreviewable | One enormous reverting changeset | One revert changeset per original changeset |
| Way deleted before its nodes | Deletions ordered wrongly | Put the delete block last in the document |
| Skip list never examined | Uploaded straight after planning | Make reviewing the skip list a required step |
Specification reference Jump to heading
The changeset download endpoint returns an
osmChangedocument describing the creations, modifications and deletions a changeset performed. Individual object versions are retrievable by version number, allowing a prior state to be fetched and re-uploaded. Adeleteblock may carry anif-unusedattribute, which causes the server to skip rather than fail when an object is still referenced. See the OSM API v0.6 documentation for the changeset download format and the deletion semantics.
Frequently Asked Questions Jump to heading
Why not just revert everything the import touched?
Because some of those objects have been edited since, and reverting them discards the work of whoever did it. That turns one unwanted edit into two, and the second is worse because it destroys a human’s deliberate change rather than merely adding unwanted data. Checking each object’s current version and skipping the ones that moved on is the difference between a revert and a second bad edit.
Should I announce the revert before or after doing it?
Before, always. Mappers watching an area will see a wave of changes appear and, with no explanation, will reasonably assume something is wrong and may start reverting your revert. A short note saying what went wrong and that a correction is starting costs minutes and prevents that entirely. It also establishes that the problem was found and acted on by you, which matters for whether the next import is welcomed.
What about objects that cannot be deleted because something references them?
Set the flag that tells the server to skip rather than fail, then handle the remainder by hand. A node your import created that is now part of a way somebody drew is not yours to remove: the way depends on it, and deleting it would break their work. Those cases are few, and a human deciding whether the reference or the object should go is the only sensible resolution.
How should the revert changesets be structured?
One reverting changeset per original changeset, with a comment naming the original and explaining the reason. That keeps the revert as reviewable as the import should have been, makes the correspondence obvious to anybody looking at the history, and means a problem with the revert itself can be addressed in the same granular way. A single enormous revert repeats the mistake that made the import hard to undo.
Related Jump to heading
- Conflation QA & Rollback — the parent topic and the structure that makes this tractable.
- Preparing an OSM Import — the preparation that avoids needing this.
- Uploading an OSM Changeset from Python — the upload machinery the revert reuses.
- Reconstructing OSM Features at a Past Date — recovering prior state from a history file when the API cannot.
- Detecting Bulk Deletions in an OSM Diff Stream — how a revert looks from the monitoring side.
Up one level: Conflation QA & Rollback.