Building Stable Surrogate Keys for OSM Features Jump to heading

Downstream systems want a key that means the same thing next year. OSM offers a key that means the same database row next year, which is not the same promise. A surrogate key is how you bridge the gap.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

A surrogate key is an identifier your pipeline mints, owns and never changes. It maps to one or more OSM objects through a table you maintain, and that indirection is what absorbs the map’s churn.

Three properties make it work.

The key is opaque and permanent. It carries no meaning, is never reused, and is never recomputed from content. A key derived from a hash of tags changes when the tags change, which defeats the entire purpose.

The mapping is versioned, not overwritten. When a feature splits, the old mapping row is closed and new ones opened, with the surrogate key unchanged. The history is then readable: this key meant one way until March and three ways since.

Lineage is recorded, not inferred. When two surrogate keys merge or one splits, the relationship between old and new keys is stored explicitly. Downstream systems that need to reconcile last quarter’s numbers with this quarter’s can then do so.

The three tables a surrogate key scheme needs Three stacked tables. The feature table holds one row per surrogate key with the attributes downstream systems consume, and never changes its key. The mapping table holds one row per surrogate key and OSM object pairing, with validity dates, so a split adds rows rather than changing the key. The lineage table records relationships between surrogate keys themselves, such as one key superseding two others, which is what lets historical figures be reconciled against current ones. Three tables, and only the first is what consumers see Features One row per surrogate key the key never changes Mapping Key to OSM objects, with dates a split adds rows Lineage Key to key relationships reconciles across time Consumers join to the first table only; the other two exist so that table can keep its promise when the map moves underneath it.
Skipping the lineage table works until somebody asks why a total changed between two reports.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import date

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

SCHEMA = """
CREATE TABLE IF NOT EXISTS feature (
    surrogate_key TEXT PRIMARY KEY,
    kind          TEXT NOT NULL,
    created_on    TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mapping (
    surrogate_key TEXT NOT NULL,
    osm_type      TEXT NOT NULL,
    osm_id        INTEGER NOT NULL,
    osm_version   INTEGER NOT NULL,
    valid_from    TEXT NOT NULL,
    valid_to      TEXT,                    -- NULL means currently valid
    PRIMARY KEY (surrogate_key, osm_type, osm_id, valid_from)
);
CREATE TABLE IF NOT EXISTS lineage (
    parent_key TEXT NOT NULL,
    child_key  TEXT NOT NULL,
    relation   TEXT NOT NULL,              -- 'split' | 'merge'
    occurred_on TEXT NOT NULL,
    PRIMARY KEY (parent_key, child_key, occurred_on)
);
"""


@dataclass(frozen=True)
class Mapping:
    surrogate_key: str
    osm_type: str
    osm_id: int
    osm_version: int


def connect(path: str) -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.executescript(SCHEMA)
    return conn


def mint(conn: sqlite3.Connection, kind: str) -> str:
    """A new opaque key. Never derived from content, never reused."""
    key = f"{kind}-{uuid.uuid4()}"
    conn.execute("INSERT INTO feature (surrogate_key, kind, created_on) "
                 "VALUES (?, ?, ?)", (key, kind, date.today().isoformat()))
    return key


def bind(conn: sqlite3.Connection, m: Mapping, on: date | None = None) -> None:
    conn.execute(
        "INSERT OR IGNORE INTO mapping (surrogate_key, osm_type, osm_id,"
        " osm_version, valid_from) VALUES (?, ?, ?, ?, ?)",
        (m.surrogate_key, m.osm_type, m.osm_id, m.osm_version,
         (on or date.today()).isoformat()))


def close_mapping(conn: sqlite3.Connection, key: str, osm_type: str,
                  osm_id: int, on: date | None = None) -> None:
    conn.execute(
        "UPDATE mapping SET valid_to = ? WHERE surrogate_key = ? "
        "AND osm_type = ? AND osm_id = ? AND valid_to IS NULL",
        ((on or date.today()).isoformat(), key, osm_type, osm_id))


