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.

The schema, with the joins each query shape uses A feature fact table sits at the centre, holding one row per feature with a surrogate key, geometry, promoted attribute columns and a map column for the remaining tags. Three dimensions hang off it. The class dimension resolves primary tags into a grouping hierarchy. The area dimension holds administrative containment, joined through a foreign key computed once at load time. The time dimension identifies which extract snapshot the row belongs to. Queries join the fact to whichever dimensions they group by, and reach into the map column only for unpromoted attributes. One fact, three dimensions, one map column class dim grouping hierarchy area dim admin containment time dim snapshot date feature fact one row per feature map column unpromoted tags The area foreign key is computed once at load; recomputing containment per query is the single largest avoidable cost here.
The map column is on the fact row rather than in a fourth table, which is what keeps wide scans affordable.

Runnable solution Jump to heading

sql
-- 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);
python
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

  1. 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.
  2. 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.
  3. Order the class priority explicitly. A feature tagged both highway and building is a bridge or a covered way; which class wins is a modelling decision and it belongs in one visible list.
  4. 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.
  5. 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.
  6. 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.
  7. Keep every tag in the map column. Promotion is an optimisation, never a filter — nothing is dropped, so no future question requires re-ingesting.
  8. 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.
What each query shape touches in this schema A grid of four common query shapes against which parts of the schema each one reads. Counting features by class in an area reads the fact table, the class dimension and the area foreign key, and touches no geometry at all. Summing road length by area reads the same plus the length measure. Finding features with an unpromoted tag reads the fact table and the map column, with no dimension join. An arbitrary spatial filter reads the fact table's geometry through the spatial index and cannot use the area foreign key for pruning. Four query shapes, four different paths Reads Does not touch Count by class in an area fact, class, area key geometry Road length by area plus the length measure geometry Filter on a rare tag fact and map column dimensions Arbitrary spatial filter geometry, spatial index area key cannot prune The first two shapes are the common ones and neither reads geometry, which is why the area foreign key pays for itself so quickly.
The fourth shape is the one that argues for clustering by a spatial cell within each area partition.
What lives on the fact row, in four groups Four groups of columns on one fact row. The keys group holds the surrogate key plus foreign keys to the snapshot, class and area dimensions. The traceability group holds the OSM element type, identifier and version, which are attributes rather than keys so that an upstream split does not change row identity. The measures group holds geometry together with area and length, each null for the geometry types they do not apply to. The open group holds the map column containing every tag that was not promoted, which is what makes future questions answerable. Four groups, and only the last one is open-ended Keys Surrogate plus dimension foreign keys never an OSM id Traceability OSM type, id and version attributes, not keys Measures Geometry, area, length nulls by geometry type Open tags Everything unpromoted nothing is ever dropped The second group is what lets a row be traced back to the map without letting the map's identifier churn reach the schema.
Keeping traceability and identity separate is the one structural decision here that is expensive to change later.

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.

Up one level: Modelling OSM for Analytics Warehouses.