Fixing OpenStreetMap maxspeed Values That Are Not Numbers

On this page

Problem statement

You want speed limits from OpenStreetMap for travel times or a road safety map, call pd.to_numeric on maxspeed, and get mostly missing values. OSM speed limits are strings by design. The convention is kilometres per hour without a unit, but where limits are posted in miles per hour the value carries mph, so in the United States almost every value looks like 25 mph. Other values are not numbers at all, none, signals, walk, or codes like US:urban, and some ways carry two limits separated by a semicolon.

This page parses every maxspeed value on the roads of Washington, D.C. in the OpenStreetMap extract of 1 January 2026. It shows what to_numeric loses, writes a parser that converts each form to km/h and reports what it cannot handle, and checks the result on the major roads.

Quick answer

Parse the number and the unit, convert mph to km/h, and keep the rest as missing:

def parse_maxspeed(v):
    if not isinstance(v, str):
        return np.nan
    v = v.split(";")[0].strip()                         # first of several limits
    m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(mph|km/h|kmh|knots)?", v)
    if not m:
        return np.nan
    n, unit = float(m.group(1)), m.group(2)
    return n * 1.609344 if unit == "mph" else n * 1.852 if unit == "knots" else n
H["kmh"] = H.maxspeed.apply(parse_maxspeed)
print(f"ways with maxspeed: {H.maxspeed.notna().sum():,}; parsed: {H.kmh.notna().sum():,}; pd.to_numeric would parse: {pd.to_numeric(H.maxspeed, errors='coerce').notna().sum():,}")
ways with maxspeed: 4,738; parsed: 4,738; pd.to_numeric would parse: 16

All 4,738 ways with a maxspeed parse with the unit-aware parser. pd.to_numeric parses 16 of them, the ones written without a unit, and those deserve a second look. By OSM's convention a bare number is km/h, and the parser reads it that way. Five of them are 24.14, exactly 15 mph converted to km/h by the mapper. The other eleven are 30, 20, 15 and 5 on residential streets and service roads in a city whose limits are posted in miles per hour: a 30 read as km/h is 19 mph, read as mph it is 30, and nothing in the tag says which was meant. Flag such values rather than trust either reading.

Map of D.C. car roads coloured by parsed speed limit from 10 to 50 mph: freeways in dark purple at 40 to 50 mph, most arterials at 25 to 30 mph in orange, and untagged roads in grey. Map of D.C. car roads coloured by parsed speed limit from 10 to 50 mph: freeways in dark purple at 40 to 50 mph, most arterials at 25 to 30 mph in orange, and untagged roads in grey.
Parsed speed limits.

What the values look like, what the parser rejects, speeds by class

1. What the values look like

vals = H.maxspeed.value_counts()
print(f"distinct values: {len(vals)}")
print(vals.head(10).to_dict())
distinct values: 18
{'25 mph': 2203, '15 mph': 673, '30 mph': 642, '20 mph': 371, '40 mph': 240, '35 mph': 173, '50 mph': 122, '45 mph': 104, '10 mph': 93, '5 mph': 60}

There are 18 distinct values, 13 in miles per hour and 5 bare numbers. 25 mph is on 2,203 ways, close to half.

2. What the parser rejects

List the values that are not a number with an optional unit:

rejected = H.loc[H.maxspeed.notna() & H.kmh.isna(), "maxspeed"].value_counts()
print(f"rejected values: {rejected.sum()} ways, {len(rejected)} distinct: {rejected.head(8).to_dict()}")
multi = H.maxspeed.fillna("").str.contains(";")
print(f"ways with several limits separated by ';': {multi.sum()}")
rejected values: 0 ways, 0 distinct: {}
ways with several limits separated by ';': 0

In this extract nothing is rejected and no way carries several limits. In other places words, codes and lists do appear, so the parser still reports them rather than assuming they cannot occur.

The most common maxspeed values on highways in the D.C. extract. maxspeed values25 mph2,20315 mph67330 mph64220 mph37140 mph24008001600240018 distinct values; pd.to_numeric parses only the 16 bare numbers spatialworkflow.io
All values carry mph.

3. Speeds by road class

H["cls"] = H.highway.str.replace("_link", "", regex=False)
tab = H[H.cls.isin(["motorway", "trunk", "primary", "secondary", "tertiary", "residential"])].groupby("cls").agg(
    ways=("kmh", "size"), tagged=("kmh", lambda s: s.notna().mean()), median_kmh=("kmh", "median"))
tab["median_mph"] = tab.median_kmh / 1.609344
print(tab.round({"tagged": 2, "median_kmh": 1, "median_mph": 0}).to_string())
             ways  tagged  median_kmh  median_mph
cls                                              
motorway      999    0.50        64.4        40.0
primary      3251    0.37        40.2        25.0
residential  5297    0.12        40.2        25.0
secondary    2250    0.36        40.2        25.0
tertiary     2036    0.29        40.2        25.0
trunk         512    0.57        48.3        30.0

Coverage varies by class: 57% of trunk ways and 50% of motorway ways are tagged, but only 37% of primary ways and 12% of residential streets. The median limit is 25 mph (40.2 km/h) on every class except trunk roads, at 30 mph, and motorways, at 40 mph.