def current_objects(conn: sqlite3.Connection, key: str) -> list[tuple[str, int]]:
    return [(r[0], r[1]) for r in conn.execute(
        "SELECT osm_type, osm_id FROM mapping WHERE surrogate_key = ? "
        "AND valid_to IS NULL ORDER BY osm_type, osm_id", (key,))]


def apply_split(conn: sqlite3.Connection, key: str,
                new_objects: list[Mapping], on: date | None = None) -> None:
    """A way split: the SURROGATE key is unchanged, the mapping gains rows.

    This is the whole point of the indirection. Downstream consumers keyed on
    the surrogate see nothing; the feature simply now spans several OSM ways.
    """
    when = on or date.today()
    existing = set(current_objects(conn, key))
    for m in new_objects:
        if (m.osm_type, m.osm_id) not in existing:
            bind(conn, m, when)
    # Any previously mapped object no longer present has been superseded.
    incoming = {(m.osm_type, m.osm_id) for m in new_objects}
    for osm_type, osm_id in existing - incoming:
        close_mapping(conn, key, osm_type, osm_id, when)
    conn.commit()
    logger.info("split applied to %s: now maps to %d object(s)",
                key, len(current_objects(conn, key)))


def apply_merge(conn: sqlite3.Connection, keys: list[str],
                kind: str, objects: list[Mapping], on: date | None = None) -> str:
    """Two features became one. A NEW key is minted and lineage recorded."""
    when = on or date.today()
    new_key = mint(conn, kind)
    for m in objects:
        bind(conn, Mapping(new_key, m.osm_type, m.osm_id, m.osm_version), when)
    for old in keys:
        for osm_type, osm_id in current_objects(conn, old):
            close_mapping(conn, old, osm_type, osm_id, when)
        conn.execute("INSERT OR IGNORE INTO lineage (parent_key, child_key,"
                     " relation, occurred_on) VALUES (?, ?, 'merge', ?)",
                     (old, new_key, when.isoformat()))
    conn.commit()
    logger.info("merged %d key(s) into %s", len(keys), new_key)
    return new_key


if __name__ == "__main__":
    conn = connect(":memory:")
    key = mint(conn, "route")
    bind(conn, Mapping(key, "way", 4305800, 7))
    apply_split(conn, key, [Mapping(key, "way", 4305800, 8),
                            Mapping(key, "way", 999001, 1)])

Step-by-step walkthrough Jump to heading

  1. Mint opaquely. A random key carries no meaning and cannot be invalidated by a content change. Deriving a key from a hash of tags or geometry recreates the exact problem the surrogate exists to solve.
  2. Never reuse a key. A retired key stays retired, exactly as OSM retires deleted identifiers, so a stale reference in a downstream system fails rather than silently pointing somewhere new.
  3. Version the mapping with validity dates. Closing a row rather than deleting it preserves the ability to answer what a key meant at a past date, which is what makes historical reports reproducible.
  4. Treat a split as a mapping change, not a key change. This is the central move: the surrogate key still names the same real-world feature, which now happens to span several OSM objects.
  5. Treat a merge as a new key with lineage. Two features becoming one is a genuinely new thing, so a new key is honest — and the lineage rows are what let somebody reconcile the old totals.
  6. Record lineage explicitly. Inferring it later from overlapping mappings is possible and unreliable; storing it costs one row per event.
  7. Give consumers only the feature table. A downstream system that joins directly to the mapping table has re-coupled itself to OSM identifiers and gains nothing from the scheme.
