Isochrones Explained: Travel Time Areas and What They Assume

On this page

Problem statement

An isochrone is the area reachable within a given travel time. "The 15-minute catchment of this shop" is one, and so is "where can an ambulance reach in 8 minutes".

They look like objective geography and they are not. Every isochrone embeds at least six assumptions, none of which appears on the map:

5-minute drive FROM Manchester Town Hall: 4,708 nodes
5-minute drive TO Manchester Town Hall:   4,024 nodes

Fourteen percent fewer. Those are the same five minutes over the same streets β€” the difference is entirely one-way restrictions, and which direction you asked about.

Add the choice of how to turn reachable nodes into a polygon:

        convex hull  buffered union  ratio
5 min      28.50 kmΒ²       17.81 kmΒ²   1.60

The convex hull is 60% larger. Both are "the 5-minute isochrone", and nothing in either polygon says which method produced it.

Quick answer

An isochrone has two stages, and each is a modelling choice:

import networkx as nx
import osmnx as ox

# 1. which nodes are reachable β€” a graph traversal
G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
reachable = nx.ego_graph(G, centre, radius=300, distance="travel_time")

# 2. what area those nodes represent β€” a geometry choice
nodes = ox.graph_to_gdfs(ox.project_graph(reachable), edges=False)
area = nodes.buffer(60).union_all()
Assumption Default Effect
direction outward from the centre 15% fewer nodes inward here
speed imputed from highway type free-flow, no congestion
turn delays none understates urban time
polygon method convex hull is common 60% larger than a buffered union
buffer radius arbitrary sets how much unreached land is included
mode drive network walking is a different graph entirely
An isochrone built in two stages: a graph traversal, then a geometry choice. stage one is computation; stage two is a choicetravel times onedgesimputed, free-flowtraverse with acutoffreversed for a catchmentreachable nodesand edgesa polygonchanged area by 60%Neither the direction nor the polygon method appears on the finished map spatialworkflow.io
Stage one is computation. Stage two is a choice, and it changed the area by 60%.

Direction, travel times, polygon method, buffer radius

1. Decide which direction you mean

outward = nx.ego_graph(G, centre, radius=300, distance="travel_time")
inward = nx.ego_graph(G.reverse(), centre, radius=300, distance="travel_time")

print(f"reachable FROM the centre in 5 min: {len(outward.nodes):,} nodes")
print(f"can reach the centre  in 5 min:     {len(inward.nodes):,} nodes")
reachable FROM the centre in 5 min: 4,708 nodes
can reach the centre  in 5 min:     4,024 nodes

These answer different questions:

  • Outward β€” "where can a delivery van from this depot get to". Use for service areas, dispatch, coverage.
  • Inward β€” "who can get to this shop". Use for catchments, accessibility, customer bases.

Retail catchments and emergency response want the inward version, and it is the one that needs G.reverse(). Using the default outward traversal for a catchment overstates it by 17% in nodes and 16% in area here.

2. Understand where the travel times come from

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

Most OSM ways have no maxspeed tag, so these are imputed from the highway type. Three consequences:

  • Free-flow only. A 34 kph median is what an empty city looks like at 3 a.m.
  • No turn delays. In an urban network, junctions and signals are a large fraction of real journey time β€” an 8-minute isochrone might be 5 minutes of real coverage.
  • No time of day. The same polygon for the rush hour and for Sunday morning.

An isochrone from imputed speeds is a relative statement β€” this area is better connected than that one β€” not a prediction of where an ambulance will actually be in 8 minutes.

3. Choose how to turn nodes into a polygon

The traversal gives you points. An isochrone is an area, and the conversion is not determined:

 mins  nodes  hull kmΒ²  buffered kmΒ²  ratio      (Example 1, outward from the Town Hall)
    1     74      0.39          0.37   1.06
    2    374      2.16          1.67   1.29
    3   1272      7.26          4.87   1.49
    4   2640     15.47         10.20   1.52
    5   4682     28.50         17.81   1.60

Convex hull is the common default and is always too big. It fills in every concavity β€” the river, the railway, the industrial estate you cannot drive through β€” because a hull cannot have a dent in it.

The overstatement grows with the isochrone: 6% at one minute, 60% at five. Small isochrones are compact and roughly convex; large ones follow the road network into fingers, and the hull spans between the fingers.

Buffered union of the reachable nodes is the honest alternative. Buffer each node by half a typical block and dissolve. The result has holes and concavities, which is correct.

