Uploading an OSM Changeset from Python Jump to heading

Write a small, documented edit back to OpenStreetMap from Python without destroying tags you never touched and without overwriting an edit somebody made while your script was thinking.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

Three properties of the API drive every design choice below.

Modification is replacement. A modify element in an osmChange document carries the object’s entire state. The server does not merge; it replaces. Sending a way with one tag and no nd children does not “just update the tag” — it strips the way’s tags and its geometry.

Uploads are atomic. The whole document applies or none of it does. One stale version rejects everything, which is exactly what you want: a partial application of a coherent edit is worse than no application.

The version field is a lock. You state the version you believe you are editing. A mismatch is a conflict, and a conflict means somebody else edited the object after you read it.

The parts of an osmChange document and the order the server applies them A single document split into three sections applied in order. The create section carries new objects with negative placeholder identifiers, which the server replaces with real ones and reports back. The modify section carries objects in their complete new state, including every tag and, for ways, every node reference. The delete section is applied last so that a way and the nodes it references can be removed in the same document. A note adds that the whole document applies atomically or not at all. Three sections, applied in this order, all or nothing create negative ids placeholders, not real ids server returns the mapping ways may reference them modify complete state every tag, every node ref replacement, never a patch carries the version you read delete applied last so a way can go with its nodes needs the current version too if-unused avoids some failures The middle section is where data gets destroyed: an incomplete modify silently removes everything it did not mention.
Ordering is why a way and its nodes can be deleted together, and atomicity is why a single stale version rejects the lot.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
import os
import xml.etree.ElementTree as ET
from dataclasses import dataclass

import requests

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.api.upload")

# Point at the DEVELOPMENT API until an edit has been reviewed end to end.
API = os.environ.get("OSM_API", "https://master.apis.dev.openstreetmap.org/api/0.6")
TOKEN = os.environ["OSM_OAUTH_TOKEN"]
HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "User-Agent": "osm-pipeline-example/1.0 (contact@example.org)",
}


class VersionConflict(RuntimeError):
    """Somebody edited an object after we read it. Re-read; never bump."""


@dataclass(frozen=True)
class TagEdit:
    osm_type: str            # "node" | "way" | "relation"
    osm_id: int
    set_tags: dict[str, str]     # keys to add or overwrite
    remove_tags: tuple[str, ...] = ()


def read_object(osm_type: str, osm_id: int) -> ET.Element:
    """Fetch an object's CURRENT full state. This is the conflict defence."""
    response = requests.get(f"{API}/{osm_type}/{osm_id}", headers=HEADERS, timeout=30)
    response.raise_for_status()
    root = ET.fromstring(response.content)
    element = root.find(osm_type)
    if element is None:
        raise RuntimeError(f"{osm_type}/{osm_id} not found")
    return element


def apply_tag_edit(element: ET.Element, edit: TagEdit) -> bool:
    """Mutate a copy of the CURRENT element. Returns False if nothing changed."""
    current = {t.get("k"): t.get("v") for t in element.findall("tag")}
    updated = dict(current)
    updated.update(edit.set_tags)
    for key in edit.remove_tags:
        updated.pop(key, None)
    if updated == current:
        return False

    # Rebuild the tag children from the FULL updated set — a modify replaces,
    # so every surviving tag must be present in the document we upload.
    for tag in element.findall("tag"):
        element.remove(tag)
    for key, value in sorted(updated.items()):
        ET.SubElement(element, "tag", {"k": key, "v": value})
    return True


def open_changeset(comment: str, source: str) -> int:
    root = ET.Element("osm")
    changeset = ET.SubElement(root, "changeset")
    for key, value in {
        "comment": comment,
        "source": source,
        "created_by": "osm-pipeline-example 1.0",
    }.items():
        ET.SubElement(changeset, "tag", {"k": key, "v": value})
    response = requests.put(f"{API}/changeset/create", headers=HEADERS,
                            data=ET.tostring(root), timeout=30)
    response.raise_for_status()
    changeset_id = int(response.text.strip())
    logger.info("opened changeset %d", changeset_id)
    return changeset_id