What each map edit does to the surrogate key and to the tables beneath it A grid of four map edits against whether the surrogate key changes, what happens in the mapping table, and whether lineage is recorded. A retag leaves the key unchanged, updates the recorded OSM version in the mapping, and needs no lineage. A split leaves the key unchanged, adds mapping rows for the new objects, and needs no lineage because the feature is still one thing. A merge mints a new key, closes the old mappings and records lineage. A deletion leaves the key in place but closes every mapping, marking the feature as no longer present on the map. Four edits, and only one of them changes the key Surrogate key Mapping Lineage Retag unchanged version updated none Split unchanged rows added none Merge new key old rows closed recorded Deletion retained all rows closed none The second row is why the scheme exists: the edit that silently breaks a raw reference is invisible to a consumer of the surrogate.
A key that survives a split and changes on a merge matches how people actually think about the features.
How one surrogate key behaves as the map changes beneath it Four moments for a single route feature. At creation the key is minted and mapped to one OSM way. After a retag the key and the mapping are unchanged except for the recorded version. After a split the key is still unchanged but the mapping now names two ways, so a consumer sees one feature spanning more objects. After a merge with a neighbouring route a new key is minted, the old mappings are closed, and lineage records that the old key was superseded. One key, four moments, one change of key created minted, one way the key is born retagged key unchanged version updated split key unchanged now two ways merged new key lineage recorded Three of the four moments are invisible to a downstream consumer, which is the measure of whether the scheme is working.
A consumer that notices any of the first three moments has been given access to the wrong table.

Verification Jump to heading

  • A split leaves the key intact. Apply one and confirm the surrogate key is unchanged while the mapping gained a row.
  • Historical mappings resolve. Query what a key mapped to at a past date and confirm it returns the pre-split object.
  • Merged keys are reachable. From an old key, follow the lineage to the current one.
  • Keys are never reused. Attempt to mint a key that already exists and confirm the primary key prevents it.
  • Consumers use only the feature table. Grep downstream code for direct references to OSM identifiers; each one is a leak.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Keys change when tags change Key derived from content Mint opaque keys with no relationship to content
Historical reports irreproducible Mapping rows overwritten Close rows with a validity date rather than updating them
Totals unexplainable after a merge No lineage recorded Store parent and child key relationships explicitly
Splits break downstream joins Split treated as a new key Keep the key; add mapping rows for the new objects
Consumers still coupled to OSM Mapping table exposed downstream Expose only the feature table
A retired key resolves again Keys recycled Never reuse a key, exactly as OSM never reuses its own
Mapping table grows unboundedly Every version recorded as a row Update the version in place; add rows only on object change

Specification reference Jump to heading

A surrogate key is an identifier with no business meaning, assigned by the system that owns the record and never changed. Combined with validity-dated mapping rows, it implements a slowly changing dimension over an external identifier space, which is the standard treatment for a source system whose keys are not stable. See OSM Feature Identity & ID Stability for the specific instabilities this pattern absorbs.

Frequently Asked Questions Jump to heading

Why not derive the key from the feature's content?

Because a content-derived key changes when the content does, which is exactly the instability a surrogate is supposed to remove. Hashing the tags gives a key that changes on a retag; hashing the geometry gives one that changes when a node moves. An opaque key minted once and never recomputed is the only form that keeps its promise, and its lack of meaning is a feature rather than a limitation.

Should a split produce a new key?

No. A road split at a junction is still the same road; only its representation in the database changed. Keeping the surrogate key and adding mapping rows means downstream consumers see nothing, which is the entire value of the indirection. A merge is different — two features genuinely becoming one is a new thing — and that is why it mints a new key with lineage recorded.

How do downstream systems handle a merge?

Through the lineage table. A consumer holding an old key finds it no longer current, follows the lineage to the key that superseded it, and can then reconcile its historical figures against the new one. Without the lineage that reconciliation is guesswork, which is how a total that changed between two reports becomes an unanswerable question.

Is this worth the complexity for a small dataset?

If the dataset is small and short-lived, probably not — storing the type, identifier and version, and re-resolving, is adequate. The scheme earns its cost when downstream systems hold references for a long time, when historical reports must be reproducible, or when the same features are joined from several places. Those conditions arrive gradually, which is why the scheme is worth considering before they do.

Up one level: OSM Feature Identity & ID Stability.