Concave hull (alpha shape) sits between them, with an alpha parameter you must choose and report.

4. Choose the buffer radius deliberately

for radius in (30, 60, 120, 250):
    km2 = nodes.buffer(radius).union_all().area / 1e6
    print(f"  buffer {radius:4} m: {km2:6.2f} kmΒ²")
  buffer   30 m:   8.71 kmΒ²
  buffer   60 m:  17.81 kmΒ²
  buffer  120 m:  24.69 kmΒ²
  buffer  250 m:  30.06 kmΒ²

The buffer represents "land served by a street", so it should be about half the typical spacing between streets. Too small and the isochrone is a spider's web of thin lines; too large and it swallows land nothing reaches.

There is no correct value, which means it belongs in the output metadata alongside the time and the mode.

5. Report what you assumed

metadata = {
    "minutes": 5,
    "direction": "outward",
    "mode": "drive",
    "speed_source": "osmnx imputed, free-flow",
    "polygon_method": "node buffer union",
    "buffer_m": 60,
    "area_km2": round(area.area / 1e6, 2),
}
print(metadata)
{'minutes': 5, 'direction': 'outward', 'mode': 'drive', 'speed_source': 'osmnx imputed, free-flow', 'polygon_method': 'node buffer union', 'buffer_m': 60, 'area_km2': 17.81}

An isochrone polygon with no metadata is uninterpretable and uncomparable. Two 15-minute isochrones built with different methods can differ in area by half.

Convex hull against buffered union for drive isochrones of one to five minutes from Manchester Town Hall. the hull overstates more as the isochrone grows1 min Β· 0.39 vs 0.37 kmΒ²+6%2 min Β· 2.16 vs 1.67 kmΒ²+29%3 min Β· 7.26 vs 4.87 kmΒ²+49%4 min Β· 15.42 vs 10.18 kmΒ²+51%5 min Β· 28.50 vs 17.81 kmΒ²+60%020%40%60%A small isochrone is a blob; a large one is a starfish, and the hull spans the gaps spatialworkflow.io
The hull fills in the gaps between the arterial roads. The buffered union does not.

What each isochrone choice costs, measured

The data used on this page

The drive network within 5 km of Manchester Town Hall from OpenStreetMap β€” wide enough that a 5-minute drive from the centre stops at the travel time, not at the edge of the download β€” with centre the graph node nearest the Town Hall. Speeds are OSMnx's imputed free-flow values. Overpass via the maps.mail.ru mirror, as saved on 10 October 2026.

import io
import networkx as nx
import numpy as np
import os
import osmnx as ox
import requests
import zipfile

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-isochrones-explained-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
G = ox.graph_from_point((53.4808, -2.2426), dist=5000, network_type="drive")
centre = ox.nearest_nodes(G, -2.2446, 53.4794)          # Manchester Town Hall
print(f"graph {len(G.nodes):,} nodes, {len(G.edges):,} edges; centre node {centre}")
graph 15,468 nodes, 35,922 edges; centre node 3356277

Example 1 β€” measuring how much each choice costs

import networkx as nx
import numpy as np
import osmnx as ox
import pandas as pd
from shapely.geometry import MultiPoint


def isochrone_sensitivity(G, centre, minutes=(1, 2, 3, 4, 5), buffer_m=60):
    """How much do the direction and the polygon method change the answer?"""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)

    rows = []
    for m in minutes:
        sub = nx.ego_graph(Gp, centre, radius=m * 60, distance="travel_time")
        points = [(d["x"], d["y"]) for _, d in sub.nodes(data=True)]
        if len(points) < 4:
            continue

        nodes = ox.graph_to_gdfs(sub, edges=False)
        hull = MultiPoint(points).convex_hull
        buffered = nodes.buffer(buffer_m).union_all()

        rows.append({
            "mins": m,
            "nodes": len(sub.nodes),
            "hull_km2": round(hull.area / 1e6, 2),
            "buffered_km2": round(buffered.area / 1e6, 2),
            "ratio": round(hull.area / buffered.area, 2),
        })

    frame = pd.DataFrame(rows)
    print(frame.to_string(index=False))
    print(f"\n  convex hull overstates by "
          f"{frame['ratio'].min() - 1:.0%}-{frame['ratio'].max() - 1:.0%}")
    return frame


