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.
Runnable solution Jump to heading
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
- 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.
- 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.
- 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.
- 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.
- 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.
- Record lineage explicitly. Inferring it later from overlapping mappings is possible and unreliable; storing it costs one row per event.
- 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.
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.
Related Jump to heading
- OSM Feature Identity & ID Stability — the parent topic and the instabilities this absorbs.
- Tracking an OSM Feature Across Versions — detecting the splits and merges that drive mapping updates.
- Handling Deleted and Redacted OSM Objects — what to do when a mapping’s objects disappear.
- Modelling OSM for Analytics Warehouses — where surrogate keys become dimension keys.
- Matching OSM Features to External Datasets — matches that should reference surrogates rather than raw identifiers.
Up one level: OSM Feature Identity & ID Stability.