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.

What happens to one source feature during conversion Four steps. The read step opens the source with GDAL and yields a feature with its attributes and geometry in the source projection. The filter step calls the translation's feature filter, which may reject the feature entirely on the basis of its attributes. The tag step calls the tag filter, which maps source fields onto OSM tags, normalises values and drops fields with no equivalent. The emit step reprojects the geometry to WGS 84, merges coincident nodes with those of neighbouring features, and writes the object with a negative identifier. Read, reject, retag, emit read GDAL, source CRS attributes plus geometry filter reject whole features before any tagging retag fields to OSM tags normalise and drop emit reproject, merge nodes negative identifiers Node merging happens across features, so the output's topology is a property of the whole file rather than of any single feature.
Rejecting a feature before tagging keeps the tag filter simple: it only ever sees features that are definitely being imported.

Runnable solution Jump to heading

python
# 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
bash
#!/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

  1. Reject before tagging. filter_feature removes demolished and proposed structures, so the tag filter only ever sees features that are genuinely being imported.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Refuse silent conflicts on merge. When two merging geometries disagree about a tag, the warning names it rather than letting one value quietly win.
  9. Round coordinates sensibly. Seven decimal places is roughly centimetre precision; more is false precision that inflates the file for no benefit.
Three decisions in a translation file that are policy rather than code Three panels. The unmapped value decision determines what happens to a source category with no agreed OSM equivalent: a safe generic fallback is almost always better than inventing a value nobody consumes. The malformed value decision determines whether an unparseable attribute is dropped or imported as-is: dropping leaves a gap a mapper can fill, while importing creates a wrong value somebody must find first. The identifier decision determines whether the source key reaches the map at all, and only a genuinely public reference should. Three judgements that belong in the plan, not the code Unmapped values Source category, no OSM equivalent Invent a value, or fall back? Fallback is almost always right Log it, extend from evidence Never invent a key Malformed values Attribute does not parse Import as-is, or drop? Dropping leaves a fixable gap Importing leaves a wrong value A gap is easier to find Identifiers Source key, public or internal? Public reference: keep it Internal key: drop it Once imported, hard to remove Track the mapping your side All three answers should appear in the published import plan, because a reviewer will ask about each of them.
Writing these into the translation file rather than a script is what makes them reviewable by somebody who does not read Python.
Which part of the conversion each kind of decision belongs in Four layers. The command line holds operational settings such as identifier sign, coordinate rounding and whether version metadata is added. The feature filter holds decisions about whether a record should exist on the map at all. The tag filter holds the mapping from source fields to OSM tags, value validation and provenance. The merge handler holds what happens when two geometries share a boundary and disagree about a tag. A note observes that putting a policy decision in the command line hides it from review. Four places a decision can live, each with a purpose Command line Identifiers, rounding, metadata operational settings Feature filter Should this exist on the map? rejects whole records Tag filter Fields to tags, validation where policy lives Merge handler Conflicts on shared geometry warn, never guess Policy belongs in the middle two layers, where it is visible in a file a reviewer can read alongside the import plan.
A decision hidden in a command-line flag is one nobody will find when the import is questioned a year later.

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

ogr2osm converts any OGR-readable data source into OSM XML, applying a translation supplied as a Python module that subclasses the translation base class. filter_feature may reject a feature, filter_tags maps source attributes to OSM tags, and merge_tags resolves 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.

Up one level: Preparing an OSM Import.