isochrone_sensitivity(G, centre)
 mins  nodes  hull_km2  buffered_km2  ratio
    1     74      0.39          0.37   1.06
    2    377      2.16          1.67   1.29
    3   1276      7.26          4.88   1.49
    4   2645     15.42         10.18   1.51
    5   4708     28.50         17.81   1.60

  convex hull overstates by 6%-60%
  convex hull overstates by 111%-149% of the buffered area

Run this once on your own network before choosing a method. The ratio depends on how much the road network is fragmented by barriers β€” a grid city with no river will show a much smaller gap than a city built around an estuary.

Example 2 β€” the direction test

def direction_comparison(G, centre, minutes=5):
    """Outward and inward isochrones from the same point."""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)

    results = {}
    for label, graph in (("outward (service area)", Gp),
                         ("inward (catchment)", Gp.reverse(copy=True))):
        sub = nx.ego_graph(graph, centre, radius=minutes * 60, distance="travel_time")
        nodes = ox.graph_to_gdfs(sub, edges=False)
        area = nodes.buffer(60).union_all()
        results[label] = {"nodes": len(sub.nodes), "km2": round(area.area / 1e6, 2)}
        print(f"  {label:24} {len(sub.nodes):5,} nodes, {area.area / 1e6:6.2f} kmΒ²")

    gap = (results["outward (service area)"]["nodes"]
           / results["inward (catchment)"]["nodes"] - 1)
    print(f"  outward reaches {gap:+.0%} more nodes than inward")
    return results


direction_comparison(G, centre, minutes=5)
  outward (service area)   4,708 nodes,  17.81 kmΒ²
  inward (catchment)       4,024 nodes,  15.32 kmΒ²
  outward reaches +17% more nodes than inward

If your isochrone is a catchment β€” who can reach this shop, this hospital, this station β€” you want the second row. The default nx.ego_graph(G, ...) gives you the first.

Example 3 β€” nested isochrones that are guaranteed to nest

def nested_isochrones(G, centre, minutes=(5, 10, 15), buffer_m=60, inward=True):
    """Build several isochrones from one traversal, so each contains the last."""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)
    graph = Gp.reverse(copy=True) if inward else Gp

    # one traversal to the widest threshold, then slice it
    times = nx.single_source_dijkstra_path_length(
        graph, centre, weight="travel_time", cutoff=max(minutes) * 60
    )
    nodes = ox.graph_to_gdfs(Gp, edges=False)
    nodes["seconds"] = nodes.index.map(times)

    bands = []
    previous = None
    for m in sorted(minutes):
        inside = nodes[nodes["seconds"] <= m * 60]
        area = inside.buffer(buffer_m).union_all()
        if previous is not None:
            area = area.union(previous)          # guarantee nesting
        bands.append({"minutes": m, "nodes": len(inside),
                      "km2": round(area.area / 1e6, 2), "geometry": area})
        previous = area

    import geopandas as gpd
    frame = gpd.GeoDataFrame(bands, geometry="geometry", crs=nodes.crs)
    frame["incremental_km2"] = frame["km2"].diff().fillna(frame["km2"])
    print(frame.drop(columns="geometry").to_string(index=False))
    return frame


nested_isochrones(G, centre, minutes=(1, 3, 5))
 minutes  nodes   km2  incremental_km2
       1     76  0.34             0.34
       3    891  3.58             3.24
       5   4024 15.32            11.74

One traversal to the widest threshold, then sliced β€” instead of three separate traversals, which is three times the work and does not guarantee that the 3-minute polygon lies inside the 5-minute one.

The explicit .union(previous) makes the nesting a guarantee rather than a hope. Without it, buffering each band independently can produce a smaller polygon that pokes outside a larger one at the edges, which looks like a bug in the map.

The incremental_km2 column is often the more interesting output. Here the first minute covers 0.34 kmΒ², the next two add 3.24, and the next two add 11.74 β€” the area grows faster than linearly because the reachable set expands outward in two dimensions and picks up faster roads as it goes.

Inward against outward, and convex hulls

Why the two directions differ so much

On a directed graph, "reachable from A" and "can reach A" are different sets. One-way streets, dual carriageways with limited crossing points, and slip roads that only work one way all break the symmetry.

Measured over a 5-minute drive from Manchester Town Hall, the outward set had 4,708 nodes and the inward set 4,024 β€” a 15% gap.

The direction you need follows from the question. A hospital catchment is inward: patients travelling to it. A fire station's coverage is outward: appliances travelling from it. Getting it backwards produces a polygon that is systematically wrong in a way no visual check will reveal.

