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.

What to do with each object the import touched A decision node examining an object's current version against the version the import produced, with three outcomes. An object still at the version the import left it can be reverted mechanically, which covers the great majority. An object whose version has moved on has been edited by somebody since, so reverting it would discard their work and it must be skipped and listed. An object now referenced by something created later cannot be deleted at all and needs a human to decide whether the reference or the object should go. Check every object before touching it Has it changed since? Compare current version Against what you uploaded Unchanged: revert it Still at the version your import left; mechanical Edited since: skip and list Somebody's work would be discarded by a blind revert Now referenced: escalate Cannot delete; a human decides what should go The middle branch is the whole reason a revert cannot be a single API call: only a per-object check can find it.
Skipping is not failure — it is the revert correctly declining to overwrite somebody who looked at the data.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. Treat an already-deleted object as skipped. Somebody has removed it, which is a decision that should stand.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
The order of a revert, including the parts that are not code Four stages. The announce stage posts what went wrong and that a revert is starting, before any edit, so mappers seeing changes understand them. The plan stage downloads each changeset, checks every object's current version and produces a revert plan with an explicit skip list. The revert stage uploads one reversing changeset per original, with deletions last and the if-unused flag set. The follow-up stage works through the skipped objects with a human and posts a summary of what was and was not undone. Announce, plan, revert, follow up announce before any edit say what went wrong plan check every object produce a skip list revert one per changeset deletions last follow up skipped objects post a summary Announcing first is what stops a second wave of confused edits from mappers watching changes appear with no explanation.
Two of these four stages are writing rather than code, and they are the two that decide how the revert is received.
What reverting means for each of the three original actions A grid of the three actions an import can perform against what the revert does and what can prevent it. A creation is reverted by deleting the object, which is prevented when something created later references it. A modification is reverted by restoring the version immediately before the import, which is prevented when the object has no prior version. A deletion is reverted by recreating the object from its last version before removal, which is prevented when another object has since taken its place. Three actions, three reversals, three obstacles Revert does Prevented by Created delete the object a later reference Modified restore the prior version no prior version Deleted recreate from last version a replacement exists Any of them only if unchanged since any later edit The bottom row applies to all three and is checked first, because no reversal is safe on an object somebody else has touched.
Each obstacle produces a skipped object rather than a failed revert, which is why the skip list is the real output.

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 osmChange document 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. A delete block may carry an if-unused attribute, 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.

Up one level: Conflation QA & Rollback.