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.
Runnable solution Jump to heading
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
- 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.
- Read the whole object.
read_objectreturns the server’s complete current element, versions and all. Everything downstream mutates that, not a hand-built stub. - Merge into the full tag set.
apply_tag_editstarts 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. - 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.
- 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.
- 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.
- 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.
- Close in
finally. An open changeset left behind by an exception is confusing to everybody who sees it.
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.
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
osmChangedocument submitted to the changeset upload endpoint containscreate,modifyanddeleteblocks 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 theversionthe 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.
Related Jump to heading
- The OSM Editing API & Changeset Upload — the parent topic with the changeset lifecycle and review expectations.
- Dry-Running a Bulk Edit Against the Dev API — how to exercise this script safely first.
- OSM Feature Identity & ID Stability — the version semantics the conflict handling depends on.
- Rolling Back a Bad OSM Import — undoing a changeset you should not have uploaded.
- Flagging Deprecated OSM Tags in a Pipeline — a common source of the retagging edits this script performs.
Up one level: The OSM Editing API & Changeset Upload.