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.

Map of D.C. cycling infrastructure from OSM: teal cycle paths along the rivers and through parks, light-blue painted lanes in the downtown grid, orange shared lanes in neighbourhoods, and short dark protected tracks. Map of D.C. cycling infrastructure from OSM: teal cycle paths along the rivers and through parks, light-blue painted lanes in the downtown grid, orange shared lanes in neighbourhoods, and short dark protected tracks.
The cycling network by type.

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.

Length of cycling infrastructure in Washington, D.C., by type, from OpenStreetMap tags. cycling infrastructure by typecycle path190.1 kmpainted lane117.7 kmshared lane66.5 kmprotected track10.7 kmshared path5.7 km050 km100 km150 km200 kmLengths of ways; lanes on both sides counted once spatialworkflow.io
Length by type.

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).

How cycling provision is recorded in OpenStreetMap ways in D.C. where the cycling tags arewaysmost common valuescycleway1,524crossing 758, separate 302cycleway:right1,547lane 582, no 395cycleway:left829no 304, separate 301cycleway:both730no 374, lane 265highway=cycleway1,999separate pathsOSM D.C. extract, 1 January 2026 spatialworkflow.io
Where the cycling tags are.

Separate ways against road tags

The order in which each way is classified as cycling infrastructure. classifying a way1highway=cycleway?cycle path2any side =track?protected track3any side =lane?painted lane4any side =shared_lane?shared lane5path/footway, bicycle=designated?shared path=separate: drawn as its own way, not counted on the road spatialworkflow.io
Classifying a way.

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

Cycle network statistics from OpenStreetMap: traps and fixes. cycle network trapsSymptomFixnetwork double-countedskip =separate road tagslanes missingread :left, :right, :bothcrossings counted as lanescycleway=crossing is not a lanesharrows as protectionclass by separation901 road ways point to separate tracks spatialworkflow.io
Cycle network traps.
  • Contraflow: cycleway:left=opposite_lane or oneway:bicycle=no mark one-way streets cyclists may ride both ways.
  • Bus lanes: share_busway marks bus lanes that cyclists may use.
  • Old tags: values such as opposite and opposite_lane on cycleway predate the side-specific scheme.
  • Routing: for bike routing, OSMnx's network_type="bike" builds a graph from the same tags.

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.