def build_osm_change(elements: list[ET.Element], changeset_id: int) -> bytes:
    root = ET.Element("osmChange", {"version": "0.6",
                                    "generator": "osm-pipeline-example"})
    modify = ET.SubElement(root, "modify")
    for element in elements:
        element.set("changeset", str(changeset_id))
        modify.append(element)
    return ET.tostring(root)


def upload(changeset_id: int, document: bytes) -> str:
    response = requests.post(f"{API}/changeset/{changeset_id}/upload",
                             headers=HEADERS, data=document, timeout=120)
    if response.status_code == 409:
        raise VersionConflict(response.text.strip())
    response.raise_for_status()
    return response.text


def close_changeset(changeset_id: int) -> None:
    requests.put(f"{API}/changeset/{changeset_id}/close",
                 headers=HEADERS, timeout=30)
    logger.info("closed changeset %d", changeset_id)


def run(edits: list[TagEdit], comment: str, source: str) -> None:
    # Read as late as possible: the read-to-upload window is the conflict window.
    staged: list[ET.Element] = []
    for edit in edits:
        element = read_object(edit.osm_type, edit.osm_id)
        if apply_tag_edit(element, edit):
            staged.append(element)
        else:
            logger.info("%s/%d already correct, skipping", edit.osm_type, edit.osm_id)

    if not staged:
        logger.info("nothing to do")
        return

    changeset_id = open_changeset(comment, source)
    try:
        upload(changeset_id, build_osm_change(staged, changeset_id))
        logger.info("uploaded %d object(s) in changeset %d", len(staged), changeset_id)
    except VersionConflict as exc:
        # Do NOT increment the version and retry: that overwrites the other edit.
        logger.error("conflict — re-read the named object and decide again: %s", exc)
        raise
    finally:
        close_changeset(changeset_id)


if __name__ == "__main__":
    run(
        edits=[TagEdit("node", 1234567, {"amenity": "pharmacy"}, ("shop",))],
        comment="Retag shop=chemist to amenity=pharmacy in <area>; see wiki page",
        source="Local survey, 2026-09",
    )

Step-by-step walkthrough Jump to heading

  1. Default to the development API. The endpoint comes from an environment variable whose default is the development instance. Reaching the live map should require a deliberate act.
  2. Read the whole object. read_object returns the server’s complete current element, versions and all. Everything downstream mutates that, not a hand-built stub.
  3. Merge into the full tag set. apply_tag_edit starts from the current tags, applies additions and removals, and rebuilds the element’s children from the complete result. This is what stops a modify from deleting untouched tags.
  4. Detect no-ops. If the merged tag set equals the current one, the object is skipped. Uploading unchanged objects inflates the changeset, muddies review, and bumps versions for nothing.
  5. Open the changeset after staging. The changeset is created only once there is something to upload, which keeps stray empty changesets out of the map.
  6. Describe the changeset properly. A comment naming the change and pointing at documentation, a checkable source, and the tool version. A reviewer with these three can evaluate the edit without contacting you; without them, reverting is their only safe option.
  7. Never bump on conflict. The conflict handler logs the object the server named and re-raises. Recovery means re-reading that object and deciding again whether the edit still applies — a decision, not a retry.
  8. Close in finally. An open changeset left behind by an exception is confusing to everybody who sees it.
What to do when the upload returns a version conflict A decision node about a conflicting object, with three outcomes. If the other edit already made the change you intended, drop your edit for that object because it is now a no-op. If the other edit is unrelated and your change still applies to the new state, recompute it against that state and re-upload. If the two edits genuinely disagree about the same tag or geometry, skip the object and route it to a human, because an automated resolution of a real disagreement is exactly what gets bulk edits reverted. A conflict is a decision, never a retry What did the other edit do? Re-read the object first Then pick one of three Drop your edit Their change already achieves what you intended Recompute and retry Unrelated change; yours still applies to the new state Skip, send to a human Genuine disagreement about the same tag or geometry Bumping the version number is a fourth option that looks like the second one and silently destroys the other mapper's work.
All three branches start by re-reading, which is the step that distinguishes a decision from a retry loop.

