Normalizing OSM Speed Limits and Units Jump to heading

Turn maxspeed into a number your routing engine can use, without inventing values for the fifth of the data that is not a plain number.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

maxspeed looks like a number and is a small union type.

The three families of maxspeed value Three panels. Numeric values include a bare 50 in kilometres per hour, 30 mph as explicit imperial, 50 km/h as explicit metric, and 8 knots on waterways; these parse and convert directly. Implicit values such as DE urban, GB nsl_single and RO motorway resolve through a country table rather than being numbers. Non-numeric values such as none, walk and signals cannot honestly become a number and should stay categorical. Five shapes a maxspeed value takes Numeric 50` — km/h, the default unit 30 mph` — explicit imperial 50 km/h` — explicit metric 8 knots` — waterways Parse, convert, done Implicit DE:urban` → 50 km/h GB:nsl_single` → 60 mph RO:motorway` → 130 km/h A country lookup, not a number Needs a maintained table Non-numeric none` — no legal limit (DE) walk` — walking pace signals` — variable, sign-controlled A number would be a lie Keep as a category Only the first column is arithmetic. The second needs a country table and the third needs a column that is not a number.
A schema with one integer column cannot represent all three. Two columns — a number and a category — can.
Distribution of 3.1 million maxspeed values A bar chart. 78.0 percent are bare numbers in kilometres per hour. 13.0 percent carry an mph suffix and need conversion. 5.6 percent are implicit country codes needing a lookup table. 2.3 percent are non-numeric categories such as none, walk or signals. 1.2 percent are unparseable typos, ranges or free text. What is actually in the data 3.1 M maxspeed values from a European extract bare number (km/h) 78.0% — the easy majority number + mph 13.0% — needs conversion implicit country code 5.6% — needs a lookup table non-numeric category 2.3% — none, walk, signals unparseable 1.2% — typos, ranges, free text Handling only the first row covers 78 percent of values and drops a fifth of the speed data on the floor.
The mph slice alone is thirteen percent. A parser that strips the unit and keeps the number makes every one of those roads 61 percent slower than it is.

Three consequences follow. A schema with one integer column cannot hold the answer, because none and walk are not numbers and nulling them conflates them with missing data. Implicit values need a country lookup, so the country has to be known before normalisation runs. And the unit is not optional to handle.

What stripping the unit does to four real values A grid of four values. 30 mph on a UK residential road becomes 30 km/h when stripped, 38 percent slow, against 48 km/h converted. 70 mph on a UK motorway becomes 70 km/h, again 38 percent slow, against 113 km/h. 8 knots on a ferry route becomes 8 km/h, 46 percent slow, against 14.8 km/h. A bare 50 becomes 50 km/h, correct by luck. What the unit mistake costs raw value stripped (wrong) converted (right) `30 mph` residential UK 30 km/h — 38% slow 48 km/h `70 mph` UK motorway 70 km/h — 38% slow 113 km/h `8 knots` a ferry route 8 km/h — 46% slow 14.8 km/h `50` metric default 50 km/h — correct by luck 50 km/h Stripping the unit produces a number in the right range and the wrong value, which is why routing ETAs drift without anyone finding a bug.
Every stripped value is plausible. That is exactly why the error survives review and shows up months later as ETAs that are consistently optimistic.

Runnable solution Jump to heading

python
#!/usr/bin/env python3
"""Normalise OSM maxspeed values to km/h, keeping what cannot be a number."""
from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from enum import Enum

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)

MPH_TO_KMH = 1.609344
KNOTS_TO_KMH = 1.852


class SpeedKind(str, Enum):
    EXPLICIT = "explicit"        # a number was stated
    IMPLICIT = "implicit"        # resolved from a country default
    CATEGORY = "category"        # none / walk / signals — no honest number
    UNKNOWN = "unknown"          # absent
    UNPARSEABLE = "unparseable"  # present, not understood


@dataclass(frozen=True)
class Speed:
    kmh: float | None
    kind: SpeedKind
    category: str | None = None
    raw: str | None = None


