How to Generate Isochrones in Python

Problem statement

An isochrone β€” the area reachable in a given travel time β€” takes four lines to compute and produces a polygon whose area depends on decisions you have not made yet:

import networkx as nx
import osmnx as ox
from shapely.geometry import MultiPoint

sub = nx.ego_graph(G, centre, radius=300, distance="travel_time")
points = [(d["x"], d["y"]) for _, d in sub.nodes(data=True)]
isochrone = MultiPoint(points).convex_hull

That runs and returns a polygon. It is also, on a real network, 28% larger than the honest answer, and it is the wrong direction for a catchment:

5-minute isochrone
  convex hull of reachable nodes    19.51 kmΒ²
  buffered union of reachable edges 15.22 kmΒ²

  outward (service area)   4,229 nodes
  inward  (catchment)      3,333 nodes

Both mistakes are silent. The polygon looks fine either way.

Quick answer

Reverse the graph for a catchment, buffer the edges rather than the nodes, and record what you did:

import geopandas as gpd
import networkx as nx
import osmnx as ox

G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
Gp = ox.project_graph(G)

graph = Gp.reverse(copy=True)                     # inward: who can REACH the centre
times = nx.single_source_dijkstra_path_length(
    graph, centre, weight="travel_time", cutoff=300
)

nodes, edges = ox.graph_to_gdfs(Gp)
reachable_edges = edges[
    edges.index.get_level_values("u").map(times).notna()
    & edges.index.get_level_values("v").map(times).notna()
]
isochrone = reachable_edges.buffer(60).union_all()

print(f"{len(times):,} reachable nodes, {isochrone.area / 1e6:.2f} kmΒ²")
3,333 reachable nodes, 15.22 kmΒ²
Decision Default that is usually wrong What to do
direction outward (nx.ego_graph(G, ...)) G.reverse() for a catchment
polygon convex hull buffered union of reachable edges
buffer unstated half the street spacing, recorded
speeds imputed free-flow state it; do not present as prediction
Five steps: build the graph with travel times, reverse for a catchment, traverse with a cutoff, select reachable edges, buffer and dissolve.
Steps two and four are the ones that get skipped, and they are where the 44% and the 21% come from.

Step-by-step solution

1. Add travel times to the graph

G = ox.add_edge_speeds(G)              # imputes maxspeed from highway type
G = ox.add_edge_travel_times(G)        # length / speed, in seconds

travel_time is in seconds, so a 15-minute isochrone is cutoff=900. Passing 15 gives you a fifteen-second isochrone and a very small polygon.

Check the imputation produced something sensible:

speeds = [d["speed_kph"] for *_, d in G.edges(data=True)]
print(f"{min(speeds):.0f}-{max(speeds):.0f} kph, median {np.median(speeds):.0f}")
8-64 kph, median 34

2. Reverse the graph for a catchment

graph = Gp.reverse(copy=True) if inward else Gp

nx.ego_graph(G, centre, ...) follows outgoing edges β€” everywhere you can get to from the centre. For a catchment you want everywhere that can get to it, which means following edges backwards.

The difference is not small:

for label, g in (("outward", Gp), ("inward", Gp.reverse(copy=True))):
    sub = nx.ego_graph(g, centre, radius=300, distance="travel_time")
    print(f"  {label:8}: {len(sub.nodes):,} nodes")
  outward : 4,229 nodes
  inward  : 3,333 nodes

Twenty-seven percent more nodes outward. Use outward for service areas and dispatch; inward for catchments, accessibility and customer bases.

3. Traverse once, with a cutoff

times = nx.single_source_dijkstra_path_length(
    graph, centre, weight="travel_time", cutoff=max(minutes) * 60
)

single_source_dijkstra_path_length with a cutoff is better than ego_graph for two reasons: it returns the time to each node, so you can slice several isochrones out of one traversal, and it stops as soon as the frontier passes the cutoff.

ego_graph returns a subgraph and throws the times away, so building 5-, 10- and 15-minute isochrones costs three traversals instead of one.

4. Buffer the reachable edges, not the nodes

nodes, edges = ox.graph_to_gdfs(Gp)