Filling gaps from the road class

The data used on this page

The Geofabrik OpenStreetMap extract for the District of Columbia dated 1 January 2026 (Β© OpenStreetMap contributors, ODbL); all highway ways with a maxspeed or a road class.

import re
from pathlib import Path

import geopandas as gpd
import numpy as np
import osmium
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
PBF = Path("data/district-of-columbia-260101.osm.pbf")
if not PBF.exists():
    r = requests.get("https://download.geofabrik.de/north-america/us/district-of-columbia-260101.osm.pbf", timeout=600)
    r.raise_for_status()
    PBF.write_bytes(r.content)
fp = (osmium.FileProcessor(str(PBF)).with_locations().with_filter(osmium.filter.EntityFilter(osmium.osm.WAY))
      .with_filter(osmium.filter.KeyFilter("highway"))
      .with_filter(osmium.filter.GeoInterfaceFilter(tags=["highway", "maxspeed", "name"])))
H = gpd.GeoDataFrame.from_features(fp, crs=4326)
H = H[H.geom_type == "LineString"].reset_index(drop=True)
print(f"{len(H):,} highway ways in the extract")
82,951 highway ways in the extract

The extract has 82,951 highway ways.

Example 1: filling gaps from the road class

Where maxspeed is missing, a default per class is a common fallback. Derive it from the tagged ways and measure how much is filled:

default = H.groupby("cls").kmh.median()
H["kmh_filled"] = H.kmh.fillna(H.cls.map(default))
car = H.cls.isin(["motorway", "trunk", "primary", "secondary", "tertiary", "residential", "unclassified"])
print(f"car roads with a speed: tagged {H.loc[car, 'kmh'].notna().mean():.0%}, after filling by class {H.loc[car, 'kmh_filled'].notna().mean():.0%}")
print("defaults used (km/h):", default.reindex(["residential", "tertiary", "primary"]).round(1).to_dict())
car roads with a speed: tagged 28%, after filling by class 100%
defaults used (km/h): {'residential': 40.2, 'tertiary': 40.2, 'primary': 40.2}

Only 28% of car roads have a tagged limit. Filling the rest with the class median brings coverage to 100%, but nearly three quarters of those speeds are then assumptions, 40.2 km/h on most classes.

Share of ways with a maxspeed and median limit by road class in the D.C. extract. speed limits by road classtaggedmedianmotorway50%40 mphtrunk57%30 mphprimary37%25 mphsecondary36%25 mphtertiary29%25 mphresidential12%25 mphShare of ways in each class with a parsed maxspeed spatialworkflow.io
Coverage by road class.

A simple rule with many exceptions

Steps to parse an OpenStreetMap maxspeed value. parsing maxspeed1string or missing?NaN stays NaN2first of severalsplit on ;3number + unitmph, km/h, knots4convertmph x 1.6093445report the restnone, signals, US:urbanA bare number is km/h spatialworkflow.io
Parsing maxspeed.

maxspeed follows a simple rule with many exceptions. A bare number is km/h. A number followed by mph or knots is in that unit. Words and codes cover limits that are not numbers: none for derestricted roads, signals for variable limits, walk for walking pace, and country codes such as US:urban or DE:urban that stand for a legal default. Conditional and directional limits use other keys, maxspeed:forward, maxspeed:backward and maxspeed:conditional. Values separated by semicolons usually mean the way has different limits along it or for different lanes.

A parser therefore needs to read the number and the unit, convert to one unit, and report everything else rather than guess. In the US almost every value has a unit, so pd.to_numeric silently drops nearly all of them, and an analysis can run on a handful of unit-less values without noticing. Once parsed, coverage is the next limit. Missing limits can be filled with defaults by road class, derived from the tagged roads or from local law, but filled values should be kept apart from tagged ones so the analysis can say how much of it rests on assumption.

Implicit limits, directions and OSMnx

Speed limit data from OpenStreetMap: traps and fixes. maxspeed trapsSymptomFixto_numeric gives NaNparse the mph unitwords like none, signalskeep missing, reporttwo limits with ;split, or use :forward72% of car roads blankclass defaults, flagged4,738 tagged ways in D.C. spatialworkflow.io
Maxspeed traps.
  • Implicit limits: maxspeed:type=US:urban or source:maxspeed say a limit is a legal default rather than signed.
  • Forward and backward: two-way roads with different limits per direction use maxspeed:forward and maxspeed:backward.
  • OSMnx: ox.add_edge_speeds parses maxspeed, including mph, and fills missing values by class.
  • Lanes: maxspeed:lanes gives a limit per lane, separated by |.

FAQ

Why is OSM maxspeed not a number?

The tag is a string: a bare number is km/h, other units carry mph or knots, and some values are words such as none or codes such as US:urban.

How do I convert OSM maxspeed to km/h?

Extract the number and unit with a regular expression and multiply mph by 1.609344.

What does maxspeed=signals mean?

The limit is shown on variable signs and has no fixed value.

How do I fill missing speed limits?

Use a default per road class, from the tagged roads or local law, and keep a flag for filled values.