How to Map Cycle Lanes From OpenStreetMap in Python
On this page
Problem statement
A map of a city's cycling network has to tell apart very different things: a separate cycle path through a park, a protected track beside a road, a painted lane, and a shared lane marked only with a bicycle symbol. OpenStreetMap records all of them, but not in one tag. Separate paths are their own ways, highway=cycleway. Lanes and tracks along roads are attributes of the road, cycleway=*, cycleway:left, cycleway:right or cycleway:both. Shared paths are footways or paths with bicycle=designated.
This page extracts the cycling network of Washington, D.C. from the OpenStreetMap extract of 1 January 2026. It classifies every way into one of five types of infrastructure, measures their length, and finds how much of the major road network has any cycling provision.
Quick answer
Read the highway ways with their cycling tags, and classify each from the strongest provision it has:
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", "name", "bicycle", "cycleway", "cycleway:left", "cycleway:right", "cycleway:both", "segregated"])))
H = gpd.GeoDataFrame.from_features(fp, crs=4326)
H = gpd.clip(H[H.geom_type == "LineString"], DC).to_crs(32618)
H["km"] = H.length / 1000
def side_values(r):
return {r.get(c) for c in ("cycleway", "cycleway:left", "cycleway:right", "cycleway:both")} - {None, np.nan}
def classify(r):
if r.highway == "cycleway":
return "cycle path"
sides = side_values(r)
if "track" in sides:
return "protected track"
if "lane" in sides:
return "painted lane"
if "shared_lane" in sides:
return "shared lane"
if r.highway in ("path", "footway") and r.bicycle == "designated":
return "shared path"
return None
H["infra"] = H.apply(classify, axis=1)
print(H.dropna(subset=["infra"]).groupby("infra").km.sum().sort_values(ascending=False).round(1).to_string())
infra
cycle path 190.1
painted lane 117.7
shared lane 66.5
protected track 10.7
shared path 5.7
Separate cycle paths are the largest class, 190.1 km. Painted lanes come next, 117.7 km, then shared lanes at 66.5 km. Protected tracks along roads add only 10.7 km, and paths and footways designated for bicycles 5.7 km.
Where the tags are, separate ways, major roads with provision
1. Where the tags are
Count how each kind of provision is recorded:
for col in ("cycleway", "cycleway:left", "cycleway:right", "cycleway:both"):
vc = H[col].value_counts()
print(f"{col:>15}: {int(vc.sum()):>5} ways; values {vc.head(4).to_dict()}")
print(f"highway=cycleway ways: {(H.highway == 'cycleway').sum():,}; path or footway with bicycle=designated: {(H.highway.isin(['path', 'footway']) & (H.bicycle == 'designated')).sum():,}")
cycleway: 1524 ways; values {'crossing': 758, 'separate': 302, 'shared_lane': 275, 'lane': 102}
cycleway:left: 829 ways; values {'no': 304, 'separate': 301, 'lane': 82, 'shared_lane': 54}
cycleway:right: 1547 ways; values {'lane': 582, 'no': 395, 'separate': 291, 'shared_lane': 179}
cycleway:both: 730 ways; values {'no': 374, 'lane': 265, 'shared_lane': 39, 'separate': 22}
highway=cycleway ways: 1,999; path or footway with bicycle=designated: 99
The plain cycleway key is on 1,524 ways, but half of those are crossing, which marks a crossing segment, not a lane. Right-side tags are the most common way of recording lanes: 582 ways carry cycleway:right=lane. The value no is frequent on all three side keys, an explicit statement that there is no provision. The network has 1,999 highway=cycleway ways, against only 99 designated paths and footways.
2. Separate ways mapped as "separate"
cycleway:right=separate on a road means the track is drawn as its own way next to it. Counting both would double the network:
sep = H.apply(lambda r: "separate" in side_values(r), axis=1)
print(f"road ways that point to a separately mapped cycleway: {sep.sum():,}, {H.loc[sep, 'km'].sum():.1f} km of road")
road ways that point to a separately mapped cycleway: 901, 78.9 km of road
901 road ways, 78.9 km of road, say their cycle track is mapped separately. Counting both the tag and the separate way would add up to that length twice.
3. Major roads with cycling provision
major = H[H.highway.str.replace("_link", "", regex=False).isin(["primary", "secondary", "tertiary", "trunk"])]
has = major.apply(lambda r: bool(side_values(r) & {"lane", "track", "shared_lane", "separate", "share_busway"}), axis=1)
print(f"major-road length with a cycle lane, track, shared lane or separate path: {major.loc[has, 'km'].sum():.0f} of {major.km.sum():.0f} km ({major.loc[has, 'km'].sum() / major.km.sum():.0%})")
major-road length with a cycle lane, track, shared lane or separate path: 227 of 826 km (28%)
Of 826 km of major-road ways, 227 km, 28%, have some cycling provision: a lane, track, shared lane or separately mapped path.
The longest named cycle paths
The data used on this page
The Geofabrik OpenStreetMap extract for the District of Columbia dated 1 January 2026 (© OpenStreetMap contributors, ODbL), clipped to the District's boundary from the US Census Bureau's 2023 cartographic boundary files at 1:500,000.
from pathlib import Path
import geopandas as gpd
import numpy as np
import osmium
import requests
Path("data").mkdir(exist_ok=True)
def cached(path, url):
p = Path(path)
if not p.exists():
r = requests.get(url, timeout=600)
r.raise_for_status()
p.write_bytes(r.content)
return p
PBF = cached("data/district-of-columbia-260101.osm.pbf", "https://download.geofabrik.de/north-america/us/district-of-columbia-260101.osm.pbf")
C = gpd.read_file(cached("data/cb_2023_us_county_500k.zip", "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip"))
DC = C[C.GEOID == "11001"].to_crs(4326)
print("boundary loaded")
boundary loaded
The District boundary is loaded for clipping.
Example 1: the longest named cycle paths
paths = H[(H.infra == "cycle path") & H.name.notna()].groupby("name").km.sum().sort_values(ascending=False)
print(paths.head(6).round(1).to_string())
name
Anacostia Riverwalk Trail 21.1
Rock Creek Trail 7.3
Capital Crescent Trail 6.5
Metropolitan Branch Trail 6.4
Chesapeake and Ohio Canal Trail 6.2
4th Street Southwest Cycleway 3.6
The Anacostia Riverwalk Trail is the longest named cycle path inside the District, at 21.1 km of ways, followed by the Rock Creek Trail (7.3 km) and the Capital Crescent Trail (6.5 km).
Separate ways against road tags
OpenStreetMap models cycling infrastructure in two ways, depending on whether it is physically separate. Infrastructure separated from the road is drawn as its own way, highway=cycleway, or as a path or footway that bicycles may use with bicycle=designated. Infrastructure that is part of the carriageway is recorded as a tag on the road: cycleway=lane for a painted lane, track for a kerb- or barrier-protected track, shared_lane for sharrows. Side-specific keys say which side has it, and the value separate says the infrastructure exists but is drawn as its own way, so it should not be counted again.
A cycle map therefore has to merge two sources and avoid one double count, and its classes should follow what matters to riders: separation from traffic. Painted lanes and shared lanes count as provision in many statistics but offer little protection. Kilometres of "bike lanes" from different sources often disagree because they draw these lines differently, and because roads with lanes on both sides may be counted once or twice. Stating the classes and the counting rule is as important as the number.
Contraflow, bus lanes and old tags
- Contraflow:
cycleway:left=opposite_laneoroneway:bicycle=nomark one-way streets cyclists may ride both ways. - Bus lanes:
share_buswaymarks bus lanes that cyclists may use. - Old tags: values such as
oppositeandopposite_laneoncyclewaypredate the side-specific scheme. - Routing: for bike routing, OSMnx's
network_type="bike"builds a graph from the same tags.
Internal links
- How to get roads by type from OpenStreetMap in Python — the road network
- Fixing an OSM tag filter that misses features — why one key is never enough
- How to build a street network graph in Python with OSMnx — networks for routing
FAQ
How do I get bike lanes from OpenStreetMap?
Combine highway=cycleway ways with roads tagged cycleway, cycleway:left, cycleway:right or cycleway:both, and paths with bicycle=designated.
What is the difference between cycleway=lane and cycleway=track?
lane is a painted lane on the carriageway; track is physically separated from traffic.
What does cycleway:right=separate mean?
The cycle track exists but is mapped as its own way; count that way, not the road tag.
Why do bike lane totals differ between sources?
Classes, double-sided lanes and separately mapped tracks are counted differently.