Speed limits on tertiary highways and traffic stress

transportation Madison (WI) Milwaukee (WI) OpenStreetMap

A look at OpenStreetMap data in Madison and Milwaukee

Harald Kliems https://haraldkliems.netlify.app/
2026-02-03

This is a very niche post, but maybe it’s of interest to some niche people and so I may as well put it on my blog. Last week I learned about a change in how People for Bikes calculate “Bicycle Level of Traffic Stress” for a specific type of street: Highways classified in OpenStreetMap as tertiary used to be classified low-stress as long as they had painted bike lanes; now they are classified as high-stress. Makes sense: Tertiary highways are probably busier, with higher speeds, and so a painted bike lane is not enough to make them feel safe for people of all ages and all abilities. The People for Bikes analysis assumes a default speed of 30 mph on these streets if no explicit speed limit is tagged in OpenStreetMap. As I looked at the map for Madison to see which streets are classified as highway=tertiary I noticed that a lot of them actually have speed limits as low as 20 mph, but those speed limits often weren’t in OpenStreetMap. This warranted more systematic investigation:

Madison

Show code
library(tidyverse)
library(osmdata)
library(sf)
library(tigris)
# set different Overpass server
set_overpass_url("https://overpass.private.coffee/api/interpreter")

We obtain all tertiary roads in the greater Madison area, as well as the city boundaries for Madison:

Show code
madison_city_limits <- places(state = "WI") |> filter(NAME == "Madison") |> st_transform(crs = "EPSG:4326")
bb <- c(-89.594193, 42.997023, -89.202118, 43.166131)
x <- opq (bbox = bb) |>
    add_osm_feature (key = "highway", value = "tertiary") |>
    osmdata_sf () 

tertiaries <- x$osm_lines

Now we filter to only the highways that are within Madison or have at least a portion within Madison.

Show code
msn_tertiary <- tertiaries[madison_city_limits, , op = st_intersects]

msn_tertiary |> 
  mutate(maxspeed_numeric = as.numeric(str_remove(maxspeed, " mph")),
         maxspeed_factor = as_factor(maxspeed)) |> 
  st_drop_geometry() |> 
  count(maxspeed_numeric) |> 
  gt::gt()
maxspeed_numeric n
15 3
20 14
25 344
30 306
35 153
40 23
45 5
50 1
NA 1033

We see that roughly half of tertiary highways have missing speed limit tags. But of the ones that do have it, many of them have limits lower than the 30 mph that the Brokenspoke analysis assumes. Note that this is based on OSM segments without taking their length into account.

We can also map this data to get a better sense of the spatial distribution. One may hypothesize that the lower speed limit roads are closer to the center of Madison.

Show code
library(tmap)
tmap_mode("plot")
msn_tertiary |> 
  mutate(maxspeed_numeric = as.numeric(str_remove(maxspeed, " mph")),
         thirty_mph = case_when(maxspeed_numeric < 30 ~ "below 30 mph",
                                maxspeed_numeric == 30 ~ "30 mph",
                                maxspeed_numeric > 30 ~ "over 30 mph"
                                 )) |> 
  tm_shape() +
  tm_lines(col = "thirty_mph")

This seems to confirm that hypothesis: In central areas, a lot of tertiary roads have limits below 30 mph, whereas those with limits above 30 are in the peripheral areas.

Milwaukee

Let’s repeat the analysis for Milwaukee.

Show code
milwaukee_city_limits <- places(state = "WI") |> filter(NAME == "Milwaukee") |> st_transform(crs = "EPSG:4326")
bb <- c(-88.205109, 42.845378, -87.782135, 43.219020)
x <- opq (bbox = bb) |>
    add_osm_feature (key = "highway", value = "tertiary") |>
    osmdata_sf () 

tertiaries <- x$osm_lines

mke_tertiary <- tertiaries[milwaukee_city_limits, , op = st_intersects]

mke_tertiary |> 
  mutate(maxspeed_numeric = as.numeric(str_remove(maxspeed, " mph")),
         maxspeed_factor = as_factor(maxspeed)) |> 
  st_drop_geometry() |> 
  count(maxspeed_numeric) |> 
  gt::gt()
maxspeed_numeric n
20 4
25 360
30 2015
35 175
40 22
NA 211

Speed limit data on Milwaukee streets is more completed, and a large majority of them indeed have a speed limit of 30 mph.

Show code
mke_tertiary |> 
  mutate(maxspeed_numeric = as.numeric(str_remove(maxspeed, " mph")),
         thirty_mph = case_when(maxspeed_numeric < 30 ~ "below 30 mph",
                                maxspeed_numeric == 30 ~ "30 mph",
                                maxspeed_numeric > 30 ~ "over 30 mph"
                                 )) |> 
  tm_shape() +
  tm_lines(col = "thirty_mph")

The spatial distribution of 30 vs <30 mph streets is more even than in Madison. But streets with a speed limit above 30 mph are again at the edges of the city.

The takeaway? Assuming a speed limit of 30 mph for tertiary roads overall is probably reasonable. But in a city like Madison it does lead to misclassification of streets as high stress. I am working on adding explicit maxspeed=* data to as many tertiary highways I can. Good Mapillary coverage helps!

If you want to review the data for your city, you can use this Overpass query:

[out:json][timeout:25];
(
  way["highway"="tertiary"][!"maxspeed"]["cycleway"]({{bbox}});
  way["highway"="tertiary"][!"maxspeed"]["cycleway:left"]({{bbox}});
  way["highway"="tertiary"][!"maxspeed"]["cycleway:right"]({{bbox}});
  way["highway"="tertiary"][!"maxspeed"]["cycleway:both"]({{bbox}});
);
out body;
>;
out skel qt;

Citation

For attribution, please cite this work as

Kliems (2026, Feb. 3). Harald Kliems: Speed limits on tertiary highways and traffic stress. Retrieved from https://haraldkliems.netlify.app/posts/2026-02-03-speed-limits-tertiary-higways-traffic-stress/

BibTeX citation

@misc{kliems2026speed,
  author = {Kliems, Harald},
  title = {Harald Kliems: Speed limits on tertiary highways and traffic stress},
  url = {https://haraldkliems.netlify.app/posts/2026-02-03-speed-limits-tertiary-higways-traffic-stress/},
  year = {2026}
}