u = edges.index.get_level_values("u").map(times)
v = edges.index.get_level_values("v").map(times)
reachable = edges[u.notna() & v.notna()]

isochrone = reachable.buffer(buffer_m).union_all()

An edge counts as reachable when both endpoints are within the cutoff. Requiring both is slightly conservative β€” a street half inside the time budget is excluded β€” and it avoids the alternative problem of a long edge with one reachable end dragging the isochrone far outside the true boundary.

Buffering edges rather than nodes matters most where the network is sparse. A 1 km street with both ends reachable is served along its whole length; a node buffer would leave a hole in the middle of it.

5. Slice several bands from the one traversal

bands, previous = [], None
for m in sorted(minutes):
    u = edges.index.get_level_values("u").map(times)
    v = edges.index.get_level_values("v").map(times)
    within = edges[(u <= m * 60) & (v <= m * 60)]
    area = within.buffer(buffer_m).union_all()
    if previous is not None:
        area = area.union(previous)          # guarantee nesting
    bands.append({"minutes": m, "geometry": area})
    previous = area

The explicit union with the previous band makes nesting a guarantee. Without it, independently buffered bands can poke outside each other at the edges, which looks like a bug on the map and is awkward to explain.

A long street buffered at its nodes leaving a hole in the middle, against the same street buffered along its whole length.
Node buffering leaves gaps on long edges. The street is served along its length, not only at its junctions.

Code examples

Example 1 β€” a complete isochrone function

import geopandas as gpd
import networkx as nx
import numpy as np
import osmnx as ox


def isochrones(G, centre_xy, minutes=(5, 10, 15), *, inward=True,
               buffer_m=60, weight="travel_time"):
    """Nested travel-time isochrones with every choice recorded."""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)

    largest = max(nx.strongly_connected_components(Gp), key=len)
    Gp = Gp.subgraph(largest).copy()

    centre, snap = ox.nearest_nodes(Gp, centre_xy[0], centre_xy[1], return_dist=True)
    graph = Gp.reverse(copy=True) if inward else Gp

    cutoff = max(minutes) * 60
    times = nx.single_source_dijkstra_path_length(
        graph, centre, weight=weight, cutoff=cutoff
    )

    nodes, edges = ox.graph_to_gdfs(Gp)
    u = edges.index.get_level_values("u").map(times)
    v = edges.index.get_level_values("v").map(times)

    bands, previous = [], None
    for m in sorted(minutes):
        within = edges[(u <= m * 60) & (v <= m * 60)]
        if within.empty:
            print(f"  {m} min: nothing reachable")
            continue
        area = within.buffer(buffer_m).union_all()
        if previous is not None:
            area = area.union(previous)
        bands.append({
            "minutes": m,
            "edges": len(within),
            "area_km2": round(area.area / 1e6, 3),
            "direction": "inward" if inward else "outward",
            "buffer_m": buffer_m,
            "weight": weight,
            "speed_source": "osmnx imputed, free-flow",
            "snap_m": round(snap, 1),
            "geometry": area,
        })
        previous = area

    frame = gpd.GeoDataFrame(bands, geometry="geometry", crs=Gp.graph["crs"])
    frame["incremental_km2"] = frame["area_km2"].diff().fillna(frame["area_km2"])
    print(f"  {'inward' if inward else 'outward'} from node {centre} "
          f"(snapped {snap:.0f} m), buffer {buffer_m} m")
    print(frame.drop(columns="geometry").to_string(index=False))
    return frame


bands = isochrones(G, (-2.2426, 53.4808), minutes=(1, 3, 5), inward=True)
bands.to_file("isochrones.gpkg", layer="catchment", driver="GPKG")
  inward from node 29779331 (snapped 4 m), buffer 60 m
 minutes  edges  area_km2 direction  buffer_m       weight            speed_source  snap_m  incremental_km2
       1    132     0.342    inward        60  travel_time  osmnx imputed, free-flow     4.4            0.342
       3   1616     3.522    inward        60  travel_time  osmnx imputed, free-flow     4.4            3.180
       5   7536    15.223    inward        60  travel_time  osmnx imputed, free-flow     4.4           11.701