Verification Jump to heading

  • Untouched tags survive. Read the object back after upload and diff its tag set against the pre-edit state; only the intended keys should differ.
  • The changeset is described. Fetch the changeset metadata and confirm the comment, source and created_by tags are present and meaningful.
  • No-ops were skipped. Run the script twice; the second run should report every object as already correct and open no changeset.
  • The conflict path works. On the development API, edit an object in the web interface between the read and the upload, and confirm the script raises rather than overwriting.
  • The changeset is closed. After both a success and a forced failure, the changeset must show as closed.
Three checks that prove an upload did what was intended Three panels covering post-upload verification. The tag diff check reads the object back and compares its tag set against the pre-edit state, proving only the intended keys changed. The idempotence check runs the script a second time and expects every object to be reported as already correct with no changeset opened. The metadata check fetches the changeset itself and confirms the comment, source and creating tool are present and meaningful to a stranger. Three checks, all of them after the upload Tag diff Read the object back Diff against the pre-edit state Only intended keys differ Catches a partial modify Idempotence Run the script a second time Every object already correct No changeset opened at all Catches a no-op detection bug Changeset metadata Fetch the changeset itself Comment, source, created_by Evaluable by a stranger Catches a silent template gap The second check is the cheapest and finds the most: a script that is not idempotent will bump versions on every scheduled run.
None of these needs the live map — all three work on the development API, which is where they should run first.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Tags vanished after upload Partial tag set in the modify element Rebuild the element from the complete merged tag set
Way geometry destroyed Node references omitted from the modify Upload the element the server returned, mutated in place
HTTP 409 on upload Object version moved since the read Re-read and decide; never increment the version
HTTP 401 Token missing, expired or wrong scope Re-issue an OAuth 2 token with write scope
Empty changesets in the map Changeset opened before staging Open only once there is something to upload
Changeset left open after a crash Close call not in a finally block Close in finally, unconditionally
Edit landed on the live map by accident Endpoint defaulted to production Default the endpoint to the development API

Specification reference Jump to heading

An osmChange document submitted to the changeset upload endpoint contains create, modify and delete blocks applied in that order, and the upload is atomic: if any element fails a precondition the entire document is rejected. Each modified or deleted element must carry the version the client believes is current; a mismatch produces an HTTP 409 conflict. See the OSM API v0.6 changeset documentation for the element-level requirements and the diff result format returned on success.

Frequently Asked Questions Jump to heading

Why does my modify delete tags I did not include?

Because modify is a replacement operation, not a patch. The server takes the element you send as the object’s complete new state, so any tag absent from your document is absent from the object afterwards. The safe pattern is never to construct an element yourself: fetch the server’s current element, mutate that object in memory, and upload the mutated original so every field you did not touch travels along unchanged.

Can I just increment the version number when I get a conflict?

You can, and it is the single most damaging thing you can do through this API. The conflict exists because another mapper edited the object after you read it; incrementing the version tells the server you have seen their change when you have not, and their edit is overwritten without trace. Re-read the object instead and decide whether your edit still applies, is now redundant, or genuinely disagrees and needs a human.

Should I upload one changeset per object?

No — that inflates the changeset history and makes the edit harder, not easier, to review. Group objects that belong to the same fix in the same bounded area into one changeset of a few hundred at most. The grouping rule is what a revert should undo: everything in a changeset stands or falls together, so everything in it should have been the same decision.

How do I test this without touching the real map?

Use the development API instance, which is a separate deployment with its own accounts, its own data and its own object identifiers. Point the endpoint at it through configuration rather than by editing code, so the live endpoint is never one uncommitted change away. Exercise the whole pipeline there, including the conflict path, before any run against the production API.

Up one level: The OSM Editing API & Changeset Upload.