Build a Walking Route Planner with OSM and Dijkstra
Fetch the pedestrian footway network from any city area, build a weighted graph in Python, and use Dijkstra's algorithm to compute the shortest walking path between any two points. The output is a GeoJSON LineString your frontend can render as a highlighted route on the map.
What you'll build
A walking route planner that constructs a turn-by-turn path from a start point to a destination using only real OSM footway data - no routing engine required. The approach:
- Fetch all walkable way segments (footways, pedestrian streets, paths, steps, and residential roads) inside a bounding box using multiple API calls - one per highway type - then deduplicate and filter out restricted ways.
- Build a graph where nodes are
(lon, lat, layer)tuples and edge weights are haversine distances in metres. The layer component prevents bridges and tunnels from being falsely joined to at-grade paths that share the same coordinate. - Run Dijkstra with
networkxon the largest connected component, then emit the waypoint list as a GeoJSON LineString.
Prerequisites
Run these commands on a Bash terminal.
pip install requests networkx haversine osmgeojsonScript
View full source code: test_pedestrian_shortest_path.py
"""
App idea: "walking directions" feature.
Fetch the pedestrian footway network inside a small Stockholm bbox, build an
undirected weighted graph, then use Dijkstra to find the shortest walking
route between two points in the network.
The route is returned as an ordered list of (lon, lat) waypoints that a
frontend could render as a highlighted path on the map.
Graph construction:
- Nodes: ``(lon, lat, layer)`` tuples — the layer prevents grade-separated
crossings (bridges, tunnels) from being falsely joined to at-grade ways
sharing the same coordinate.
- Edge weights: haversine distance in metres.
API calls: multiple ``type=way&shape=line&tags=highway=<type>`` queries,
one per pedestrian highway type, merged client-side with deduplication.
"""
import pytest
import networkx as nx
from osmfeatures import OSMFeatureCollection, OSMFeaturesClient
from tests.example_apps.graph_utils import build_nx_graph
GAMLA_STAN_BBOX = "18.063,59.322,18.082,59.332"
PEDESTRIAN_HIGHWAY_TYPES = {
"footway", "pedestrian", "steps", "path", "service", "residential", "living_street",
}
def test_pedestrian_shortest_path(client: OSMFeaturesClient):
pedestrian_features = []
seen_feature_ids: set[str] = set()
for highway_type in PEDESTRIAN_HIGHWAY_TYPES:
data = client.query(
bbox=GAMLA_STAN_BBOX,
type="way",
shape="line",
tags=f"highway={highway_type}",
limit=300,
)
assert isinstance(data, OSMFeatureCollection)
for feature in data["features"]:
feature_id = feature["id"]
if feature_id in seen_feature_ids:
continue
if feature.tags.get("foot") in {"no", "private"}:
continue
if feature.tags.get("access") in {"no", "private"}:
continue
seen_feature_ids.add(feature_id)
pedestrian_features.append(feature)
if len(pedestrian_features) < 5:
pytest.skip("Not enough pedestrian way features to build a meaningful graph")
G = build_nx_graph(pedestrian_features)
assert G.number_of_nodes() > 10, "Graph too small - expected more nodes in Gamla Stan"
# Work within the largest connected component so Dijkstra always finds a path
largest_cc = max(nx.connected_components(G), key=len)
H = G.subgraph(largest_cc)
# Pick a start node that is an actual intersection (degree >= 2)
start = next((n for n in H.nodes if H.degree(n) >= 2), None)
assert start is not None, "No intersection nodes found in graph"
# Compute shortest-path distances from start using networkx Dijkstra
dist = nx.single_source_dijkstra_path_length(H, start, weight="weight")
# Find reachable nodes more than 50 m away as candidate endpoints
candidates = [n for n, d in dist.items() if d > 50]
assert candidates, "No reachable nodes found more than 50 m from start"
# Pick the farthest reachable node as the destination
end = max(candidates, key=lambda n: dist[n])
# Retrieve the actual waypoint list
path = nx.dijkstra_path(H, start, end, weight="weight")
total_dist_m = dist[end]
assert len(path) >= 2, "Path must have at least 2 waypoints"
assert path[0] == start
assert path[-1] == end
assert total_dist_m > 0
# Verify each step is a real edge in the graph
for i in range(len(path) - 1):
assert H.has_edge(path[i], path[i + 1]), (
f"Path step {path[i]} -> {path[i + 1]} is not a graph edge"
)
# Convert to GeoJSON LineString (what the frontend would render)
route_geojson = {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[n[0], n[1]] for n in path],
},
"properties": {
"total_distance_m": round(total_dist_m, 1),
"waypoints": len(path),
},
}
assert route_geojson["geometry"]["type"] == "LineString"
assert route_geojson["properties"]["total_distance_m"] > 0
print(
f"\n[shortest path] {len(pedestrian_features)} pedestrian segments -> "
f"{G.number_of_nodes()} graph nodes"
)
print(
f" Route: {len(path)} waypoints, "
f"{route_geojson['properties']['total_distance_m']} m"
)
print(f" Start : {start}")
print(f" End : {end}")
How it works
1. Fetching the network
OSM splits walkable infrastructure across several highway values - footway for dedicated foot paths, pedestrian for pedestrianised streets, steps for staircases, and so on. Each is queried separately and the results are merged, deduplicating by feature ID so shared segments appear only once. Ways with explicit foot=no or access=no tags are excluded before the graph is built.
2. The layer trick
When inferring graph topology purely from coordinates (without original OSM node IDs), bridges and tunnels can falsely connect to at-grade paths that cross them - because both share the same lon/lat at the crossing point. The fix: graph nodes are keyed as (lon, lat, layer) where the layer is derived from the feature's bridge, tunnel, and layer OSM tags. Mid-span interior nodes carry the way's effective layer; endpoints are always placed at layer 0 so the structure still connects to the ground network at its ends.
3. Dijkstra and the connected-component constraint
A real-world OSM bbox often contains disconnected sub-graphs - islands of ways with no connecting path to the rest. Dijkstra raises NetworkXNoPath if start and end are in different components. Working on the largest connected component avoids this: any two nodes in it are guaranteed to be reachable from each other, so the algorithm always finds a path.
| API parameter | Value | Why |
|---|---|---|
bbox | min_lon,min_lat,max_lon,max_lat | Defines the area to fetch. Larger areas -> denser graph -> longer Dijkstra runtime. |
type | line | Only LineString geometries are useful for routing; nodes and polygons are ignored. |
tags | highway=footway etc. | One request per pedestrian highway type. The types are merged client-side. |
limit | 300 | Per-request cap. Dense areas may need pagination via X-Next-Cursor (SDK: result.meta.next_cursor). |
Route output format
The route is emitted as a standard GeoJSON Feature with a LineString geometry - the same format used by Mapbox, Leaflet, and Google Maps for rendering a highlighted path:
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[18.0681, 59.3252],
[18.0684, 59.3251],
...
]
},
"properties": {
"total_distance_m": 342.5,
"waypoints": 28
}
}More examples
Park Bench Finder
The simplest possible MapLark query - fetch point nodes in a bbox and place them on a map.
Restaurant Guide
Fetch restaurants with names, cuisine types, and map pins - handling both point and polygon geometries.
Cycling Trail Finder
Use OR-combined tag filters to find bike-permitted paths and forestry tracks.
API Playground
Explore the OSM dataset with preset tag filters and test all API parameters.