Converting a Shapefile to OSM XML with ogr2osm Jump to heading
Turn an external vector file into an OSM XML document whose tags a local mapper would recognise — with the awkward decisions made in a translation file you can show somebody, rather than buried in a script.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
ogr2osm reads any format GDAL can open and emits OSM XML with negative identifiers, which is the convention for objects that do not yet exist on the server. The upload assigns real identifiers and reports the mapping back, exactly as described in Uploading an OSM Changeset from Python.
The interesting part is the translation file: a Python module defining a class whose methods are called for each feature. filter_tags receives the source attributes and returns the OSM tags; filter_feature can reject a feature entirely; merge_tags decides what happens when two geometries are merged.
Two conversion behaviours matter for correctness. The tool merges coincident nodes by default, which is usually what you want — adjacent polygons sharing a boundary should share nodes rather than stacking duplicates — but it means the output’s topology is not a straightforward copy of the source’s. And it reprojects to WGS 84, which is required, but depends on the source declaring its projection correctly.
Runnable solution Jump to heading
# translation.py — the file that carries every judgement in the conversion.
from __future__ import annotations
import logging
import re
import ogr2osm
logger = logging.getLogger("osm.import.translate")
# Source values -> OSM tagging, agreed with the local community and published
# in the import plan. Anything absent here is DROPPED, deliberately.
BUILDING_TYPES: dict[str, dict[str, str]] = {
"RESIDENTIAL": {"building": "residential"},
"COMMERCIAL": {"building": "commercial"},
"INDUSTRIAL": {"building": "industrial"},
"SCHOOL": {"building": "school", "amenity": "school"},
"CHURCH": {"building": "church"},
# "OTHER" and "UNKNOWN" are intentionally absent: building=yes is better
# than inventing a value, and is applied as the fallback below.
}
_HOUSENUMBER = re.compile(r"^\s*(\d+[A-Za-z]?(?:\s*[-/]\s*\d+[A-Za-z]?)?)\s*$")
class BuildingTranslation(ogr2osm.TranslationBase):
def filter_feature(self, ogrfeature, layer_fields, reproject):
"""Reject features that should not be imported at all."""
if ogrfeature is None:
return None
status = (ogrfeature.GetField("STATUS") or "").upper()
if status in {"DEMOLISHED", "PROPOSED", "PLANNED"}:
# Not on the ground: importing it would be mapping the future.
return None
geometry = ogrfeature.GetGeometryRef()
if geometry is None or geometry.IsEmpty():
return None
return ogrfeature
def filter_tags(self, attrs):
if not attrs:
return {}
tags: dict[str, str] = {}
kind = (attrs.get("BLD_TYPE") or "").strip().upper()
tags.update(BUILDING_TYPES.get(kind, {"building": "yes"}))
if kind and kind not in BUILDING_TYPES:
logger.debug("unmapped building type %r -> building=yes", kind)
# Addresses: normalise, and drop anything that does not parse cleanly
# rather than importing a malformed housenumber onto the map.
number = (attrs.get("HOUSENUM") or "").strip()
match = _HOUSENUMBER.match(number)
if match:
tags["addr:housenumber"] = match.group(1).replace(" ", "")
elif number:
logger.debug("dropping unparseable housenumber %r", number)
street = (attrs.get("STREET") or "").strip()
if street:
# Title-casing a street name is a local decision; here the source
# is already correctly cased and is passed through unchanged.
tags["addr:street"] = street
levels = (attrs.get("FLOORS") or "").strip()
if levels.isdigit() and 1 <= int(levels) <= 200:
tags["building:levels"] = levels
# The source identifier is kept ONLY because it is a published public
# reference; an internal key would be dropped here instead.
ref = (attrs.get("PUB_REF") or "").strip()
if ref:
tags["ref:cadastre"] = ref
# Provenance on every object: what it came from and when.
tags["source"] = "City Cadastre 2026-06"
return tags
def merge_tags(self, geometry_type, tags_existing, tags_new):
"""Called when two geometries merge. Refuse silent conflicts."""
merged = dict(tags_existing)
for key, value in tags_new.items():
if key in merged and merged[key] != value:
logger.warning("conflicting %s: %r vs %r — keeping existing",
key, merged[key], value)
continue
merged[key] = value
return merged
#!/usr/bin/env bash
set -euo pipefail
ogr2osm buildings.shp \
--translation translation.py \
--output buildings.osm \
--add-version --add-timestamp \
--positive-id=false \
--rounding-digits 7
# Sanity: every object must be negative-id and carry a source tag.
grep -c 'id="-' buildings.osm
grep -c 'k="source"' buildings.osm
Step-by-step walkthrough Jump to heading
- Reject before tagging.
filter_featureremoves demolished and proposed structures, so the tag filter only ever sees features that are genuinely being imported. - Refuse to map the future. A cadastre’s “proposed” buildings are not on the ground, and importing them puts things on the map that do not exist.
- Use an explicit mapping table with a safe fallback. Unmapped types become a generic value rather than an invented one, and the unmapped case is logged so the table can be extended from evidence.
- Drop what does not parse. A housenumber that does not match the expected pattern is dropped rather than imported malformed — a missing tag is far easier for a mapper to fix than a wrong one.
- Bound numeric values. A floor count outside a plausible range is almost always a sentinel value in the source, and importing it produces obviously wrong data.
- Keep only public references. The source identifier is retained because it is a published reference, in the established namespace. An internal key would be dropped here.
- Tag provenance on every object. A source tag naming the dataset and its vintage is what lets a mapper five years later understand where a feature came from.
- Refuse silent conflicts on merge. When two merging geometries disagree about a tag, the warning names it rather than letting one value quietly win.
- Round coordinates sensibly. Seven decimal places is roughly centimetre precision; more is false precision that inflates the file for no benefit.
Verification Jump to heading
- Every object has a negative identifier. A positive identifier means the output is claiming to modify existing objects rather than create new ones.
- Every object carries provenance. A source tag naming the dataset and vintage should be universal.
- Rejected features really are absent. Count features in the source with a rejecting status and confirm the output is smaller by exactly that number.
- Coordinates are in WGS 84. Spot-check a coordinate against a known location; a source with a mis-declared projection lands somewhere plausible but wrong.
- No unexpected keys. Extract the distinct keys from the output and compare against the published mapping; anything else is a leak.
- Merge conflicts are rare. A high conflict count means the source has inconsistent attributes on adjacent geometries, which is worth understanding before uploading.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Objects have positive identifiers | Positive identifiers enabled | Emit negative identifiers for new objects |
| Features land in the wrong place | Source projection mis-declared | Verify the source CRS before converting |
| Invented tag values on the map | No fallback for unmapped categories | Fall back to a generic value and log the gap |
| Malformed housenumbers imported | Values passed through unvalidated | Validate and drop what does not parse |
| Duplicate nodes along boundaries | Node merging disabled | Leave coincident-node merging enabled |
| Internal identifiers on the map | Source key mapped without justification | Keep only genuinely public references |
| File enormous for the feature count | Excessive coordinate precision | Round to about seven decimal places |
Specification reference Jump to heading
ogr2osmconverts any OGR-readable data source into OSM XML, applying a translation supplied as a Python module that subclasses the translation base class.filter_featuremay reject a feature,filter_tagsmaps source attributes to OSM tags, andmerge_tagsresolves tagging when geometries are merged. Output objects are given negative identifiers to indicate that they do not yet exist on the server. See the ogr2osm documentation for the translation interface and the command-line options.
Frequently Asked Questions Jump to heading
Why negative identifiers?
Because they mark objects as new. In an upload document a negative identifier is a placeholder: the server assigns a real identifier on creation and returns the mapping. A positive identifier would instead claim to refer to an existing object, which either fails as a version conflict or, worse, succeeds against an unrelated object. Emitting negative identifiers is the convention and the default, and confirming it in the output is a one-line check worth doing.
Should an unmapped source category become a new tag value?
Almost never. Inventing a value produces data that is technically present and consumed by nothing, and once it is on the map it is hard to remove. A generic fallback — the broad value everyone already renders — is more useful to consumers and honest about what is known. Log the unmapped categories, review them against real usage, and extend the mapping deliberately where a recognised value exists.
Is it better to drop a malformed value or import it as-is?
Drop it. A missing tag is a visible gap that any mapper can fill from local knowledge; a wrong tag looks like data and has to be discovered before it can be fixed. This is especially true for addresses, where a malformed housenumber will be consumed by geocoders and routing engines as though it were correct. Log what you dropped so the source’s data-quality problems are visible.
What does node merging actually do?
It makes geometries that share a coordinate share a node rather than each carrying its own. For adjacent polygons — building blocks, land parcels — that is correct and important: without it every shared boundary is duplicated, doubling the node count and leaving the map topologically wrong. It does mean the output’s topology depends on the whole file rather than on any single feature, which is worth remembering when comparing counts against the source.
Related Jump to heading
- Preparing an OSM Import — the parent topic and the permission this conversion assumes.
- Deduplicating Addresses Before an OSM Import — the step that decides which of these objects are uploaded.
- Uploading an OSM Changeset from Python — what consumes the negative identifiers.
- Mapping OSM Tags to a Fixed Schema with YAML — the same mapping discipline in the other direction.
- Fixing Malformed OSM Tags During ETL Ingestion — validating values before they reach a sink.
Up one level: Preparing an OSM Import.