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.

RoutingDijkstranetworkxhaversinePython

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:

  1. 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.
  2. 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.
  3. Run Dijkstra with networkx on the largest connected component, then emit the waypoint list as a GeoJSON LineString.

Prerequisites

Run these commands on a Bash terminal.

Python
pip install requests networkx haversine osmgeojson

Script

View full source code: test_pedestrian_shortest_path.py

Python
"""
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 parameterValueWhy
bboxmin_lon,min_lat,max_lon,max_latDefines the area to fetch. Larger areas -> denser graph -> longer Dijkstra runtime.
typelineOnly LineString geometries are useful for routing; nodes and polygons are ignored.
tagshighway=footway etc.One request per pedestrian highway type. The types are merged client-side.
limit300Per-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:

JSON
{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [
      [18.0681, 59.3252],
      [18.0684, 59.3251],
      ...
    ]
  },
  "properties": {
    "total_distance_m": 342.5,
    "waypoints": 28
  }
}

Explore the footway data live

Try GET /v2/osm_features?bbox=...&type=way&shape=line&tags=highway=footway interactively.