Every parameter that affected the areas is a column. Written to a GeoPackage, the isochrone stays interpretable and comparable months later β€” which a bare polygon does not.

Example 2 β€” showing what the polygon method costs

from shapely.geometry import MultiPoint
import pandas as pd


def method_comparison(G, centre_xy, minutes=5, buffer_m=60):
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)
    centre = ox.nearest_nodes(Gp, centre_xy[0], centre_xy[1])

    times = nx.single_source_dijkstra_path_length(
        Gp.reverse(copy=True), centre, weight="travel_time", cutoff=minutes * 60
    )
    nodes, edges = ox.graph_to_gdfs(Gp)
    reached = nodes.loc[nodes.index.isin(times)]

    u = edges.index.get_level_values("u").map(times)
    v = edges.index.get_level_values("v").map(times)
    reached_edges = edges[u.notna() & v.notna()]

    methods = {
        "convex hull": MultiPoint(list(reached.geometry)).convex_hull,
        "node buffer": reached.buffer(buffer_m).union_all(),
        "edge buffer": reached_edges.buffer(buffer_m).union_all(),
    }
    base = methods["edge buffer"].area
    rows = [{"method": k, "km2": round(g.area / 1e6, 2),
             "vs_edge_buffer": f"{g.area / base - 1:+.0%}"}
            for k, g in methods.items()]
    print(pd.DataFrame(rows).to_string(index=False))
    return methods


method_comparison(G, (-2.2426, 53.4808), minutes=5)
     method   km2 vs_edge_buffer
convex hull 19.51           +28%
node buffer 12.93           -15%
edge buffer 15.22            +0%

Twenty-eight percent larger from the convex hull, on a five-minute catchment. That is not a rounding difference β€” it is the river, the railway and the industrial estate being filled in, because a hull cannot have a dent.

The node buffer is 15% smaller than the edge buffer, and that gap is the holes it leaves along streets that are entirely reachable. On a rural network with kilometre-long roads the shortfall is far worse.

Example 3 β€” plotting the bands over the network

import matplotlib.pyplot as plt
from matplotlib.colors import to_rgba


def plot_isochrones(bands, G, *, cmap=("#0c4a6e", "#0284c7", "#7dd3fc")):
    Gp = ox.project_graph(G)
    nodes, edges = ox.graph_to_gdfs(Gp)

    fig, ax = plt.subplots(figsize=(9, 9))
    edges.plot(ax=ax, color="#e2e8f0", linewidth=0.4, zorder=1)

    # largest first, so the smallest band ends up on top
    for (_, band), colour in zip(bands.iloc[::-1].iterrows(), cmap):
        gpd.GeoSeries([band.geometry], crs=bands.crs).plot(
            ax=ax, color=to_rgba(colour, 0.55), edgecolor=colour,
            linewidth=1.4, zorder=2,
            label=f"{band['minutes']} min Β· {band['area_km2']:.1f} kmΒ²",
        )

    ax.legend(loc="lower right", frameon=False)
    ax.set_title(
        f"{bands['direction'].iloc[0]} travel-time catchment Β· "
        f"{bands['weight'].iloc[0]} Β· {bands['buffer_m'].iloc[0]} m buffer\\n"
        f"{bands['speed_source'].iloc[0]}",
        fontsize=10,
    )
    ax.set_axis_off()
    fig.savefig("isochrones.png", dpi=200, bbox_inches="tight")
    print(f"  plotted {len(bands)} bands")
    return fig


plot_isochrones(bands, G)
  plotted 3 bands

Two things are deliberate. The bands are drawn largest first so the smallest sits on top β€” the reverse order hides every band but the widest.

And the direction, weight, buffer and speed source are in the title. An isochrone map without them cannot be compared with any other isochrone map, and comparison is nearly always the reason someone made it.

Explanation

Why ego_graph is the wrong tool for nested bands

nx.ego_graph(G, centre, radius=r, distance=w) returns the induced subgraph of everything within r. It is convenient and it discards the distances.

To build 5-, 10- and 15-minute bands you would call it three times β€” three traversals of an expanding neighbourhood, the largest of which does all the work of the other two.

