Designing a Star Schema for OSM Features Jump to heading
A star schema over OSM is unusual in one respect: the fact table’s grain is a thing rather than an event, and the measures are mostly counts and geometric quantities rather than sums of transactions. Everything else about the pattern transfers.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The schema has one fact table and three dimensions.
The feature fact has one row per feature, keyed on a surrogate. Its measures are geometric — area, length, a count of one — and its degenerate attributes are the promoted tags. Everything unpromoted lives in a map column on the same row.
The class dimension resolves OSM’s primary tags into a hierarchy analysts can group by: a three-level class, subclass and detail, derived once from the tag set rather than parsed in every query.
The area dimension holds the administrative hierarchy, and the fact table carries a foreign key to the smallest containing area. That single precomputation removes a point-in-polygon join from every geographic query, which is usually the difference between a query that runs and one that does not.
The time dimension is the extract date, not an event date. It is what lets two snapshots be compared, and it is the only dimension whose grain is a decision rather than a fact.
Runnable solution Jump to heading
-- A star schema for OSM features. Written for DuckDB; the shape transfers.
CREATE TABLE dim_class (
class_key INTEGER PRIMARY KEY,
class VARCHAR NOT NULL, -- 'transportation'
subclass VARCHAR NOT NULL, -- 'road'
detail VARCHAR NOT NULL, -- 'residential'
primary_key_tag VARCHAR NOT NULL, -- 'highway'
primary_value VARCHAR NOT NULL -- 'residential'
);
CREATE TABLE dim_area (
area_key INTEGER PRIMARY KEY,
osm_relation_id BIGINT,
name VARCHAR NOT NULL,
admin_level SMALLINT NOT NULL,
parent_area_key INTEGER, -- the containing area, or NULL
country_code VARCHAR(2) NOT NULL
);
CREATE TABLE dim_snapshot (
snapshot_key INTEGER PRIMARY KEY,
extract_date DATE NOT NULL,
source_digest VARCHAR NOT NULL, -- ties back to provenance
sequence_number BIGINT
);
CREATE TABLE fact_feature (
feature_key VARCHAR PRIMARY KEY, -- the surrogate, never an OSM id
snapshot_key INTEGER NOT NULL REFERENCES dim_snapshot,
class_key INTEGER NOT NULL REFERENCES dim_class,
area_key INTEGER REFERENCES dim_area, -- smallest containing area
osm_type VARCHAR NOT NULL, -- kept for traceability, not as a key
osm_id BIGINT NOT NULL,
osm_version INTEGER NOT NULL,
-- Promoted attributes: present on most features, filtered on constantly.
name VARCHAR,
addr_street VARCHAR,
addr_housenumber VARCHAR,
-- Geometric measures.
geom GEOMETRY NOT NULL,
area_m2 DOUBLE, -- NULL for lines and points
length_m DOUBLE, -- NULL for points and areas
-- Everything else, without exception. No tag is ever dropped.
tags MAP(VARCHAR, VARCHAR) NOT NULL
);
-- Partition and cluster for the filters analysts actually apply.
CREATE INDEX idx_feature_area ON fact_feature (area_key, class_key);
CREATE INDEX idx_feature_geom ON fact_feature USING RTREE (geom);
from __future__ import annotations
import logging
import duckdb
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.warehouse.star")
# Primary tag -> (class, subclass). Detail is the tag value itself.
CLASS_MAP: dict[str, tuple[str, str]] = {
"highway": ("transportation", "road"),
"railway": ("transportation", "rail"),
"building": ("built", "building"),
"landuse": ("land", "landuse"),
"natural": ("land", "natural"),
"amenity": ("poi", "amenity"),
"shop": ("poi", "shop"),
"waterway": ("water", "waterway"),
}
# Checked in order: the first matching key decides the class.
PRIORITY = ("highway", "railway", "waterway", "building", "amenity", "shop",
"landuse", "natural")
def classify(tags: dict[str, str]) -> tuple[str, str, str, str, str] | None:
"""Resolve a feature's primary tag into the class hierarchy."""
for key in PRIORITY:
value = tags.get(key)
if not value:
continue
klass, subclass = CLASS_MAP[key]
return (klass, subclass, value, key, value)
return None # no primary tag: the feature is not a thing we model
def assign_area(conn: duckdb.DuckDBPyConnection) -> None:
"""Precompute the smallest containing area, once per load.
Doing this here rather than per query is the single largest performance
decision in the schema: a point-in-polygon join against a country's
boundaries costs orders of magnitude more than a foreign key lookup.
"""
conn.execute("""
UPDATE fact_feature f
SET area_key = (
SELECT a.area_key
FROM dim_area a
JOIN area_geometry g ON g.area_key = a.area_key
WHERE ST_Contains(g.geom, ST_Centroid(f.geom))
ORDER BY a.admin_level DESC -- smallest containing area
LIMIT 1
)
WHERE f.area_key IS NULL
""")
missing, = conn.execute(
"SELECT COUNT(*) FROM fact_feature WHERE area_key IS NULL").fetchone()
if missing:
# Features outside every mapped boundary: coastal, offshore, or a gap.
logger.warning("%d feature(s) fall outside every area", missing)
def null_rate_report(conn: duckdb.DuckDBPyConnection) -> None:
"""Which promoted columns are earning their place?"""
for column in ("name", "addr_street", "addr_housenumber"):
rate, = conn.execute(
f"SELECT 1.0 - COUNT({column}) * 1.0 / COUNT(*) FROM fact_feature"
).fetchone()
verdict = "consider demoting" if rate > 0.8 else "keep"
logger.info("%-18s %5.1f%% null — %s", column, rate * 100, verdict)
if __name__ == "__main__":
logger.info("classify, load, assign areas, then review the null rates")
Step-by-step walkthrough Jump to heading
- Key the fact on a surrogate. The OSM type, identifier and version are kept as attributes for traceability, not as the key, so a split upstream does not change a row’s identity.
- Resolve the class once. Parsing a primary tag in every query is repeated work and, worse, repeated logic that drifts between queries. One dimension, one definition.
- Order the class priority explicitly. A feature tagged both
highwayandbuildingis a bridge or a covered way; which class wins is a modelling decision and it belongs in one visible list. - Return nothing for unclassifiable features. A feature with no primary tag is not a thing the schema models, and forcing it into a class invents a category.
- Assign the area at load time. The containment join is expensive and the answer changes rarely; computing it once per load and storing a foreign key removes it from every subsequent query.
- Store the smallest containing area. With a parent chain on the dimension, a query can roll up to any level, so the finest assignment is the most useful one.
- Keep every tag in the map column. Promotion is an optimisation, never a filter — nothing is dropped, so no future question requires re-ingesting.
- Report null rates. A promoted column that is ninety percent null is costing storage and confusion for an access pattern the map column would serve fine.
Verification Jump to heading
- Class assignment is total and unambiguous. Every fact row must have a class key, and re-running the classifier must produce the same result.
- Area assignment covers nearly everything. A high count of null area keys means the boundary set is incomplete rather than the data being offshore.
- No tag was dropped. Compare the distinct key count in the map column against the distinct keys in the source extract.
- Promoted columns earn their place. Review the null rates; anything above eighty percent is a candidate for demotion.
- Counts match the source. Total fact rows should equal the count of classifiable features in the extract.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Row identity changes upstream | Fact keyed on an OSM identifier | Key on a surrogate; keep the OSM reference as an attribute |
| Class logic differs between queries | Primary tag parsed per query | Resolve the class once into a dimension |
| Geographic queries are slow | Containment computed per query | Assign an area foreign key at load time |
| Bridges classified inconsistently | Class priority implicit | Declare the priority order in one visible list |
| A new question needs a re-ingest | Unpromoted tags dropped | Keep every tag in the map column |
| Mostly null columns | Promotion decided without evidence | Report null rates and demote the worst |
| Rolling up by region is awkward | Only the finest area stored, with no parent chain | Give the area dimension a parent key |
Specification reference Jump to heading
A star schema places a fact table at the centre, joined to dimension tables by foreign keys, with the fact’s grain defined by what one row represents. For feature data the grain is one row per feature rather than per event, and the measures are geometric quantities and counts. See Modelling OSM for Analytics Warehouses for the modelling decisions this schema implements.
Frequently Asked Questions Jump to heading
Why precompute the area foreign key?
Because a point-in-polygon join against a country’s administrative boundaries is expensive, it is the same answer every time, and it appears in the majority of OSM analytics queries. Computing it once at load and storing a foreign key converts that join into a lookup, which is usually the difference between a geographic query returning in seconds and in minutes. The containment changes only when boundaries change, which is rare.
Should the map column be a separate table instead?
Only if your engine handles map types badly. A key-value table makes every tag predicate a join, which is fine for a lookup of a few features and painful for a scan across millions. Keeping the map on the fact row means a wide scan reads one table, and modern engines can filter on map keys without materialising the whole structure. The separate table is the older pattern and it survives mostly by habit.
How is a feature with several primary tags classified?
By an explicitly ordered priority list, which turns an ambiguity into a documented decision. A way tagged as both a highway and a building is a covered passage or a bridge, and whether it belongs in transportation or built environment depends on what your analysts expect. Declaring the order in one list means the answer is consistent and reviewable, rather than depending on dictionary iteration order.
What should happen to features with no primary tag?
They are not loaded into the fact table, because the schema models things and an untagged geometry carrier is not one. The nodes that exist only to give a way its shape are the overwhelming majority of this category, and including them would multiply the fact table by an order of magnitude for rows nobody queries. Keep the count, so the exclusion is visible rather than silent.
Related Jump to heading
- Modelling OSM for Analytics Warehouses — the parent topic and the decisions behind this layout.
- Incremental OSM Loads into DuckDB — keeping this schema current from the diff stream.
- Choosing Partition Keys for an OSM Data Lake — physical layout for the query shapes above.
- Building Stable Surrogate Keys for OSM Features — the key the fact table uses.
- Mapping OSM Tags to a Fixed Schema with YAML — expressing the class map as configuration.
Up one level: Modelling OSM for Analytics Warehouses.