#: Values that deliberately are not numbers. Mapping them to one loses information.
CATEGORIES = {
    "none": "no_legal_limit",       # German autobahn — advisory 130, not a limit
    "walk": "walking_pace",
    "signals": "variable_signed",
    "variable": "variable_signed",
}

#: A small extract of the implicit-value table. The real one is maintained upstream
#: in the OSM wiki and should be loaded as data, not embedded in code.
IMPLICIT: dict[str, float] = {
    "DE:urban": 50, "DE:rural": 100, "DE:living_street": 7, "DE:motorway": 130,
    "GB:nsl_single": 60 * MPH_TO_KMH, "GB:nsl_dual": 70 * MPH_TO_KMH,
    "GB:motorway": 70 * MPH_TO_KMH,
    "FR:urban": 50, "FR:rural": 80, "FR:motorway": 130,
    "RO:urban": 50, "RO:rural": 90, "RO:motorway": 130,
    "AT:urban": 50, "AT:rural": 100, "AT:motorway": 130,
}

_NUMERIC = re.compile(r"""
    ^\s*
    (?P<value>\d+(?:\.\d+)?)        # the number
    \s*
    (?P<unit>km/h|kmh|kph|mph|knots|kn)?   # optional unit; absent means km/h
    \s*$
""", re.IGNORECASE | re.VERBOSE)

_UNIT_FACTOR = {
    None: 1.0, "": 1.0, "km/h": 1.0, "kmh": 1.0, "kph": 1.0,
    "mph": MPH_TO_KMH, "knots": KNOTS_TO_KMH, "kn": KNOTS_TO_KMH,
}

#: Above this, the value is a data error rather than a fast road.
MAX_PLAUSIBLE_KMH = 300.0


def normalise(raw: str | None, country: str | None = None) -> Speed:
    """Parse one maxspeed value. Never guesses; never returns a number it invented."""
    if raw is None or not raw.strip():
        return Speed(None, SpeedKind.UNKNOWN, raw=raw)

    value = raw.strip()
    lowered = value.lower()

    if lowered in CATEGORIES:
        return Speed(None, SpeedKind.CATEGORY, category=CATEGORIES[lowered], raw=raw)

    match = _NUMERIC.match(value)
    if match:
        unit = (match.group("unit") or "").lower()
        kmh = float(match.group("value")) * _UNIT_FACTOR[unit or None]
        if not 0 < kmh <= MAX_PLAUSIBLE_KMH:
            return Speed(None, SpeedKind.UNPARSEABLE, raw=raw)
        return Speed(round(kmh, 1), SpeedKind.EXPLICIT, raw=raw)

    # Implicit form: either a full "CC:type" token, or a bare type with a known country.
    key = value if ":" in value else (f"{country}:{value}" if country else None)
    if key and key in IMPLICIT:
        return Speed(round(IMPLICIT[key], 1), SpeedKind.IMPLICIT, raw=raw)

    return Speed(None, SpeedKind.UNPARSEABLE, raw=raw)


def normalise_many(values: list[tuple[str | None, str | None]]) -> list[Speed]:
    results = [normalise(raw, country) for raw, country in values]
    counts: dict[SpeedKind, int] = {kind: 0 for kind in SpeedKind}
    for speed in results:
        counts[speed.kind] += 1
    total = len(results) or 1
    for kind, n in counts.items():
        logger.info("%-12s %8d  %5.1f%%", kind.value, n, 100 * n / total)
    return results

Step-by-step walkthrough Jump to heading

CATEGORIES is checked before the numeric pattern, and its values become a category rather than a number. maxspeed=none on a German autobahn means there is no legal limit; encoding it as 130 asserts a limit that does not exist, and encoding it as null makes it indistinguishable from an unsurveyed road. A separate categorical column is the only representation that is true.

The numeric regex makes the unit optional and defaults it to km/h, which is what the specification says. The important part is that mph is converted rather than stripped — the thirteen percent slice that a naive parser silently makes 38 percent too slow.