Why the convex hull always overstates

A convex hull is the smallest convex polygon containing a set of points. "Convex" means no dents β€” so any concavity in the reachable area is filled in.

Real isochrones are full of concavities. A river with two bridges produces two fingers of reachable land and a large unreachable wedge between them; the hull covers the wedge. A railway, a motorway with no junction, a park with no through road β€” all the same.

The measured overstatement grew from 6% at one minute to 29% at two, 49% at three, 51% at four and 60% at five. The pattern is intuitive: a small isochrone is a blob and roughly convex; a large one is a starfish, and the hull is the circle around it.

Convex hulls are fast, always valid and never right for this. Use them for a quick look and never for a reported area.

Nodes reached and area covered travelling outward from a centre against inward to it, in five minutes. same centre, same five minutesoutward β€” service areainward β€” catchmentnodes reached4,7084,024area covered17.81 kmΒ²15.32 kmΒ²nx.ego_graph gives you the first column; a catchment needs G.reverse() spatialworkflow.io
Same centre, same five minutes, 15% fewer nodes inward. Catchments need the inward version.

Why the polygon is an interpolation, not a measurement

The traversal tells you the travel time to each node β€” a junction. It says nothing about the land between junctions, and land is what an isochrone claims to describe.

Every method for filling that gap is an interpolation with an assumption in it:

  • Node buffer assumes land within r of a reachable junction is served.
  • Edge buffer is better β€” buffer the reachable edges rather than their endpoints, so a long street contributes along its whole length.
  • Convex hull assumes everything between reachable points is reachable, which is false.
  • Alpha shape assumes concavities smaller than alpha are not real.

Edge buffering is the most defensible for a road network, because the served land really is a corridor along the streets. It also handles the case a node buffer gets badly wrong: a single 1 km edge with reachable endpoints, where node buffering leaves a hole in the middle of a street you can drive down.

Why isochrones are comparative, not predictive

Every input is an estimate. Speeds are imputed from highway type; turn delays and signals are ignored entirely; congestion does not exist; the polygon method is a choice; and the buffer radius is arbitrary.

Stack those and an "8-minute isochrone" is not a claim that an ambulance arrives in 8 minutes. It is a claim that this area is better connected to that point than the area outside it.

That is still useful β€” comparing sites, ranking accessibility, finding underserved areas β€” because the biases apply equally across the comparison. It stops being useful the moment someone reads the boundary as a service-level guarantee.

For an actual prediction you need a routing engine with a traffic model, turn restrictions and time-of-day profiles.

ego_graph direction, radius units and nesting

  • nx.ego_graph follows outgoing edges on a directed graph. Pass G.reverse() for an inward isochrone.
  • radius is in the units of distance β€” seconds for travel_time, metres for length.
  • Convex hulls overstated by 6–60% here, growing with the isochrone size.
  • Buffer the edges, not just the nodes, or long streets get a hole in the middle.
  • Imputed speeds are free-flow. No congestion, no signals, no turn delays.
  • The graph must extend beyond the isochrone, or the boundary is the download edge rather than the travel time.
  • Walking isochrones need the walk network β€” the drive network has no footpaths and imposes one-way restrictions pedestrians ignore.
  • Nested isochrones should be built together so the 5-minute polygon is guaranteed inside the 10-minute one.

FAQ

What is an isochrone?

The area reachable within a given travel time from (or to) a point. It is built in two stages: a graph traversal, then a choice of how to turn reachable nodes into a polygon.

Why do inward and outward isochrones differ?

One-way streets. A 5-minute drive from Manchester Town Hall reached 4,708 nodes outward and 4,024 inward β€” a 15% gap. Catchments need the inward version, which requires G.reverse().

Should I use a convex hull?

No, except for a quick look. It fills in rivers, railways and every other concavity, overstating the area by 6–60% from Manchester Town Hall depending on the isochrone size.

What is the best way to build the polygon?

Buffer the reachable edges by about half the typical street spacing and dissolve. It follows the network into its fingers and leaves genuine holes where they exist.

Are isochrone travel times realistic?

No. They come from imputed free-flow speeds and ignore congestion, signals and turn delays. Treat isochrones as comparative, not predictive.

What buffer radius should I use?

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

Can I compare two isochrones from different sources?

Only if both state their direction, mode, speed source, polygon method and buffer radius. Two 15-minute isochrones built differently can differ in area by half.