single_source_dijkstra_path_length with cutoff=max(minutes) * 60 traverses once and returns a dict of node to seconds. Slicing that at several thresholds is a dict comprehension, and the bands are guaranteed consistent with each other because they came from one traversal.

Why both endpoints must be inside the cutoff

An edge with one endpoint at 290 seconds and the other at 400 is partly inside a 5-minute isochrone. Three options:

  • Require both β€” slightly conservative, excludes the reachable part of that street.
  • Require either β€” includes the whole street, so the isochrone extends past the true boundary by up to one edge length.
  • Split the edge at the exact time boundary β€” correct, and considerably more code.

Requiring both is the usual choice because the error is bounded by the edge length and it errs on the side of understating the catchment. With a median edge of 51 m in a city that is a negligible boundary effect; on a rural network with 1 km edges it is not, and splitting becomes worth the effort.

Convex hull at 19.51 square kilometres, node buffer at 12.93 and edge buffer at 15.22 for the same five-minute catchment.
Same traversal, three polygons. The hull is 28% too large and the node buffer 15% too small.

Why the graph must be bigger than the isochrone

If the reachable set touches the edge of your downloaded graph, the isochrone boundary is the download boundary, not the travel time. The polygon then has a suspiciously straight edge, and it is smaller than the truth.

nodes_gdf = ox.graph_to_gdfs(Gp, edges=False)
reached = nodes_gdf.loc[nodes_gdf.index.isin(times)]
if reached.total_bounds[0] <= nodes_gdf.total_bounds[0] + 100:
    print("WARNING: the isochrone reaches the graph boundary β€” download a larger area")

The rule of thumb: download a radius of at least max_minutes Γ— max_speed, with margin. For a 15-minute drive at 60 kph that is 15 km, which is a much larger graph than most people start with.

Why isochrones should not be presented as guarantees

Every input is an estimate. Speeds are imputed from highway type, congestion is absent, turn delays and signals are ignored, the polygon method is a choice and the buffer radius is arbitrary.

An "8-minute ambulance isochrone" built this way is a statement that this area is better connected to the station than the area outside it. It is not a claim about response times, and the gap between those two readings is where isochrones cause trouble.

Use them comparatively β€” ranking sites, finding underserved areas, comparing before and after a road scheme β€” where the biases apply equally on both sides. For an operational service-level answer, use a routing engine with a traffic model.

Edge cases or notes

  • travel_time is seconds. A 15-minute isochrone is cutoff=900.
  • nx.ego_graph follows outgoing edges. Reverse the graph for a catchment.
  • Require both endpoints inside the cutoff, or a single long edge drags the boundary outward.
  • Buffer edges, not nodes, or long streets get holes in the middle.
  • Union each band with the previous one so nested isochrones are guaranteed to nest.
  • Download a graph much larger than the isochrone, or the boundary is the download edge.
  • Restrict to the largest strongly connected component first, or stranded nodes puncture the polygon.
  • Walking isochrones need the walk network β€” the drive network has no footpaths and imposes one-way restrictions pedestrians ignore.

FAQ

Why is my isochrone the wrong direction?

nx.ego_graph(G, ...) follows outgoing edges, giving everywhere reachable from the centre. For a catchment β€” everywhere that can reach it β€” pass G.reverse(copy=True).

Should I use a convex hull?

No. It was 28% larger than the edge-buffered polygon on a five-minute catchment, because it fills in rivers, railways and every other concavity.

Should I buffer nodes or edges?

Edges. A long street with both ends reachable is served along its whole length; node buffering leaves a hole in the middle of it, and came out 15% smaller here.

What buffer radius should I use?

About half the typical street spacing β€” 50 to 100 m in a city. There is no correct value, so record whichever you chose.

How do I build several nested bands efficiently?

One single_source_dijkstra_path_length traversal with the widest cutoff, then slice it at each threshold. Union each band with the previous one to guarantee nesting.

Why does my isochrone have a straight edge?

It reached the boundary of your downloaded graph. Download a radius of at least the maximum travel time times the maximum speed, with margin.

Are these travel times accurate?

They come from imputed free-flow speeds and ignore congestion, signals and turn delays. Treat isochrones as comparative, not as a service-level prediction.