MAX_PLAUSIBLE_KMH rejects rather than clamps. OSM contains maxspeed=999 and maxspeed=1000, which are data errors, and clamping them to 300 asserts a limit nobody surveyed. Returning UNPARSEABLE keeps them visible, following the same discipline as the coercion bounds in Mapping OSM Tags to a Fixed Schema with YAML.

The implicit lookup accepts both a full CC:type token and a bare type combined with a known country, because both appear. Without a country it does not guess — a bare urban with no country is genuinely ambiguous, and 50 km/h is right in much of Europe and wrong in the United States.

The kind field is what makes the output usable. A consumer computing average speeds needs to exclude implicit values or weight them differently; one computing coverage needs to count them as present. One number cannot serve both.

Verification Jump to heading

Test each family, and specifically test that the unit is converted:

python
def test_mph_is_converted_not_stripped():
    assert normalise("30 mph").kmh == 48.3
    assert normalise("70 mph").kmh == 112.7

def test_bare_number_is_kmh():
    assert normalise("50").kmh == 50.0

def test_none_is_a_category_not_a_number():
    speed = normalise("none")
    assert speed.kmh is None and speed.category == "no_legal_limit"

def test_implicit_needs_a_country():
    assert normalise("urban").kind is SpeedKind.UNPARSEABLE
    assert normalise("urban", country="DE").kmh == 50.0

def test_absurd_values_are_rejected_not_clamped():
    assert normalise("999").kind is SpeedKind.UNPARSEABLE

Then run over a real extract and compare the distribution against the chart above. A parseable rate far below 98 percent usually means the country join has not run, so every implicit value is failing.

Finally, sanity-check the resulting distribution against reality:

python
speeds = [s.kmh for s in results if s.kmh is not None]
import statistics
logger.info("median %.0f km/h, p95 %.0f km/h",
            statistics.median(speeds), sorted(speeds)[int(0.95 * len(speeds))])

A median around 50 and a 95th percentile around 120–130 is what a European road network looks like. A median in the thirties means mph values are being stripped rather than converted — the number is plausible, which is exactly why the distribution check is worth running.

Common errors and fixes Jump to heading

Symptom Root cause Fix
Routing ETAs consistently optimistic mph stripped, not converted Multiply by 1.609344
German autobahns capped at 130 none mapped to a number Keep it as a category
Every implicit value unparseable No country available at normalisation time Join the country before normalising
A road at 999 km/h Value accepted without bounds Reject above a plausible ceiling
none and unsurveyed look identical Both stored as null Add a kind column
Median speed near 35 km/h mph slice mishandled Check the distribution, not just the parse rate

Frequently Asked Questions Jump to heading

Should implicit values be resolved at all?

Resolve them, and mark them. An implicit value is a real legal limit and excluding it loses five percent of the network’s speed data; but it is a limit derived from a national rule rather than a surveyed sign, so a consumer comparing surveyed coverage between countries needs to be able to exclude them. The kind column costs one byte and makes both uses possible.

Where does the implicit-value table come from?

The OSM wiki maintains it per country, and it changes when national speed laws change. Load it as data rather than embedding it in code, version it alongside your mapping, and treat an update as a reviewable change — a table edit that silently reclassifies every rural road in a country is exactly the kind of change that should show up in a diff.

What about maxspeed:conditional?

It uses the value @ condition form and shares its time-selector grammar with opening_hours, so the parser in Parsing OSM Opening Hours Values does most of the work. Resolve the base maxspeed first and treat the conditional as an override applying over a time window; storing only the conditional loses the default.

Should I fall back to a default when maxspeed is absent?

Only with a provenance stamp. A highway-class default is a reasonable estimate and a terrible fact: a routing engine benefits from it, and a completeness metric computed over defaulted values measures your defaults rather than the map. Fill it, mark it as defaulted, and let each consumer decide — the fallback-chain pattern from Batch Attribute Mapping Strategies.

Specification reference Jump to heading

maxspeed values are numeric with an optional unit suffix, where an absent unit means km/h and recognised suffixes are mph and knots. Implicit values take the form CC:type, resolved against a country-specific table. The values none, walk, signals and variable are defined categorical states rather than numbers.

Up one level: Value Standardization & Regex Cleaning.